//go:build !ios package dns import ( "bufio" "bytes" "fmt" "io" "net" "net/netip" "os/exec" "strconv" "strings" log "github.com/sirupsen/logrus" ) const ( netbirdDNSStateKeyFormat = "State:/Network/Service/NetBird-%s/DNS" globalIPv4State = "State:/Network/Global/IPv4" primaryServiceStateKeyFormat = "State:/Network/Service/%s/DNS" keySupplementalMatchDomains = "SupplementalMatchDomains" keySupplementalMatchDomainsNoSearch = "SupplementalMatchDomainsNoSearch" keyServerAddresses = "ServerAddresses" keyServerPort = "ServerPort" arraySymbol = "* " digitSymbol = "# " scutilPath = "/usr/sbin/scutil" searchSuffix = "Search" matchSuffix = "Match" localSuffix = "Local" ) type systemConfigurator struct { createdKeys map[string]struct{} systemDNSSettings SystemDNSSettings } func newHostManager() (hostManager, error) { return &systemConfigurator{ createdKeys: make(map[string]struct{}), }, nil } func (s *systemConfigurator) supportCustomPort() bool { return true } func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig) error { var err error // create a file for unclean shutdown detection if err := createUncleanShutdownIndicator(); err != nil { log.Errorf("failed to create unclean shutdown file: %s", err) } var ( searchDomains []string matchDomains []string ) err = s.recordSystemDNSSettings(true) if err != nil { log.Errorf("unable to update record of System's DNS config: %s", err.Error()) } if config.RouteAll { searchDomains = append(searchDomains, "\"\"") err = s.addLocalDNS() if err != nil { log.Infof("failed to enable split DNS") } } for _, dConf := range config.Domains { if dConf.Disabled { continue } if dConf.MatchOnly { matchDomains = append(matchDomains, dConf.Domain) continue } searchDomains = append(searchDomains, dConf.Domain) } matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix) if len(matchDomains) != 0 { err = s.addMatchDomains(matchKey, strings.Join(matchDomains, " "), config.ServerIP, config.ServerPort) } else { log.Infof("removing match domains from the system") err = s.removeKeyFromSystemConfig(matchKey) } if err != nil { return fmt.Errorf("add match domains: %w", err) } searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix) if len(searchDomains) != 0 { err = s.addSearchDomains(searchKey, strings.Join(searchDomains, " "), config.ServerIP, config.ServerPort) } else { log.Infof("removing search domains from the system") err = s.removeKeyFromSystemConfig(searchKey) } if err != nil { return fmt.Errorf("add search domains: %w", err) } return nil } func (s *systemConfigurator) restoreHostDNS() error { keys := s.getRemovableKeysWithDefaults() for _, key := range keys { keyType := "search" if strings.Contains(key, matchSuffix) { keyType = "match" } log.Infof("removing %s domains from system", keyType) err := s.removeKeyFromSystemConfig(key) if err != nil { log.Errorf("failed to remove %s domains from system: %s", keyType, err) } } if err := removeUncleanShutdownIndicator(); err != nil { log.Errorf("failed to remove unclean shutdown file: %s", err) } return nil } func (s *systemConfigurator) getRemovableKeysWithDefaults() []string { if len(s.createdKeys) == 0 { // return defaults for startup calls return []string{getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix), getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)} } keys := make([]string, 0, len(s.createdKeys)) for key := range s.createdKeys { keys = append(keys, key) } return keys } func (s *systemConfigurator) removeKeyFromSystemConfig(key string) error { line := buildRemoveKeyOperation(key) _, err := runSystemConfigCommand(wrapCommand(line)) if err != nil { return fmt.Errorf("remove key: %w", err) } delete(s.createdKeys, key) return nil } func (s *systemConfigurator) addLocalDNS() error { if s.systemDNSSettings.ServerIP == "" || len(s.systemDNSSettings.Domains) == 0 { err := s.recordSystemDNSSettings(true) log.Errorf("Unable to get system DNS configuration") return err } localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix) if s.systemDNSSettings.ServerIP != "" && len(s.systemDNSSettings.Domains) != 0 { err := s.addSearchDomains(localKey, strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort) if err != nil { return fmt.Errorf("couldn't add local network DNS conf: %w", err) } } else { log.Info("Not enabling local DNS server") } return nil } func (s *systemConfigurator) recordSystemDNSSettings(force bool) error { if s.systemDNSSettings.ServerIP != "" && len(s.systemDNSSettings.Domains) != 0 && !force { return nil } systemDNSSettings, err := s.getSystemDNSSettings() if err != nil { return fmt.Errorf("couldn't get current DNS config: %w", err) } s.systemDNSSettings = systemDNSSettings return nil } func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { primaryServiceKey, _, err := s.getPrimaryService() if err != nil || primaryServiceKey == "" { return SystemDNSSettings{}, fmt.Errorf("couldn't find the primary service key: %w", err) } dnsServiceKey := getKeyWithInput(primaryServiceStateKeyFormat, primaryServiceKey) line := buildCommandLine("show", dnsServiceKey, "") stdinCommands := wrapCommand(line) b, err := runSystemConfigCommand(stdinCommands) if err != nil { return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err) } var dnsSettings SystemDNSSettings inSearchDomainsArray := false inServerAddressesArray := false scanner := bufio.NewScanner(bytes.NewReader(b)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) switch { case strings.HasPrefix(line, "DomainName :"): domainName := strings.TrimSpace(strings.Split(line, ":")[1]) dnsSettings.Domains = append(dnsSettings.Domains, domainName) case line == "SearchDomains : {": inSearchDomainsArray = true continue case line == "ServerAddresses : {": inServerAddressesArray = true continue case line == "}": inSearchDomainsArray = false inServerAddressesArray = false } if inSearchDomainsArray { searchDomain := strings.Split(line, " : ")[1] dnsSettings.Domains = append(dnsSettings.Domains, searchDomain) } else if inServerAddressesArray { address := strings.Split(line, " : ")[1] if ip := net.ParseIP(address); ip != nil && ip.To4() != nil { dnsSettings.ServerIP = address inServerAddressesArray = false // Stop reading after finding the first IPv4 address } } } if err := scanner.Err(); err != nil { return dnsSettings, err } // default to 53 port dnsSettings.ServerPort = 53 return dnsSettings, nil } func (s *systemConfigurator) addSearchDomains(key, domains string, ip string, port int) error { err := s.addDNSState(key, domains, ip, port, true) if err != nil { return fmt.Errorf("add dns state: %w", err) } log.Infof("added %d search domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) s.createdKeys[key] = struct{}{} return nil } func (s *systemConfigurator) addMatchDomains(key, domains, dnsServer string, port int) error { err := s.addDNSState(key, domains, dnsServer, port, false) if err != nil { return fmt.Errorf("add dns state: %w", err) } log.Infof("added %d match domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) s.createdKeys[key] = struct{}{} return nil } func (s *systemConfigurator) addDNSState(state, domains, dnsServer string, port int, enableSearch bool) error { noSearch := "1" if enableSearch { noSearch = "0" } lines := buildAddCommandLine(keySupplementalMatchDomains, arraySymbol+domains) lines += buildAddCommandLine(keySupplementalMatchDomainsNoSearch, digitSymbol+noSearch) lines += buildAddCommandLine(keyServerAddresses, arraySymbol+dnsServer) lines += buildAddCommandLine(keyServerPort, digitSymbol+strconv.Itoa(port)) addDomainCommand := buildCreateStateWithOperation(state, lines) stdinCommands := wrapCommand(addDomainCommand) _, err := runSystemConfigCommand(stdinCommands) if err != nil { return fmt.Errorf("applying state for domains %s, error: %w", domains, err) } return nil } func (s *systemConfigurator) getPrimaryService() (string, string, error) { line := buildCommandLine("show", globalIPv4State, "") stdinCommands := wrapCommand(line) b, err := runSystemConfigCommand(stdinCommands) if err != nil { return "", "", fmt.Errorf("sending the command: %w", err) } scanner := bufio.NewScanner(bytes.NewReader(b)) primaryService := "" router := "" for scanner.Scan() { text := scanner.Text() if strings.Contains(text, "PrimaryService") { primaryService = strings.TrimSpace(strings.Split(text, ":")[1]) } if strings.Contains(text, "Router") { router = strings.TrimSpace(strings.Split(text, ":")[1]) } } if err := scanner.Err(); err != nil && err != io.EOF { return primaryService, router, fmt.Errorf("scan: %w", err) } return primaryService, router, nil } func (s *systemConfigurator) restoreUncleanShutdownDNS(*netip.Addr) error { if err := s.restoreHostDNS(); err != nil { return fmt.Errorf("restoring dns via scutil: %w", err) } return nil } func getKeyWithInput(format, key string) string { return fmt.Sprintf(format, key) } func buildAddCommandLine(key, value string) string { return buildCommandLine("d.add", key, value) } func buildCommandLine(action, key, value string) string { return fmt.Sprintf("%s %s %s\n", action, key, value) } func wrapCommand(commands string) string { return fmt.Sprintf("open\n%s\nquit\n", commands) } func buildRemoveKeyOperation(key string) string { return fmt.Sprintf("remove %s\n", key) } func buildCreateStateWithOperation(state, commands string) string { return buildWriteStateOperation("set", state, commands) } func buildWriteStateOperation(operation, state, commands string) string { return fmt.Sprintf("d.init\n%s %s\n%s\nset %s\n", operation, state, commands, state) } func runSystemConfigCommand(command string) ([]byte, error) { cmd := exec.Command(scutilPath) cmd.Stdin = strings.NewReader(command) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("running system configuration command: \"%s\", error: %w", command, err) } return out, nil }