package main import ( "fmt" "os" "os/exec" "strings" ) // renderPortForwards builds the content of the generated nftables include // file (a standalone "inet portforward" table) from the current forwards. // Forwards whose target host no longer exists in the host DB are skipped // and reported back as warnings instead of failing the whole render. func renderPortForwards(hosts []Host, fwds []PortForward, wanIface string) (content string, warnings []string) { ipByHost := map[string]string{} for _, h := range hosts { ipByHost[strings.ToLower(h.Name)] = h.IP } sorted := make([]PortForward, len(fwds)) copy(sorted, fwds) sortPortFwds(sorted) var rules strings.Builder for _, f := range sorted { ip, ok := ipByHost[strings.ToLower(f.Host)] if !ok { warnings = append(warnings, fmt.Sprintf("port-forward %d/%s -> %q skipped: host no longer in the ipadm database", f.WanPort, f.Proto, f.Host)) continue } switch f.Proto { case "tcp", "udp": fmt.Fprintf(&rules, "\t\tiifname %q %s dport %d dnat ip to %s:%d\n", wanIface, f.Proto, f.WanPort, ip, f.LanPort) case "both": fmt.Fprintf(&rules, "\t\tiifname %q meta l4proto { tcp, udp } th dport %d dnat ip to %s:%d\n", wanIface, f.WanPort, ip, f.LanPort) } } var b strings.Builder b.WriteString("# Generated by ipadm -u — DO NOT EDIT MANUALLY.\n") b.WriteString("# Edit forwards with `ipadm -pa`/`ipadm -pd` and re-run `ipadm -u` to regenerate this file.\n\n") b.WriteString("table inet portforward {\n") b.WriteString("\tchain prerouting {\n") b.WriteString("\t\ttype nat hook prerouting priority dstnat;\n") b.WriteString(rules.String()) b.WriteString("\t}\n") b.WriteString("}\n") return b.String(), warnings } // applyPortForwards writes the generated table to path, validates the full // nftables ruleset, and on success reloads nftables. On validation failure // the previous file content is restored and the service is left untouched. func applyPortForwards(path string, content string) error { var backup []byte hadFile := false if b, err := os.ReadFile(path); err == nil { backup = b hadFile = true } else if !os.IsNotExist(err) { return fmt.Errorf("cannot read existing %s: %w", path, err) } if err := os.MkdirAll(dirOf(path), 0755); err != nil { return fmt.Errorf("cannot create directory for %s: %w", path, err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("cannot write %s: %w", path, err) } if err := runNftTest(); err != nil { if hadFile { os.WriteFile(path, backup, 0644) } else { os.Remove(path) } return fmt.Errorf("nftables config invalid, change rolled back: %w", err) } if err := reloadNftables(); err != nil { return fmt.Errorf("%s written, but reload failed: %w", path, err) } return nil } func runNftTest() error { out, err := exec.Command("nft", "-c", "-f", "/etc/nftables.conf").CombinedOutput() if err != nil { return fmt.Errorf("%s", strings.TrimSpace(string(out))) } return nil } func reloadNftables() error { out, err := exec.Command("systemctl", "reload", "nftables").CombinedOutput() if err != nil { return fmt.Errorf("%s", strings.TrimSpace(string(out))) } return nil }