Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-04 16:41:54 -05:00
parent 8ee8e5e956
commit e8f8574053
34 changed files with 7164 additions and 49 deletions
+8
View File
@@ -0,0 +1,8 @@
//go:build darwin
package platform
// NewNetworkDiscoverer returns the macOS-specific NetworkDiscoverer.
func NewNetworkDiscoverer() NetworkDiscoverer {
return &DarwinNetworkDiscoverer{}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build linux
package platform
// NewNetworkDiscoverer returns the Linux-specific NetworkDiscoverer.
func NewNetworkDiscoverer() NetworkDiscoverer {
return &LinuxNetworkDiscoverer{}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package platform
// NewNetworkDiscoverer returns the Windows-specific NetworkDiscoverer.
func NewNetworkDiscoverer() NetworkDiscoverer {
return &WindowsNetworkDiscoverer{}
}
+26
View File
@@ -0,0 +1,26 @@
package platform
// NetworkInfo holds discovered local network configuration from the
// system's default-route network adapter.
type NetworkInfo struct {
DNSServers []string // Ordered DNS server IPs from the default-route adapter
Gateway string // Default gateway IP (may be empty if not discoverable)
Interface string // Adapter name holding the default route (informational)
}
// NetworkDiscoverer discovers the local network configuration.
type NetworkDiscoverer interface {
Discover() (NetworkInfo, error)
}
// FakeNetworkDiscoverer returns predetermined NetworkInfo for testing.
// It is exported so packages outside of platform can use it in tests.
type FakeNetworkDiscoverer struct {
Info NetworkInfo
Err error
}
// Discover implements NetworkDiscoverer. Returns the pre-configured Info and Err.
func (f *FakeNetworkDiscoverer) Discover() (NetworkInfo, error) {
return f.Info, f.Err
}
+169
View File
@@ -0,0 +1,169 @@
//go:build darwin
package platform
import (
"bufio"
"fmt"
"net"
"os/exec"
"strings"
)
// DarwinNetworkDiscoverer discovers network configuration using route(8) and
// scutil(8). Both commands are available on every macOS version since 10.5.
type DarwinNetworkDiscoverer struct{}
// Discover returns the default gateway IP and DNS server list for this host.
func (d *DarwinNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: Determine default gateway and interface via "route -n get default".
gateway, iface, err := darwinDefaultRoute()
if err != nil {
return info, fmt.Errorf("reading default route: %w", err)
}
info.Gateway = gateway
info.Interface = iface
// Step 2: Extract DNS servers from scutil --dns, matching the interface.
servers, err := darwinDNSServers(iface)
if err != nil {
// Non-fatal — return what we have.
return info, nil
}
info.DNSServers = servers
return info, nil
}
// darwinDefaultRoute runs "route -n get default" and returns the gateway IP
// and interface name for the default route.
func darwinDefaultRoute() (gateway, iface string, err error) {
out, err := exec.Command("route", "-n", "get", "default").Output()
if err != nil {
return "", "", fmt.Errorf("route -n get default: %w", err)
}
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "gateway:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
ip := fields[1]
if net.ParseIP(ip) != nil {
gateway = ip
}
}
}
if strings.HasPrefix(line, "interface:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
iface = fields[1]
}
}
}
if gateway == "" {
return "", "", fmt.Errorf("no default gateway found")
}
return gateway, iface, nil
}
// darwinDNSServers runs "scutil --dns" and extracts DNS server IPs from the
// resolver block that matches iface. Falls back to resolver #1 if no match.
func darwinDNSServers(iface string) ([]string, error) {
out, err := exec.Command("scutil", "--dns").Output()
if err != nil {
return nil, fmt.Errorf("scutil --dns: %w", err)
}
type resolverBlock struct {
nameservers []string
ifaceMatch bool
isMDNS bool
number int
}
var blocks []resolverBlock
var current *resolverBlock
inScopedSection := false
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := scanner.Text()
stripped := strings.TrimSpace(line)
// Detect the "scoped queries" section — stop parsing there.
if strings.Contains(line, "DNS configuration (for scoped queries)") {
inScopedSection = true
}
if inScopedSection {
continue
}
// Start of a new resolver block.
if strings.HasPrefix(stripped, "resolver #") {
if current != nil {
blocks = append(blocks, *current)
}
num := 0
fmt.Sscanf(stripped, "resolver #%d", &num)
current = &resolverBlock{number: num}
continue
}
if current == nil {
continue
}
// Parse nameserver entries.
if strings.HasPrefix(stripped, "nameserver[") {
// Format: "nameserver[0] : 8.8.8.8"
idx := strings.Index(stripped, ": ")
if idx >= 0 {
ip := strings.TrimSpace(stripped[idx+2:])
if net.ParseIP(ip) != nil {
current.nameservers = append(current.nameservers, ip)
}
}
continue
}
// Check for interface match via if_index line: "if_index : 5 (en0)"
if strings.HasPrefix(stripped, "if_index") && iface != "" {
if strings.Contains(stripped, "("+iface+")") {
current.ifaceMatch = true
}
continue
}
// Detect mDNS blocks: domain + options lines.
if strings.HasPrefix(stripped, "domain") && strings.Contains(stripped, ": local") {
current.isMDNS = true
}
if strings.HasPrefix(stripped, "options") && strings.Contains(stripped, "mdns") {
current.isMDNS = true
}
}
if current != nil {
blocks = append(blocks, *current)
}
// Find the best block: interface-matching, non-mDNS.
for _, b := range blocks {
if b.ifaceMatch && !b.isMDNS && len(b.nameservers) > 0 {
return b.nameservers, nil
}
}
// Fallback: use resolver #1 if it's not mDNS.
for _, b := range blocks {
if b.number == 1 && !b.isMDNS && len(b.nameservers) > 0 {
return b.nameservers, nil
}
}
return nil, fmt.Errorf("no usable DNS resolver block found in scutil output")
}
+200
View File
@@ -0,0 +1,200 @@
//go:build linux
package platform
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"strings"
)
// LinuxNetworkDiscoverer discovers network configuration using ip(8) and
// /etc/resolv.conf. On systemd-resolved systems it falls back to resolvectl(1)
// when all nameservers are stub addresses.
type LinuxNetworkDiscoverer struct{}
// stubAddrs are local stub resolver addresses used by systemd-resolved.
var stubAddrs = map[string]bool{
"127.0.0.53": true,
"127.0.0.1": true,
"127.0.1.1": true,
}
// Discover returns the default gateway and DNS server list for this host.
func (d *LinuxNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: Determine default gateway and interface via "ip route show default".
gateway, iface, err := linuxDefaultRoute()
if err != nil {
return info, fmt.Errorf("reading default route: %w", err)
}
info.Gateway = gateway
info.Interface = iface
// Step 2: Parse /etc/resolv.conf for nameserver lines.
servers, err := linuxResolvConf()
if err != nil {
// A missing resolv.conf is not fatal — return empty list.
return info, nil
}
// Step 3: If all servers are stub addresses, try resolvectl for upstream IPs.
if len(servers) > 0 && allStubs(servers) && iface != "" {
if upstream, err := linuxResolvectl(iface); err == nil && len(upstream) > 0 {
servers = upstream
}
}
info.DNSServers = servers
return info, nil
}
// linuxDefaultRoute parses "ip route show default" and returns the gateway IP
// and interface name for the best (lowest metric) default route.
func linuxDefaultRoute() (gateway, iface string, err error) {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return "", "", fmt.Errorf("ip route show default: %w", err)
}
bestMetric := -1
bestGW := ""
bestIface := ""
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "default" {
continue
}
// Parse by keyword: "via <ip>" and "dev <iface>" and optional "metric <N>".
var gw, dev string
metric := 0
for i := 1; i < len(fields)-1; i++ {
switch fields[i] {
case "via":
gw = fields[i+1]
case "dev":
dev = fields[i+1]
case "metric":
if n, err := parseInt(fields[i+1]); err == nil {
metric = n
}
}
}
if gw == "" || net.ParseIP(gw) == nil {
continue
}
if bestMetric < 0 || metric < bestMetric {
bestMetric = metric
bestGW = gw
bestIface = dev
}
}
if bestGW == "" {
return "", "", fmt.Errorf("no default route found")
}
return bestGW, bestIface, nil
}
// linuxResolvConf reads /etc/resolv.conf and returns the nameserver IPs.
func linuxResolvConf() ([]string, error) {
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return nil, err
}
defer f.Close()
var servers []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if !strings.HasPrefix(line, "nameserver") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
ip := fields[1]
if net.ParseIP(ip) != nil {
servers = append(servers, ip)
}
}
return servers, nil
}
// linuxResolvectl runs "resolvectl status <iface>" and returns the upstream DNS
// servers. Returns an error if resolvectl is not available.
func linuxResolvectl(iface string) ([]string, error) {
path, err := exec.LookPath("resolvectl")
if err != nil {
return nil, fmt.Errorf("resolvectl not found: %w", err)
}
out, err := exec.Command(path, "status", iface).Output()
if err != nil {
return nil, fmt.Errorf("resolvectl status %s: %w", iface, err)
}
// Parse "DNS Servers: <ip> <ip>..." and continuation lines.
var servers []string
inDNS := false
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := scanner.Text()
stripped := strings.TrimSpace(line)
if strings.HasPrefix(stripped, "DNS Servers:") {
inDNS = true
// Extract IPs from the same line after the label.
rest := strings.TrimPrefix(stripped, "DNS Servers:")
for _, f := range strings.Fields(rest) {
if net.ParseIP(f) != nil {
servers = append(servers, f)
}
}
continue
}
if inDNS {
// Continuation lines are indented; stop at a non-indented line.
if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
break
}
for _, f := range strings.Fields(stripped) {
if net.ParseIP(f) != nil {
servers = append(servers, f)
}
}
}
}
return servers, nil
}
// allStubs reports whether every address in servers is a known stub resolver.
func allStubs(servers []string) bool {
for _, s := range servers {
if !stubAddrs[s] {
return false
}
}
return true
}
func parseInt(s string) (int, error) {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("not an int: %q", s)
}
n = n*10 + int(c-'0')
}
return n, nil
}
+67
View File
@@ -0,0 +1,67 @@
package platform_test
import (
"errors"
"testing"
"dns-helper/platform"
)
func TestFakeNetworkDiscoverer_ReturnsInfo(t *testing.T) {
fake := &platform.FakeNetworkDiscoverer{
Info: platform.NetworkInfo{
DNSServers: []string{"10.0.0.1", "10.0.0.2"},
Gateway: "10.0.0.1",
Interface: "eth0",
},
}
info, err := fake.Discover()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(info.DNSServers) != 2 {
t.Errorf("expected 2 DNS servers, got %d", len(info.DNSServers))
}
if info.DNSServers[0] != "10.0.0.1" {
t.Errorf("DNSServers[0]: got %q, want %q", info.DNSServers[0], "10.0.0.1")
}
if info.DNSServers[1] != "10.0.0.2" {
t.Errorf("DNSServers[1]: got %q, want %q", info.DNSServers[1], "10.0.0.2")
}
if info.Gateway != "10.0.0.1" {
t.Errorf("Gateway: got %q, want %q", info.Gateway, "10.0.0.1")
}
if info.Interface != "eth0" {
t.Errorf("Interface: got %q, want %q", info.Interface, "eth0")
}
}
func TestFakeNetworkDiscoverer_ReturnsError(t *testing.T) {
fake := &platform.FakeNetworkDiscoverer{
Err: errors.New("discovery failed"),
}
_, err := fake.Discover()
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "discovery failed" {
t.Errorf("error message: got %q, want %q", err.Error(), "discovery failed")
}
}
func TestFakeNetworkDiscoverer_EmptyInfo(t *testing.T) {
fake := &platform.FakeNetworkDiscoverer{}
info, err := fake.Discover()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(info.DNSServers) != 0 {
t.Errorf("expected empty DNSServers, got %v", info.DNSServers)
}
if info.Gateway != "" {
t.Errorf("expected empty Gateway, got %q", info.Gateway)
}
}
+151
View File
@@ -0,0 +1,151 @@
//go:build windows
package platform
import (
"bufio"
"fmt"
"net"
"os/exec"
"regexp"
"strconv"
"strings"
)
// WindowsNetworkDiscoverer discovers network configuration using netsh commands.
// It does not require elevated privileges or PowerShell.
type WindowsNetworkDiscoverer struct{}
var ipv4Re = regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})`)
var dnsLineRe = regexp.MustCompile(`^\s*(?:\S.*:\s+)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$`)
// Discover returns the gateway IP and DNS servers for the default IPv4 route.
func (d *WindowsNetworkDiscoverer) Discover() (NetworkInfo, error) {
info := NetworkInfo{}
// Step 1: Find default route — extract interface index and gateway IP.
ifIdx, gateway, err := windowsDefaultRoute()
if err != nil {
return info, fmt.Errorf("reading default IPv4 route: %w", err)
}
info.Gateway = gateway
// Step 2: Map interface index to name.
ifName, err := windowsInterfaceName(ifIdx)
if err != nil {
return info, fmt.Errorf("resolving interface name for index %d: %w", ifIdx, err)
}
info.Interface = ifName
// Step 3: Enumerate DNS servers for that interface.
servers, err := windowsDNSServers(ifName)
if err != nil {
return info, fmt.Errorf("reading DNS servers for interface %q: %w", ifName, err)
}
info.DNSServers = servers
return info, nil
}
// windowsDefaultRoute runs "netsh interface ipv4 show route" and returns the
// interface index and gateway IP for the best (lowest-metric) 0.0.0.0/0 route.
func windowsDefaultRoute() (ifIdx int, gateway string, err error) {
out, err := exec.Command("netsh", "interface", "ipv4", "show", "route").Output()
if err != nil {
return 0, "", fmt.Errorf("netsh interface ipv4 show route: %w", err)
}
bestMetric := -1
bestIdx := 0
bestGateway := ""
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "0.0.0.0/0") {
continue
}
// Format: <Publish> <Type> <Metric> <Prefix> <Idx> <Gateway or Iface Name...>
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
metric, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
idx, err := strconv.Atoi(fields[4])
if err != nil {
continue
}
// Gateway field is field 5+ — join and extract first IPv4.
gatewayField := strings.Join(fields[5:], " ")
m := ipv4Re.FindString(gatewayField)
if m == "" {
continue
}
if bestMetric < 0 || metric < bestMetric {
bestMetric = metric
bestIdx = idx
bestGateway = m
}
}
if bestIdx == 0 {
return 0, "", fmt.Errorf("no default IPv4 route found")
}
return bestIdx, bestGateway, nil
}
// windowsInterfaceName runs "netsh interface ipv4 show interfaces" and maps
// the given interface index to the interface name.
func windowsInterfaceName(idx int) (string, error) {
out, err := exec.Command("netsh", "interface", "ipv4", "show", "interfaces").Output()
if err != nil {
return "", fmt.Errorf("netsh interface ipv4 show interfaces: %w", err)
}
idxStr := strconv.Itoa(idx)
scanner := bufio.NewScanner(strings.NewReader(string(out)))
// Skip two header lines.
for i := 0; i < 2; i++ {
scanner.Scan()
}
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
if fields[0] != idxStr {
continue
}
// Name is fields[4:] joined — may contain spaces.
return strings.Join(fields[4:], " "), nil
}
return "", fmt.Errorf("interface index %d not found", idx)
}
// windowsDNSServers runs "netsh interface ipv4 show dnsservers name=<ifName>"
// and returns all IPv4 DNS server addresses in order.
func windowsDNSServers(ifName string) ([]string, error) {
out, err := exec.Command("netsh", "interface", "ipv4", "show", "dnsservers",
"name="+ifName).Output()
if err != nil {
return nil, fmt.Errorf("netsh interface ipv4 show dnsservers: %w", err)
}
var servers []string
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
m := dnsLineRe.FindStringSubmatch(scanner.Text())
if len(m) < 2 {
continue
}
ip := m[1]
if net.ParseIP(ip) != nil {
servers = append(servers, ip)
}
}
return servers, nil
}