diff --git a/internal/iprule/iprule.go b/internal/iprule/iprule.go new file mode 100644 index 0000000..27591df --- /dev/null +++ b/internal/iprule/iprule.go @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT + +// Package iprule manages the policy routing rules and routes that prx relies +// on to keep inbound reply traffic on the physical interface. +package iprule + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "prx/internal/execx" +) + +// Spec describes the mark-based policy rule that steers replies back out the WAN. +type Spec struct { + Family int // 4 or 6 + Mark uint32 + Table int + Priority int + Gateway string + Dev string +} + +// BypassSpec describes a destination-based rule that keeps traffic direct. +type BypassSpec struct { + Family int + CIDR string + Table int + Priority int + Gateway string + Dev string +} + +func ipArgs(family int, rest ...string) []string { + if family == 6 { + return append([]string{"-6"}, rest...) + } + return rest +} + +func markString(m uint32) string { return fmt.Sprintf("0x%x", m) } + +type ruleEntry struct { + Priority int + HasMark bool + Mark uint32 + HasTo bool + To string + Table int +} + +var ruleLineRE = regexp.MustCompile(`^\s*(\d+):\s+(.*)$`) + +func parseRules(out string) []ruleEntry { + var entries []ruleEntry + for _, line := range strings.Split(out, "\n") { + m := ruleLineRE.FindStringSubmatch(line) + if m == nil { + continue + } + prio, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + e := ruleEntry{Priority: prio} + fields := strings.Fields(m[2]) + for i := 0; i < len(fields); i++ { + switch fields[i] { + case "fwmark": + if i+1 < len(fields) { + e.HasMark = true + e.Mark = parseMark(fields[i+1]) + i++ + } + case "lookup": + if i+1 < len(fields) { + e.Table, _ = strconv.Atoi(fields[i+1]) + i++ + } + case "to": + if i+1 < len(fields) { + e.HasTo = true + e.To = fields[i+1] + i++ + } + } + } + entries = append(entries, e) + } + return entries +} + +func parseMark(s string) uint32 { + if idx := strings.IndexByte(s, '/'); idx >= 0 { + s = s[:idx] + } + s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") + v, _ := strconv.ParseUint(s, 16, 32) + return uint32(v) +} + +func listRules(ctx context.Context, r execx.Runner, family int) ([]ruleEntry, error) { + res, err := r.Run(ctx, nil, "ip", ipArgs(family, "rule", "show")...) + if err != nil { + return nil, fmt.Errorf("ip rule show: %w", err) + } + return parseRules(string(res.Stdout)), nil +} + +// RuleExists reports whether a mark-based rule with the given attributes exists. +func RuleExists(ctx context.Context, r execx.Runner, s Spec) (bool, error) { + entries, err := listRules(ctx, r, s.Family) + if err != nil { + return false, err + } + for _, e := range entries { + if e.Priority == s.Priority && e.HasMark && e.Mark == s.Mark && e.Table == s.Table { + return true, nil + } + } + return false, nil +} + +// EnsureRule adds the mark-based rule if it is missing. +func EnsureRule(ctx context.Context, r execx.Runner, s Spec) (bool, error) { + exists, err := RuleExists(ctx, r, s) + if err != nil { + return false, err + } + if exists { + return false, nil + } + _, err = r.Run(ctx, nil, "ip", ipArgs(s.Family, + "rule", "add", "fwmark", markString(s.Mark), + "lookup", strconv.Itoa(s.Table), + "priority", strconv.Itoa(s.Priority))...) + if err != nil { + return false, fmt.Errorf("add fwmark rule: %w", err) + } + return true, nil +} + +// BypassRuleExists reports whether a destination-based rule exists. +func BypassRuleExists(ctx context.Context, r execx.Runner, s BypassSpec) (bool, error) { + entries, err := listRules(ctx, r, s.Family) + if err != nil { + return false, err + } + for _, e := range entries { + if e.Priority == s.Priority && e.HasTo && e.To == s.CIDR && e.Table == s.Table { + return true, nil + } + } + return false, nil +} + +// EnsureBypassRule adds a destination-based bypass rule if missing. +func EnsureBypassRule(ctx context.Context, r execx.Runner, s BypassSpec) (bool, error) { + exists, err := BypassRuleExists(ctx, r, s) + if err != nil { + return false, err + } + if exists { + return false, nil + } + _, err = r.Run(ctx, nil, "ip", ipArgs(s.Family, + "rule", "add", "to", s.CIDR, + "lookup", strconv.Itoa(s.Table), + "priority", strconv.Itoa(s.Priority))...) + if err != nil { + return false, fmt.Errorf("add bypass rule: %w", err) + } + return true, nil +} + +// RouteExists reports whether the table already holds a default route via the +// given gateway and device. +func RouteExists(ctx context.Context, r execx.Runner, family, table int, gateway, dev string) (bool, error) { + res, err := r.Run(ctx, nil, "ip", ipArgs(family, "route", "show", "table", strconv.Itoa(table))...) + if err != nil { + if isMissing(err) { + return false, nil + } + return false, err + } + for _, line := range strings.Split(string(res.Stdout), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "default" { + continue + } + if via, d := extractViaDev(fields[1:]); via == gateway && (dev == "" || d == dev) { + return true, nil + } + } + return false, nil +} + +// EnsureRoute installs a default route in the policy table (idempotent). +func EnsureRoute(ctx context.Context, r execx.Runner, family, table int, gateway, dev string) (bool, error) { + exists, err := RouteExists(ctx, r, family, table, gateway, dev) + if err != nil { + return false, err + } + if exists { + return false, nil + } + _, err = r.Run(ctx, nil, "ip", ipArgs(family, + "route", "replace", "default", "via", gateway, "dev", dev, "table", strconv.Itoa(table))...) + if err != nil { + return false, fmt.Errorf("add default route to table %d: %w", table, err) + } + return true, nil +} + +// EnsureCIDRRoute installs a route for a specific CIDR in a table (idempotent). +func EnsureCIDRRoute(ctx context.Context, r execx.Runner, family, table int, cidr, gateway, dev string) (bool, error) { + res, err := r.Run(ctx, nil, "ip", ipArgs(family, "route", "show", "table", strconv.Itoa(table), cidr)...) + if err != nil { + return false, fmt.Errorf("ip route show %s: %w", cidr, err) + } + if strings.TrimSpace(string(res.Stdout)) != "" { + return false, nil + } + _, err = r.Run(ctx, nil, "ip", ipArgs(family, + "route", "replace", cidr, "via", gateway, "dev", dev, "table", strconv.Itoa(table))...) + if err != nil { + return false, fmt.Errorf("add route for %s: %w", cidr, err) + } + return true, nil +} + +// FlushTable removes every route from the given table. Missing table is not an +// error. +func FlushTable(ctx context.Context, r execx.Runner, family, table int) error { + _, err := r.Run(ctx, nil, "ip", ipArgs(family, "route", "flush", "table", strconv.Itoa(table))...) + if err != nil && !isMissing(err) { + return fmt.Errorf("flush table %d: %w", table, err) + } + return nil +} + +// TableHasRoutes reports whether the FIB table contains any route. +func TableHasRoutes(ctx context.Context, r execx.Runner, family, table int) (bool, error) { + res, err := r.Run(ctx, nil, "ip", ipArgs(family, "route", "show", "table", strconv.Itoa(table))...) + if err != nil { + if isMissing(err) { + return false, nil + } + return false, err + } + return strings.TrimSpace(string(res.Stdout)) != "", nil +} + +// RemoveRulesByTable deletes every rule that looks up the given table, +// reconstructing an exact `ip rule del` for each. It is robust against +// priority drift between runs and returns the number of rules removed. +func RemoveRulesByTable(ctx context.Context, r execx.Runner, family, table int) (int, error) { + entries, err := listRules(ctx, r, family) + if err != nil { + return 0, err + } + removed := 0 + for _, e := range entries { + if e.Table != table { + continue + } + args := []string{"rule", "del"} + if e.HasMark { + args = append(args, "fwmark", markString(e.Mark)) + } + if e.HasTo { + args = append(args, "to", e.To) + } + args = append(args, "lookup", strconv.Itoa(table), "priority", strconv.Itoa(e.Priority)) + if _, err := r.Run(ctx, nil, "ip", ipArgs(family, args...)...); err != nil { + return removed, fmt.Errorf("delete rule: %w", err) + } + removed++ + } + return removed, nil +} + +func extractViaDev(fields []string) (string, string) { + var via, dev string + for i := 0; i < len(fields); i++ { + switch fields[i] { + case "via": + if i+1 < len(fields) { + via = fields[i+1] + } + case "dev": + if i+1 < len(fields) { + dev = fields[i+1] + } + } + } + return via, dev +} + +func isMissing(err error) bool { + var ee *execx.ExitError + if errors.As(err, &ee) { + msg := string(ee.Stderr) + return strings.Contains(msg, "No such") || strings.Contains(msg, "does not exist") + } + return false +} diff --git a/internal/iprule/iprule_test.go b/internal/iprule/iprule_test.go new file mode 100644 index 0000000..3c963d7 --- /dev/null +++ b/internal/iprule/iprule_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT + +package iprule + +import ( + "context" + "strings" + "testing" + + "prx/internal/execx" +) + +func TestParseRules(t *testing.T) { + out := `0: from all lookup local +50: from all to 198.51.100.0/24 lookup 102 +100: from all fwmark 0x100 lookup 100 +32766: from all lookup main +32767: from all lookup default +` + entries := parseRules(out) + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } + if !entries[2].HasMark || entries[2].Mark != 0x100 || entries[2].Table != 100 || entries[2].Priority != 100 { + t.Errorf("mark entry parsed wrong: %+v", entries[2]) + } + if !entries[1].HasTo || entries[1].To != "198.51.100.0/24" || entries[1].Table != 102 { + t.Errorf("bypass entry parsed wrong: %+v", entries[1]) + } +} + +func ruleRunner(out string) (*execx.Fake, *[]execx.Call) { + f := &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) { + if len(args) >= 2 && args[len(args)-2] == "rule" && args[len(args)-1] == "show" { + return execx.Result{Stdout: []byte(out)}, nil + } + return execx.Result{}, nil + }} + return f, &f.Calls +} + +func TestRuleExists(t *testing.T) { + f, _ := ruleRunner("100:\tfrom all fwmark 0x100 lookup 100\n") + ok, err := RuleExists(context.Background(), f, Spec{Family: 4, Mark: 0x100, Table: 100, Priority: 100}) + if err != nil || !ok { + t.Fatalf("RuleExists = %v, %v", ok, err) + } + ok, err = RuleExists(context.Background(), f, Spec{Family: 4, Mark: 0x100, Table: 100, Priority: 200}) + if err != nil || ok { + t.Fatalf("expected absent, got %v, %v", ok, err) + } +} + +func TestEnsureRuleAddsWhenMissing(t *testing.T) { + f, calls := ruleRunner("") + changed, err := EnsureRule(context.Background(), f, Spec{Family: 4, Mark: 0x100, Table: 100, Priority: 100}) + if err != nil || !changed { + t.Fatalf("EnsureRule = %v, %v", changed, err) + } + if !hasCall(*calls, "ip", "rule", "add", "fwmark", "0x100", "lookup", "100", "priority", "100") { + t.Fatalf("expected add call, got %+v", *calls) + } +} + +func TestEnsureRuleNoopWhenPresent(t *testing.T) { + f, calls := ruleRunner("100:\tfrom all fwmark 0x100 lookup 100\n") + changed, err := EnsureRule(context.Background(), f, Spec{Family: 4, Mark: 0x100, Table: 100, Priority: 100}) + if err != nil || changed { + t.Fatalf("expected no-op, got %v, %v", changed, err) + } + for _, c := range *calls { + if len(c.Args) >= 2 && c.Args[1] == "add" { + t.Fatalf("unexpected add call: %+v", c) + } + } +} + +func TestRemoveRulesByTable(t *testing.T) { + out := `50: from all to 198.51.100.0/24 lookup 102 +100: from all fwmark 0x100 lookup 100 +110: from all fwmark 0x100 lookup 100 +` + f, calls := ruleRunner(out) + n, err := RemoveRulesByTable(context.Background(), f, 4, 100) + if err != nil { + t.Fatalf("RemoveRulesByTable: %v", err) + } + if n != 2 { + t.Fatalf("removed %d, want 2", n) + } + for _, c := range *calls { + if len(c.Args) >= 2 && c.Args[1] == "del" && !strings.Contains(strings.Join(c.Args, " "), "lookup 100") { + t.Fatalf("deleted wrong rule: %+v", c) + } + } +} + +func hasCall(calls []execx.Call, name string, args ...string) bool { + for _, c := range calls { + if c.Name != name || len(c.Args) != len(args) { + continue + } + match := true + for i := range args { + if c.Args[i] != args[i] { + match = false + break + } + } + if match { + return true + } + } + return false +}