feat(iprule): manage policy-routing rules and tables
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user