Files
2026-06-27 09:42:29 -07:00

288 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package netcfg
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/vishvananda/netlink"
"go4.org/netipx"
)
var defaultDst = func() *net.IPNet {
_, net, err := net.ParseCIDR("0.0.0.0/0")
if err != nil {
log.Fatal(err)
}
return net
}()
func priorityFromName(ifname string) int {
if strings.HasPrefix(ifname, "eth") {
return 1
}
return 5 // wlan0 and others
}
func applyCFG(nl *netlink.Handle, ifname string, source string, cfg Config, extraRoutePriority int) error {
// Log the received DHCPACK packet:
details := []string{
fmt.Sprintf("IP %v", cfg.CIDR),
}
if len(cfg.Router) > 0 {
details = append(details, fmt.Sprintf("router %v", cfg.Router))
}
if len(cfg.DNS) > 0 {
details = append(details, fmt.Sprintf("DNS %v", cfg.DNS))
}
log.Printf("%s: %v", source, strings.Join(details, ", "))
l, err := nl.LinkByName(ifname)
if err != nil {
return fmt.Errorf("LinkByName: %v", err)
}
// Apply the received settings:
if err := nl.AddrReplace(l, &netlink.Addr{IPNet: netipx.PrefixIPNet(cfg.CIDR)}); err != nil {
return fmt.Errorf("AddrReplace: %v", err)
}
if l.Attrs().OperState != netlink.OperUp {
if err := nl.LinkSetUp(l); err != nil {
return fmt.Errorf("LinkSetUp: %v", err)
}
}
// Adjust the priority of the network routes on this interface; the kernel
// adds at least one based on the configured address.
if err := changeRoutePriority(nl, l, priorityFromName(ifname)+extraRoutePriority); err != nil {
return fmt.Errorf("changeRoutePriority: %v", err)
}
if r := cfg.Router; len(r) > 0 {
err = nl.RouteReplace(&netlink.Route{
LinkIndex: l.Attrs().Index,
Dst: defaultDst,
Gw: r,
Priority: priorityFromName(ifname) + extraRoutePriority,
})
if err != nil {
return fmt.Errorf("RouteReplace: %v", err)
}
}
if len(cfg.DNS) > 0 {
if err := writeResolvConf(cfg); err != nil {
return fmt.Errorf("writing resolv.conf: %v", err)
}
}
// Notify init of new addresses
p, _ := os.FindProcess(1)
if err := p.Signal(syscall.SIGHUP); err != nil {
log.Printf("send SIGHUP to init: %v", err)
}
return nil
}
func writeResolvConf(lease Config) error {
const resolvConf = "/tmp/resolv.conf"
// Has another program (e.g. tailscale) replaced our resolv.conf?
// Note: at boot, /tmp/resolv.conf is a symlink to /proc/net/pnp,
// which contains no “generated by” marker.
if b, err := os.ReadFile(resolvConf); err == nil {
if bytes.Contains(b, []byte("generated by")) &&
!bytes.Contains(b, []byte("generated by netcfg")) {
log.Printf("%s updated outside of netcfg, not updating", resolvConf)
return nil
}
}
lines := []string{
"# generated by netcfg",
}
if domain := lease.Domain; domain != "" {
lines = append(lines, fmt.Sprintf("domain %s", domain))
lines = append(lines, fmt.Sprintf("search %s", domain))
}
for _, ns := range lease.DNS {
lines = append(lines, fmt.Sprintf("nameserver %v", ns))
}
if err := os.WriteFile(resolvConf, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
return fmt.Errorf("resolv.conf: %v", err)
}
return nil
}
func changeRoutePriority(nl *netlink.Handle, l netlink.Link, priority int) error {
routes, err := nl.RouteList(l, netlink.FAMILY_V4)
if err != nil {
return fmt.Errorf("netlink.RouteList: %v", err)
}
for _, route := range routes {
if route.Priority == priority {
continue // no change necessary
}
newRoute := route // copy
log.Printf("adjusting route [dst=%v src=%v gw=%v] priority to %d", route.Dst, route.Src, route.Gw, priority)
newRoute.Flags = 0 // prevent "invalid argument" error
newRoute.Priority = priority
if err := nl.RouteReplace(&newRoute); err != nil {
return fmt.Errorf("RouteReplace: %v", err)
}
if err := nl.RouteDel(&route); err != nil {
return fmt.Errorf("RouteDel: %v", err)
}
}
return nil
}
func deprioritizeRoutesWhenDown(nl *netlink.Handle, ifname string, extraRoutePriority int) {
last := netlink.LinkOperState(netlink.OperUp)
for range time.Tick(1 * time.Second) {
l, err := nl.LinkByName(ifname)
if err != nil {
log.Printf("netlink.LinkByName: %v", err)
continue
}
operState := l.Attrs().OperState
if last == operState {
continue // no change
}
last = operState
if operState == netlink.OperDown {
log.Printf("lost carrier on interface %s, de-prioritizing routes", ifname)
if err := changeRoutePriority(nl, l, 1024); err != nil {
log.Print(err)
}
} else {
log.Printf("regained carrier on interface %s, re-prioritizing routes", ifname)
if err := changeRoutePriority(nl, l, priorityFromName(ifname)+extraRoutePriority); err != nil {
log.Print(err)
}
}
}
}
func waitForInterface(ifname string) {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
log.Printf("waiting indefinitely for %s to appear", ifname)
for range t.C {
if _, err := net.InterfaceByName(ifname); err == nil {
return
}
}
}
type Config struct {
CIDR netip.Prefix
Router net.IP
VlanID int
DNS []net.IP
Domain string
}
func Main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
var (
ifname = flag.String(
"interface",
"eth0",
"network interface to obtain a DHCP lease on")
staticConfig = flag.String(
"static_network_config",
"",
"network configuration to apply instead of querying DHCP")
extraRoutePriority = flag.Int(
"extra_route_priority",
0,
"extra value to add to the interfaces priority (eth* defaults to priority 1, wlan* defaults to priority 5, interfaces without link are set to priority 1024)")
)
flag.Parse()
// NOTE: cannot gokrazy.WaitForClock() here, since the clock can only be
// initialized once the network is up.
waitForInterface(*ifname)
nl, err := netlink.NewHandle(netlink.FAMILY_V4)
if err != nil {
log.Fatal(err)
}
go deprioritizeRoutesWhenDown(nl, *ifname, *extraRoutePriority)
// Ensure the interface is up.
link, err := nl.LinkByName(*ifname)
if err != nil {
log.Fatalf("LinkByName: %v", err)
}
if link.Attrs().OperState != netlink.OperUp {
if err := nl.LinkSetUp(link); err != nil {
log.Fatalf("LinkSetUp: %v", err)
}
}
// Wait for up to 10 seconds for the link to indicate it has a
// carrier.
for range 10 {
b, err := os.ReadFile(filepath.Join("/sys/class/net", *ifname, "carrier"))
if err == nil && strings.TrimSpace(string(b)) == "1" {
break
}
time.Sleep(1 * time.Second)
}
var sc []byte
var lsrc string
// Simple heuristic to check if we got JSON strings from the command line
if strings.HasPrefix(strings.TrimSpace(*staticConfig), "{") {
sc = []byte(*staticConfig)
lsrc = "-static_network_config <string>"
} else {
sc, err = os.ReadFile(*staticConfig)
if err != nil {
log.Fatal(err)
}
lsrc = fmt.Sprintf("-static_network_config=%s", *staticConfig)
}
var l Config
if err := json.Unmarshal(sc, &l); err != nil {
log.Fatal(err)
}
if l.Router != nil {
l.Router = l.Router.To4()
}
for idx, dns := range l.DNS {
l.DNS[idx] = dns.To4()
}
l.CIDR.Addr()
for {
if err := applyCFG(nl, *ifname, lsrc, l, *extraRoutePriority); err != nil {
log.Fatal(err)
}
log.Print("Static config applied successfully")
time.Sleep(15*time.Second)
}
}