package dhcp import ( "encoding/json" "fmt" "os" "os/exec" "strings" "sync" ) const ( ConfigFile = "/etc/dnsmasq.d/nano-router-dhcp.conf" StateFile = "/var/lib/nano-router/dhcp.json" ) // Pool describes a DHCP pool tied to one interface/subnet. type Pool struct { Interface string `json:"interface"` Enabled bool `json:"enabled"` Subnet string `json:"subnet"` // e.g. "172.16.54.0" Netmask string `json:"netmask"` // e.g. "255.255.255.0" RangeStart string `json:"range_start"` // e.g. "172.16.54.100" RangeEnd string `json:"range_end"` // e.g. "172.16.54.200" Router string `json:"router"` // option routers (advertised gateway) DNS []string `json:"dns"` // option domain-name-servers LeaseTime int `json:"lease_time"` // seconds, 0 → 86400 } // Config is the top-level DHCP configuration persisted on disk. type Config struct { Enabled bool `json:"enabled"` Pools []Pool `json:"pools"` } // StaticBinding represents a DHCP host reservation (MAC → fixed IP). type StaticBinding struct { MAC string `json:"mac"` Host string `json:"host"` IP string `json:"ip"` } var mu sync.Mutex // IsInstalled reports whether dnsmasq binary is available. func IsInstalled() bool { _, err := exec.LookPath("dnsmasq") return err == nil } // Load reads the config from the state file. // Returns an empty config if the file does not exist yet. func Load() (*Config, error) { mu.Lock() defer mu.Unlock() data, err := os.ReadFile(StateFile) if err != nil { if os.IsNotExist(err) { return &Config{Pools: []Pool{}}, nil } return nil, fmt.Errorf("read state: %w", err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse state: %w", err) } if cfg.Pools == nil { cfg.Pools = []Pool{} } return &cfg, nil } // Save writes the config to the state file (JSON, not dnsmasq.conf). func Save(cfg *Config) error { mu.Lock() defer mu.Unlock() if err := os.MkdirAll("/var/lib/nano-router", 0755); err != nil { return fmt.Errorf("mkdir state dir: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(StateFile, data, 0644) } // WriteConfigs generates /etc/dnsmasq.d/nano-router-dhcp.conf. // dnsmasq is used in DHCP-only mode (port=0 disables DNS resolver). func WriteConfigs(cfg *Config) error { if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil { return fmt.Errorf("mkdir /etc/dnsmasq.d: %w", err) } var sb strings.Builder sb.WriteString("# Generated by nano-router — do not edit manually\n") sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") sb.WriteString("port=0\n") // disable DNS sb.WriteString("bind-interfaces\n") // only listen on specified interfaces sb.WriteString("\n") for _, pool := range cfg.Pools { if !pool.Enabled { continue } leaseTime := pool.LeaseTime if leaseTime <= 0 { leaseTime = 86400 } tag := pool.Interface // use interface name as tag for option scoping fmt.Fprintf(&sb, "# Pool: %s\n", pool.Interface) fmt.Fprintf(&sb, "interface=%s\n", pool.Interface) if pool.RangeStart != "" && pool.RangeEnd != "" { fmt.Fprintf(&sb, "dhcp-range=set:%s,%s,%s,%s,%ds\n", tag, pool.RangeStart, pool.RangeEnd, pool.Netmask, leaseTime) } if pool.Router != "" { fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:router,%s\n", tag, pool.Router) } if len(pool.DNS) > 0 { fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:dns-server,%s\n", tag, strings.Join(pool.DNS, ",")) } sb.WriteString("\n") } return os.WriteFile(ConfigFile, []byte(sb.String()), 0644) } // WriteConfigsWithBindings generates dnsmasq config including static host reservations. func WriteConfigsWithBindings(cfg *Config, bindings []StaticBinding) error { if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil { return fmt.Errorf("mkdir /etc/dnsmasq.d: %w", err) } var sb strings.Builder sb.WriteString("# Generated by nano-router — do not edit manually\n") sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") sb.WriteString("port=0\n") sb.WriteString("bind-interfaces\n") sb.WriteString("\n") for _, pool := range cfg.Pools { if !pool.Enabled { continue } leaseTime := pool.LeaseTime if leaseTime <= 0 { leaseTime = 86400 } tag := pool.Interface fmt.Fprintf(&sb, "# Pool: %s\n", pool.Interface) fmt.Fprintf(&sb, "interface=%s\n", pool.Interface) if pool.RangeStart != "" && pool.RangeEnd != "" { fmt.Fprintf(&sb, "dhcp-range=set:%s,%s,%s,%s,%ds\n", tag, pool.RangeStart, pool.RangeEnd, pool.Netmask, leaseTime) } if pool.Router != "" { fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:router,%s\n", tag, pool.Router) } if len(pool.DNS) > 0 { fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:dns-server,%s\n", tag, strings.Join(pool.DNS, ",")) } sb.WriteString("\n") } if len(bindings) > 0 { sb.WriteString("# Static host reservations\n") for _, b := range bindings { if b.MAC == "" || b.IP == "" { continue } if b.Host != "" { fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s,infinite\n", b.MAC, b.IP, b.Host) } else { fmt.Fprintf(&sb, "dhcp-host=%s,%s,infinite\n", b.MAC, b.IP) } } sb.WriteString("\n") } return os.WriteFile(ConfigFile, []byte(sb.String()), 0644) } // ServiceStatus returns true if dnsmasq is running. func ServiceStatus() bool { return exec.Command("rc-service", "dnsmasq", "status").Run() == nil } // ServiceRestart restarts the dnsmasq service. func ServiceRestart() error { out, err := exec.Command("rc-service", "dnsmasq", "restart").CombinedOutput() if err != nil { return fmt.Errorf("restart dnsmasq: %s", strings.TrimSpace(string(out))) } return nil } // ServiceStop stops the dnsmasq service. func ServiceStop() error { out, err := exec.Command("rc-service", "dnsmasq", "stop").CombinedOutput() if err != nil { return fmt.Errorf("stop dnsmasq: %s", strings.TrimSpace(string(out))) } return nil }