feat(nft): generate and apply the inet prx ruleset atomically
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user