// Package netcfg manages network configuration for the VPN tunnel: // TAP interface addressing, routing (default, static, policy), DNS, and // server host routes. package netcfg import ( "fmt" "log" "net" "os" "os/exec" "strings" "git.sagar.ch/sagar/softether-go/pkg/dhcp" ) // Options controls which DHCP-provided network settings to apply. type Options struct { DHCP bool AcceptDefaultGW bool AcceptStaticRoutes bool AcceptDNS bool PolicyRouteTable int } // ConfigureTAP sets the IP address, routes, and DNS on a TAP interface from a DHCP lease. // Returns a cleanup function that undoes the changes. func ConfigureTAP(ifname string, lease *dhcp.Lease, acceptDefaultGW, acceptStaticRoutes, acceptDNS bool) (func(), error) { noop := func() {} ones, _ := lease.SubnetMask.Size() addr := fmt.Sprintf("%s/%d", lease.ClientIP, ones) log.Printf("tap %s: adding address %s", ifname, addr) if err := run("ip", "addr", "add", addr, "dev", ifname); err != nil { return noop, fmt.Errorf("ip addr add: %w", err) } if acceptStaticRoutes && len(lease.Routes) > 0 { for _, r := range lease.Routes { dest := r.Dest.String() if dest == "0.0.0.0/0" { if acceptDefaultGW { log.Printf("tap %s: adding static default route via %s", ifname, r.Gateway) if err := run("ip", "route", "add", "default", "via", r.Gateway.String(), "dev", ifname, "metric", "50"); err != nil { log.Printf("warning: static default route: %v", err) } } else { log.Printf("tap %s: skipping static default route via %s (accept-default-gateway not set)", ifname, r.Gateway) } continue } log.Printf("tap %s: adding static route %s via %s", ifname, dest, r.Gateway) if err := run("ip", "route", "add", dest, "via", r.Gateway.String(), "dev", ifname); err != nil { log.Printf("warning: static route %s via %s: %v", dest, r.Gateway, err) } } } if acceptDefaultGW && lease.Gateway != nil && !hasDefaultRoute(lease.Routes) { log.Printf("tap %s: adding default route via %s", ifname, lease.Gateway) if err := run("ip", "route", "add", "default", "via", lease.Gateway.String(), "dev", ifname, "metric", "50"); err != nil { log.Printf("warning: default route: %v", err) } } var savedResolv []byte if acceptDNS && len(lease.DNS) > 0 { log.Printf("tap %s: setting DNS servers %v", ifname, lease.DNS) savedResolv = backupResolv() writeResolv(lease.DNS) } cleanup := func() { log.Printf("tap %s: flushing addresses", ifname) run("ip", "addr", "flush", "dev", ifname) if savedResolv != nil { restoreResolv(savedResolv) } } return cleanup, nil } // ReconfigureTAP flushes the current TAP config and applies a new lease. // Used when DHCP renewal returns a different IP address. func ReconfigureTAP(ifname string, lease *dhcp.Lease, acceptDefaultGW, acceptStaticRoutes, acceptDNS bool) { log.Printf("tap %s: reconfiguring for new IP", ifname) run("ip", "addr", "flush", "dev", ifname) // Ignore errors — best effort reconfiguration if _, err := ConfigureTAP(ifname, lease, acceptDefaultGW, acceptStaticRoutes, acceptDNS); err != nil { log.Printf("warning: reconfigure tap: %v", err) } } // ConfigurePolicyRoute sets up policy routing so packets from the VPN IP are routed // back through the VPN gateway. Needed when the VPN server forwards ports to the // client — without it, reply packets use the default route instead of the VPN tunnel. func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int) func() { t := fmt.Sprintf("%d", table) clientIP := lease.ClientIP.String() gw := lease.Gateway.String() run("ip", "rule", "del", "table", t) run("ip", "route", "replace", "default", "via", gw, "dev", ifname, "table", t) if err := run("ip", "rule", "add", "from", clientIP, "table", t); err != nil { log.Printf("warning: policy rule: %v", err) } else { log.Printf("policy route: from %s via %s dev %s table %s", clientIP, gw, ifname, t) } return func() { run("ip", "rule", "del", "table", t) run("ip", "route", "del", "default", "table", t) log.Printf("policy route: cleaned up table %s", t) } } // AddServerRoute adds a /32 host route to the VPN server via the current default gateway. // This prevents routing loops when the VPN's default route is installed. // Returns a cleanup function that removes the route. func AddServerRoute(serverIP net.IP) func() { noop := func() {} if serverIP == nil { return noop } gw, dev := getDefaultGateway() if gw == nil { log.Println("warning: no default gateway found, skipping server route") return noop } route := serverIP.String() + "/32" args := []string{"route", "add", route, "via", gw.String()} if dev != "" { args = append(args, "dev", dev) } if err := run("ip", args...); err != nil { log.Printf("warning: add server route: %v", err) return noop } log.Printf("added route: %s via %s", route, gw) return func() { run("ip", "route", "del", route) log.Printf("removed route: %s", route) } } // ResolveHost resolves a hostname to an IPv4 address. If it's already an IP, returns it. func ResolveHost(host string) net.IP { if ip := net.ParseIP(host); ip != nil { return ip } ips, err := net.LookupIP(host) if err != nil { log.Printf("warning: could not resolve %s: %v", host, err) return nil } for _, ip := range ips { if ip.To4() != nil { return ip.To4() } } log.Printf("warning: no IPv4 address for %s", host) return nil } func hasDefaultRoute(routes []dhcp.Route) bool { for _, r := range routes { ones, bits := r.Dest.Mask.Size() if ones == 0 && bits == 32 { return true } } return false } const resolvPath = "/etc/resolv.conf" func backupResolv() []byte { data, err := os.ReadFile(resolvPath) if err != nil { log.Printf("warning: backup resolv.conf: %v", err) return nil } return data } func writeResolv(servers []net.IP) { var buf strings.Builder buf.WriteString("# Generated by softether-go\n") for _, ip := range servers { fmt.Fprintf(&buf, "nameserver %s\n", ip) } if err := os.WriteFile(resolvPath, []byte(buf.String()), 0644); err != nil { log.Printf("warning: write resolv.conf: %v", err) return } log.Printf("dns: set nameservers %v", servers) } func restoreResolv(saved []byte) { if err := os.WriteFile(resolvPath, saved, 0644); err != nil { log.Printf("warning: restore resolv.conf: %v", err) return } log.Println("dns: restored resolv.conf") } func getDefaultGateway() (net.IP, string) { out, err := exec.Command("ip", "route", "show", "default").Output() if err != nil { return nil, "" } fields := strings.Fields(string(out)) var gw net.IP var dev string for i, f := range fields { if f == "via" && i+1 < len(fields) { gw = net.ParseIP(fields[i+1]) } if f == "dev" && i+1 < len(fields) { dev = fields[i+1] } } return gw, dev } func run(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }