package nat import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" ) const tableName = "alpine-router-nat" // Config holds NAT masquerade settings per interface. type Config struct { // Interfaces is the list of LAN interface names for which masquerade is enabled. // Traffic arriving on these interfaces will be NATted to the outgoing WAN interface. Interfaces []string `json:"interfaces"` } // configPath returns the path to nat.json next to the running binary. func configPath() string { exe, err := os.Executable() if err != nil { return filepath.Join("configs", "nat.json") } return filepath.Join(filepath.Dir(exe), "configs", "nat.json") } // IsInstalled reports whether the nft (nftables) binary is available. func IsInstalled() bool { _, err := exec.LookPath("nft") return err == nil } // Load reads the NAT config from disk. // Returns an empty config if the file does not exist yet. func Load() (*Config, error) { data, err := os.ReadFile(configPath()) if err != nil { if os.IsNotExist(err) { return &Config{Interfaces: []string{}}, nil } return nil, fmt.Errorf("read nat config: %w", err) } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse nat config: %w", err) } if cfg.Interfaces == nil { cfg.Interfaces = []string{} } return &cfg, nil } // Save writes the NAT config to the configs/ directory next to the binary. func Save(cfg *Config) error { p := configPath() if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { return fmt.Errorf("mkdir configs: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(p, data, 0644) } // ApplyRules flushes the existing alpine-router NAT table and recreates it // from the provided config. Called on every daemon startup and on config save. // // nftables is used instead of iptables because it applies all rules atomically // in a single kernel call, which is faster and avoids partial-state issues. func ApplyRules(cfg *Config) error { return ApplyRulesWithBlocked(cfg, nil) } // ApplyRulesWithBlocked is like ApplyRules but also installs drop rules for // the given list of blocked client IPs. func ApplyRulesWithBlocked(cfg *Config, blockedIPs []string) error { if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { return fmt.Errorf("enable ip_forward: %w", err) } exec.Command("nft", "delete", "table", "ip", tableName).Run() if len(cfg.Interfaces) == 0 && len(blockedIPs) == 0 { return nil } var sb strings.Builder fmt.Fprintf(&sb, "table ip %s {\n", tableName) sb.WriteString(" chain forward {\n") sb.WriteString(" type filter hook forward priority filter; policy drop;\n") sb.WriteString(" ct state established,related accept\n") for _, ip := range blockedIPs { fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) } for _, iface := range cfg.Interfaces { fmt.Fprintf(&sb, " iifname \"%s\" accept\n", iface) } sb.WriteString(" }\n") sb.WriteString(" chain postrouting {\n") sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") for _, iface := range cfg.Interfaces { fmt.Fprintf(&sb, " iifname \"%s\" masquerade\n", iface) } sb.WriteString(" }\n") sb.WriteString("}\n") cmd := exec.Command("nft", "-f", "-") cmd.Stdin = strings.NewReader(sb.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err) } return nil }