feat(nft): generate and apply the inet prx ruleset atomically

This commit is contained in:
ahp
2026-10-15 14:29:19 +03:30
parent 181fe69480
commit 465c482854
2 changed files with 229 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: MIT
// Package nft renders and applies the prx-managed nftables table.
//
// The whole table is (re)defined in a single `nft -f` transaction that begins
// with `destroy table`, which makes application atomic and idempotent: running
// it twice never duplicates rules and there is no window without protection.
package nft
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"prx/internal/execx"
)
// TableName is the dedicated nftables table owned by prx.
const TableName = "prx"
var ifaceRE = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}$`)
// Params are the inputs required to render the table.
type Params struct {
WANInterface string
Mark uint32
Local4 []string
Local6 []string
}
// Table returns the fully qualified table reference.
func Table() string { return "inet " + TableName }
// Script renders the nftables ruleset text.
//
// Safety model:
// - New inbound connections arriving on the WAN interface get the conntrack
// mark.
// - Every outgoing packet belonging to a connection whose original
// destination is one of our WAN addresses is marked too. This covers
// connections that existed before prx was enabled (e.g. an already open
// SSH session), which a `ct state new` rule alone would miss.
//
// A separate ip rule then routes marked packets back out the WAN interface.
func Script(p Params) (string, error) {
if !ifaceRE.MatchString(p.WANInterface) {
return "", fmt.Errorf("invalid interface name %q", p.WANInterface)
}
if p.Mark == 0 {
return "", errors.New("mark must be non-zero")
}
var b strings.Builder
fmt.Fprintf(&b, "destroy table %s\n", Table())
fmt.Fprintf(&b, "table %s {\n", Table())
if len(p.Local4) > 0 {
fmt.Fprintf(&b, "\tset wan_local4 {\n\t\ttype ipv4_addr;\n\t\telements = { %s }\n\t}\n", strings.Join(p.Local4, ", "))
}
if len(p.Local6) > 0 {
fmt.Fprintf(&b, "\tset wan_local6 {\n\t\ttype ipv6_addr;\n\t\telements = { %s }\n\t}\n", strings.Join(p.Local6, ", "))
}
fmt.Fprintf(&b, "\tchain pre {\n")
fmt.Fprintf(&b, "\t\ttype filter hook prerouting priority mangle; policy accept;\n")
fmt.Fprintf(&b, "\t\tiifname %q ct state new ct mark set 0x%x\n", p.WANInterface, p.Mark)
fmt.Fprintf(&b, "\t}\n")
fmt.Fprintf(&b, "\tchain out {\n")
fmt.Fprintf(&b, "\t\ttype filter hook output priority mangle; policy accept;\n")
fmt.Fprintf(&b, "\t\tct mark 0x%x meta mark set 0x%x\n", p.Mark, p.Mark)
if len(p.Local4) > 0 {
fmt.Fprintf(&b, "\t\tct original ip daddr @wan_local4 meta mark set 0x%x\n", p.Mark)
}
if len(p.Local6) > 0 {
fmt.Fprintf(&b, "\t\tct original ip6 daddr @wan_local6 meta mark set 0x%x\n", p.Mark)
}
fmt.Fprintf(&b, "\t}\n")
fmt.Fprintf(&b, "}\n")
return b.String(), nil
}
// Apply installs the ruleset atomically.
func Apply(ctx context.Context, r execx.Runner, script string) error {
if _, err := r.Run(ctx, []byte(script), "nft", "-f", "-"); err != nil {
return interpret("nft -f", err)
}
return nil
}
// Check validates the ruleset without committing it.
func Check(ctx context.Context, r execx.Runner, script string) error {
if _, err := r.Run(ctx, []byte(script), "nft", "-c", "-f", "-"); err != nil {
return interpret("nft -c -f", err)
}
return nil
}
// Remove destroys the prx table. It is safe to call when the table is absent.
func Remove(ctx context.Context, r execx.Runner) error {
_, err := r.Run(ctx, nil, "nft", "destroy", "table", "inet", TableName)
if err == nil || isAbsent(err) {
return nil
}
return interpret("nft destroy", err)
}
// interpret turns an nft failure into an actionable message. A syntax-style
// failure usually means the host's nftables is older than 0.9.4 and does not
// understand `destroy`, which would otherwise surface as a cryptic error on a
// freshly installed system.
func interpret(op string, err error) error {
var ee *execx.ExitError
if !errors.As(err, &ee) {
return fmt.Errorf("%s: %w", op, err)
}
msg := strings.TrimSpace(string(ee.Stderr))
if msg == "" {
return fmt.Errorf("%s: exit status %d", op, ee.Code)
}
lower := strings.ToLower(msg)
for _, marker := range []string{"syntax error", "invalid", "unknown command", "unexpected"} {
if strings.Contains(lower, marker) {
return fmt.Errorf("%s: %s (nftables may be too old: prx needs >= 0.9.4 — upgrade nftables)", op, msg)
}
}
return fmt.Errorf("%s: %s", op, msg)
}
// Exists reports whether the prx table currently exists.
func Exists(ctx context.Context, r execx.Runner) (bool, error) {
_, err := r.Run(ctx, nil, "nft", "list", "table", "inet", TableName)
if err != nil {
if isAbsent(err) {
return false, nil
}
return false, err
}
return true, nil
}
func isAbsent(err error) bool {
var ee *execx.ExitError
if errors.As(err, &ee) {
msg := string(ee.Stderr)
return strings.Contains(msg, "No such file or directory") || strings.Contains(msg, "does not exist")
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
package nft
import (
"strings"
"testing"
)
func TestScriptGolden(t *testing.T) {
got, err := Script(Params{
WANInterface: "wlan0",
Mark: 0x100,
Local4: []string{"192.168.1.100"},
})
if err != nil {
t.Fatalf("Script: %v", err)
}
want := `destroy table inet prx
table inet prx {
set wan_local4 {
type ipv4_addr;
elements = { 192.168.1.100 }
}
chain pre {
type filter hook prerouting priority mangle; policy accept;
iifname "wlan0" ct state new ct mark set 0x100
}
chain out {
type filter hook output priority mangle; policy accept;
ct mark 0x100 meta mark set 0x100
ct original ip daddr @wan_local4 meta mark set 0x100
}
}
`
if got != want {
t.Fatalf("unexpected script:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestScriptIncludesIPv6(t *testing.T) {
got, err := Script(Params{
WANInterface: "eth0",
Mark: 0x200,
Local4: []string{"203.0.113.5"},
Local6: []string{"2001:db8::5"},
})
if err != nil {
t.Fatalf("Script: %v", err)
}
for _, needle := range []string{
"set wan_local6",
"elements = { 2001:db8::5 }",
"ct original ip6 daddr @wan_local6 meta mark set 0x200",
} {
if !strings.Contains(got, needle) {
t.Errorf("script missing %q", needle)
}
}
}
func TestScriptAlwaysDestroysFirst(t *testing.T) {
got, _ := Script(Params{WANInterface: "wlan0", Mark: 1, Local4: []string{"1.2.3.4"}})
if !strings.HasPrefix(got, "destroy table inet prx\n") {
t.Fatal("script must start with destroy table to stay idempotent")
}
}
func TestScriptRejectsInjection(t *testing.T) {
for _, bad := range []string{`wlan0" ; flush ruleset`, "wlan0;x", "", "this-name-is-way-too-long"} {
if _, err := Script(Params{WANInterface: bad, Mark: 1, Local4: []string{"1.2.3.4"}}); err == nil {
t.Errorf("expected error for interface %q", bad)
}
}
if _, err := Script(Params{WANInterface: "wlan0", Mark: 0}); err == nil {
t.Error("expected error for zero mark")
}
}