Files

223 lines
5.9 KiB
Go
Raw Permalink Normal View History

2026-04-13 09:46:02 +03:00
package dhcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
const (
2026-04-15 11:38:26 +03:00
ConfigFile = "/etc/dnsmasq.d/nano-router-dhcp.conf"
StateFile = "/var/lib/nano-router/dhcp.json"
2026-04-13 09:46:02 +03:00
)
// 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()
2026-04-15 11:38:26 +03:00
if err := os.MkdirAll("/var/lib/nano-router", 0755); err != nil {
2026-04-13 09:46:02 +03:00
return fmt.Errorf("mkdir state dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(StateFile, data, 0644)
}
2026-04-15 11:38:26 +03:00
// WriteConfigs generates /etc/dnsmasq.d/nano-router-dhcp.conf.
2026-04-13 09:46:02 +03:00
// 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
2026-04-15 11:38:26 +03:00
sb.WriteString("# Generated by nano-router — do not edit manually\n")
2026-04-13 09:46:02 +03:00
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
2026-04-15 11:38:26 +03:00
sb.WriteString("# Generated by nano-router — do not edit manually\n")
2026-04-13 09:46:02 +03:00
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
}