diff --git a/Makefile b/Makefile index 77e4e8e..5283935 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ RUNTIME_SOURCE_DIR ?= runtime RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz BINARIES := banger bangerd GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort) -RUNTIME_EXECUTABLES := firecracker customize.sh dns.sh packages.sh nat.sh namegen +RUNTIME_EXECUTABLES := firecracker customize.sh dns.sh packages.sh namegen RUNTIME_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4 RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 bundle.json RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic diff --git a/README.md b/README.md index 1bb7efa..c5b7659 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,9 @@ banger vm set web --nat banger vm set web --no-nat ``` -For daemon-managed VMs, NAT is applied directly by `bangerd` using host `iptables` -rules derived from the VM's current guest IP and TAP device. +NAT is applied by the Go control plane using host `iptables` rules derived from +the VM's current guest IP and TAP device. The remaining shell helpers also +route NAT changes through `banger` instead of a standalone shell NAT script. Running VMs are published as `.vm` through `mapdns`. @@ -248,6 +249,5 @@ The runtime VM lifecycle is managed through `banger`. The remaining shell script `BANGER_STATE_DIR`/XDG state - `make-rootfs.sh`: convenience wrapper for rebuilding `./runtime/rootfs-docker.ext4` - `interactive.sh`: manual one-off rootfs customization over SSH -- `nat.sh`: legacy host NAT helper used by the shell customization flows - `packages.sh`, `dns.sh`: shell helper libraries - `verify.sh`: smoke test for the Go workflow (`./verify.sh --nat` adds NAT coverage) diff --git a/customize.sh b/customize.sh index 2d5be2c..9039c2c 100755 --- a/customize.sh +++ b/customize.sh @@ -69,13 +69,37 @@ FC_BIN="$RUNTIME_DIR/firecracker" KERNEL="$(bundle_path default_kernel "$RUNTIME_DIR/wtf/root/boot/vmlinux-6.8.0-94-generic")" INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")" SSH_KEY="$RUNTIME_DIR/id_ed25519" -NAT_SCRIPT="$RUNTIME_DIR/nat.sh" BR_DEV="br-fc" BR_IP="172.16.0.1" CIDR="24" DNS_SERVER="1.1.1.1" +resolve_banger_bin() { + if [[ -n "${BANGER_BIN:-}" ]]; then + printf '%s\n' "$BANGER_BIN" + return + fi + if [[ -x "$SCRIPT_DIR/banger" ]]; then + printf '%s\n' "$SCRIPT_DIR/banger" + return + fi + if command -v banger >/dev/null 2>&1; then + command -v banger + return + fi + log "banger binary not found; install/build banger or set BANGER_BIN" + exit 1 +} + +BANGER_BIN="$(resolve_banger_bin)" +NAT_ACTIVE=0 + +banger_nat() { + local action="$1" + "$BANGER_BIN" internal nat "$action" --guest-ip "$GUEST_IP" --tap "$TAP_DEV" +} + BASE_ROOTFS="" OUT_ROOTFS="" SIZE_SPEC="" @@ -223,6 +247,9 @@ sudo -v cleanup() { sudo kill "${FC_PID:-}" 2>/dev/null || true + if [[ "$NAT_ACTIVE" -eq 1 ]]; then + banger_nat down >/dev/null 2>&1 || true + fi sudo ip link del "$TAP_DEV" 2>/dev/null || true rm -f "$API_SOCK" banger_dns_remove_record_name "${DNS_NAME:-}" @@ -332,7 +359,8 @@ jq -n \ > "$VM_DIR/vm.json" log "enabling NAT for customization" -sudo -E "$NAT_SCRIPT" up "$VM_TAG" >/dev/null +banger_nat up >/dev/null +NAT_ACTIVE=1 log "waiting for SSH" SSH_READY=0 diff --git a/interactive.sh b/interactive.sh index c7ea2e2..2902b6b 100755 --- a/interactive.sh +++ b/interactive.sh @@ -73,6 +73,31 @@ BR_IP="172.16.0.1" CIDR="24" DNS_SERVER="1.1.1.1" +resolve_banger_bin() { + if [[ -n "${BANGER_BIN:-}" ]]; then + printf '%s\n' "$BANGER_BIN" + return + fi + if [[ -x "$DIR/banger" ]]; then + printf '%s\n' "$DIR/banger" + return + fi + if command -v banger >/dev/null 2>&1; then + command -v banger + return + fi + log "banger binary not found; install/build banger or set BANGER_BIN" + exit 1 +} + +BANGER_BIN="$(resolve_banger_bin)" +NAT_ACTIVE=0 + +banger_nat() { + local action="$1" + "$BANGER_BIN" internal nat "$action" --guest-ip "$GUEST_IP" --tap "$TAP_DEV" +} + BASE_ROOTFS="" OUT_ROOTFS="" SIZE_SPEC="" @@ -167,6 +192,9 @@ sudo -v cleanup() { sudo kill "${FC_PID:-}" 2>/dev/null || true + if [[ "$NAT_ACTIVE" -eq 1 ]]; then + banger_nat down >/dev/null 2>&1 || true + fi sudo ip link del "$TAP_DEV" 2>/dev/null || true rm -f "$API_SOCK" banger_dns_remove_record_name "${DNS_NAME:-}" @@ -272,7 +300,8 @@ jq -n \ > "$VM_DIR/vm.json" log "enabling NAT for interactive session" -sudo -E ./nat.sh up "$VM_TAG" >/dev/null +banger_nat up >/dev/null +NAT_ACTIVE=1 log "waiting for SSH" log "guest ip: $GUEST_IP" diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 4b337c5..089bb83 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -15,6 +15,7 @@ import ( "banger/internal/api" "banger/internal/config" + "banger/internal/hostnat" "banger/internal/model" "banger/internal/paths" "banger/internal/rpc" @@ -39,10 +40,60 @@ func NewBangerCommand() *cobra.Command { RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true - root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand()) + root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand(), newInternalCommand()) return root } +func newInternalCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "internal", + Hidden: true, + RunE: helpNoArgs, + } + cmd.AddCommand(newInternalNATCommand()) + return cmd +} + +func newInternalNATCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "nat", + Hidden: true, + RunE: helpNoArgs, + } + cmd.AddCommand( + newInternalNATActionCommand("up", true), + newInternalNATActionCommand("down", false), + ) + return cmd +} + +func newInternalNATActionCommand(use string, enable bool) *cobra.Command { + var guestIP string + var tapDevice string + cmd := &cobra.Command{ + Use: use, + Hidden: true, + Args: noArgsUsage("usage: banger internal nat " + use + " --guest-ip --tap "), + RunE: func(cmd *cobra.Command, args []string) error { + guestIP = strings.TrimSpace(guestIP) + tapDevice = strings.TrimSpace(tapDevice) + if guestIP == "" { + return errors.New("guest IP is required") + } + if tapDevice == "" { + return errors.New("tap device is required") + } + if err := system.EnsureSudo(cmd.Context()); err != nil { + return err + } + return hostnat.Ensure(cmd.Context(), system.NewRunner(), guestIP, tapDevice, enable) + }, + } + cmd.Flags().StringVar(&guestIP, "guest-ip", "", "guest IPv4 address") + cmd.Flags().StringVar(&tapDevice, "tap", "", "tap device name") + return cmd +} + func newDaemonCommand() *cobra.Command { cmd := &cobra.Command{ Use: "daemon", diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 7bb153a..4671c39 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -18,12 +18,33 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) { for _, sub := range cmd.Commands() { names = append(names, sub.Name()) } - want := []string{"daemon", "image", "tui", "vm"} + want := []string{"daemon", "image", "internal", "tui", "vm"} if !reflect.DeepEqual(names, want) { t.Fatalf("subcommands = %v, want %v", names, want) } } +func TestInternalNATFlagsExist(t *testing.T) { + root := NewBangerCommand() + internal, _, err := root.Find([]string{"internal"}) + if err != nil { + t.Fatalf("find internal: %v", err) + } + nat, _, err := internal.Find([]string{"nat"}) + if err != nil { + t.Fatalf("find nat: %v", err) + } + up, _, err := nat.Find([]string{"up"}) + if err != nil { + t.Fatalf("find nat up: %v", err) + } + for _, flagName := range []string{"guest-ip", "tap"} { + if up.Flags().Lookup(flagName) == nil { + t.Fatalf("missing flag %q", flagName) + } + } +} + func TestVMCreateFlagsExist(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) diff --git a/internal/daemon/nat.go b/internal/daemon/nat.go index 9781883..e38f6a3 100644 --- a/internal/daemon/nat.go +++ b/internal/daemon/nat.go @@ -2,46 +2,16 @@ package daemon import ( "context" - "errors" - "fmt" - "strings" + "banger/internal/hostnat" "banger/internal/model" "banger/internal/system" ) -type natRule struct { - table string - chain string - args []string -} +type natRule = hostnat.Rule func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error { - uplink, err := d.validateNATPrereqs(ctx) - if err != nil { - return err - } - rules, err := natRulesForVM(vm, uplink) - if err != nil { - return err - } - if enable { - if _, err := d.runner.RunSudo(ctx, "sysctl", "-w", "net.ipv4.ip_forward=1"); err != nil { - return err - } - for _, rule := range rules { - if err := d.addNATRule(ctx, rule); err != nil { - return err - } - } - return nil - } - for _, rule := range rules { - if err := d.removeNATRule(ctx, rule); err != nil { - return err - } - } - return nil + return hostnat.Ensure(ctx, d.runner, vm.Runtime.GuestIP, vm.Runtime.TapDevice, enable) } func (d *Daemon) validateNATPrereqs(ctx context.Context) (string, error) { @@ -55,102 +25,29 @@ func (d *Daemon) validateNATPrereqs(ctx context.Context) (string, error) { } func (d *Daemon) defaultUplink(ctx context.Context) (string, error) { - out, err := d.runner.Run(ctx, "ip", "route", "show", "default") - if err != nil { - return "", err - } - return parseDefaultUplink(string(out)) + return hostnat.DefaultUplink(ctx, d.runner) } func parseDefaultUplink(output string) (string, error) { - for _, line := range strings.Split(output, "\n") { - fields := strings.Fields(line) - if len(fields) == 0 || fields[0] != "default" { - continue - } - for i := 0; i < len(fields)-1; i++ { - if fields[i] == "dev" && fields[i+1] != "" { - return fields[i+1], nil - } - } - } - return "", errors.New("failed to detect uplink interface") + return hostnat.ParseDefaultUplink(output) } func natRulesForVM(vm model.VMRecord, uplink string) ([]natRule, error) { - guestIP := strings.TrimSpace(vm.Runtime.GuestIP) - if guestIP == "" { - return nil, errors.New("nat requires a guest IP") - } - tap := strings.TrimSpace(vm.Runtime.TapDevice) - if tap == "" { - return nil, errors.New("nat requires a tap device") - } - uplink = strings.TrimSpace(uplink) - if uplink == "" { - return nil, errors.New("nat requires an uplink interface") - } - guestCIDR := guestIP + "/32" - return []natRule{ - { - table: "nat", - chain: "POSTROUTING", - args: []string{"-s", guestCIDR, "-o", uplink, "-j", "MASQUERADE"}, - }, - { - chain: "FORWARD", - args: []string{"-i", tap, "-o", uplink, "-j", "ACCEPT"}, - }, - { - chain: "FORWARD", - args: []string{"-i", uplink, "-o", tap, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, - }, - }, nil + return hostnat.Rules(vm.Runtime.GuestIP, vm.Runtime.TapDevice, uplink) } func natRuleArgs(action string, rule natRule) []string { - args := make([]string, 0, len(rule.args)+4) - if rule.table != "" { - args = append(args, "-t", rule.table) - } - args = append(args, action, rule.chain) - args = append(args, rule.args...) - return args + return hostnat.RuleArgs(action, rule) } func natAddPlan(rules []natRule) [][]string { - plan := make([][]string, 0, len(rules)+1) - plan = append(plan, []string{"sysctl", "-w", "net.ipv4.ip_forward=1"}) - for _, rule := range rules { - plan = append(plan, natRuleArgs("-A", rule)) - } - return plan + return hostnat.AddPlan(rules) } func natRemovePlan(rules []natRule) [][]string { - plan := make([][]string, 0, len(rules)) - for _, rule := range rules { - plan = append(plan, natRuleArgs("-D", rule)) - } - return plan -} - -func (d *Daemon) addNATRule(ctx context.Context, rule natRule) error { - if _, err := d.runner.RunSudo(ctx, append([]string{"iptables"}, natRuleArgs("-C", rule)...)...); err == nil { - return nil - } - _, err := d.runner.RunSudo(ctx, append([]string{"iptables"}, natRuleArgs("-A", rule)...)...) - return err -} - -func (d *Daemon) removeNATRule(ctx context.Context, rule natRule) error { - if _, err := d.runner.RunSudo(ctx, append([]string{"iptables"}, natRuleArgs("-C", rule)...)...); err != nil { - return nil - } - _, err := d.runner.RunSudo(ctx, append([]string{"iptables"}, natRuleArgs("-D", rule)...)...) - return err + return hostnat.RemovePlan(rules) } func natRuleKey(rule natRule) string { - return fmt.Sprintf("%s:%s:%s", rule.table, rule.chain, strings.Join(rule.args, " ")) + return hostnat.RuleKey(rule) } diff --git a/internal/daemon/nat_test.go b/internal/daemon/nat_test.go index f96f13c..d5a01d0 100644 --- a/internal/daemon/nat_test.go +++ b/internal/daemon/nat_test.go @@ -104,8 +104,8 @@ func TestNATPlans(t *testing.T) { t.Parallel() rules := []natRule{ - {table: "nat", chain: "POSTROUTING", args: []string{"-s", "172.16.0.8/32", "-o", "eth0", "-j", "MASQUERADE"}}, - {chain: "FORWARD", args: []string{"-i", "tap-fc-abcd1234", "-o", "eth0", "-j", "ACCEPT"}}, + {Table: "nat", Chain: "POSTROUTING", Args: []string{"-s", "172.16.0.8/32", "-o", "eth0", "-j", "MASQUERADE"}}, + {Chain: "FORWARD", Args: []string{"-i", "tap-fc-abcd1234", "-o", "eth0", "-j", "ACCEPT"}}, } addPlan := natAddPlan(rules) diff --git a/internal/hostnat/hostnat.go b/internal/hostnat/hostnat.go new file mode 100644 index 0000000..8a2ed03 --- /dev/null +++ b/internal/hostnat/hostnat.go @@ -0,0 +1,145 @@ +package hostnat + +import ( + "context" + "errors" + "fmt" + "strings" + + "banger/internal/system" +) + +type Rule struct { + Table string + Chain string + Args []string +} + +func Ensure(ctx context.Context, runner system.CommandRunner, guestIP, tapDevice string, enable bool) error { + uplink, err := DefaultUplink(ctx, runner) + if err != nil { + return err + } + rules, err := Rules(guestIP, tapDevice, uplink) + if err != nil { + return err + } + if enable { + if _, err := runner.RunSudo(ctx, "sysctl", "-w", "net.ipv4.ip_forward=1"); err != nil { + return err + } + for _, rule := range rules { + if err := addRule(ctx, runner, rule); err != nil { + return err + } + } + return nil + } + for _, rule := range rules { + if err := removeRule(ctx, runner, rule); err != nil { + return err + } + } + return nil +} + +func DefaultUplink(ctx context.Context, runner system.CommandRunner) (string, error) { + out, err := runner.Run(ctx, "ip", "route", "show", "default") + if err != nil { + return "", err + } + return ParseDefaultUplink(string(out)) +} + +func ParseDefaultUplink(output string) (string, error) { + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "default" { + continue + } + for i := 0; i < len(fields)-1; i++ { + if fields[i] == "dev" && fields[i+1] != "" { + return fields[i+1], nil + } + } + } + return "", errors.New("failed to detect uplink interface") +} + +func Rules(guestIP, tapDevice, uplink string) ([]Rule, error) { + guestIP = strings.TrimSpace(guestIP) + if guestIP == "" { + return nil, errors.New("nat requires a guest IP") + } + tapDevice = strings.TrimSpace(tapDevice) + if tapDevice == "" { + return nil, errors.New("nat requires a tap device") + } + uplink = strings.TrimSpace(uplink) + if uplink == "" { + return nil, errors.New("nat requires an uplink interface") + } + guestCIDR := guestIP + "/32" + return []Rule{ + { + Table: "nat", + Chain: "POSTROUTING", + Args: []string{"-s", guestCIDR, "-o", uplink, "-j", "MASQUERADE"}, + }, + { + Chain: "FORWARD", + Args: []string{"-i", tapDevice, "-o", uplink, "-j", "ACCEPT"}, + }, + { + Chain: "FORWARD", + Args: []string{"-i", uplink, "-o", tapDevice, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, + }, + }, nil +} + +func RuleArgs(action string, rule Rule) []string { + args := make([]string, 0, len(rule.Args)+4) + if rule.Table != "" { + args = append(args, "-t", rule.Table) + } + args = append(args, action, rule.Chain) + args = append(args, rule.Args...) + return args +} + +func AddPlan(rules []Rule) [][]string { + plan := make([][]string, 0, len(rules)+1) + plan = append(plan, []string{"sysctl", "-w", "net.ipv4.ip_forward=1"}) + for _, rule := range rules { + plan = append(plan, RuleArgs("-A", rule)) + } + return plan +} + +func RemovePlan(rules []Rule) [][]string { + plan := make([][]string, 0, len(rules)) + for _, rule := range rules { + plan = append(plan, RuleArgs("-D", rule)) + } + return plan +} + +func RuleKey(rule Rule) string { + return fmt.Sprintf("%s:%s:%s", rule.Table, rule.Chain, strings.Join(rule.Args, " ")) +} + +func addRule(ctx context.Context, runner system.CommandRunner, rule Rule) error { + if _, err := runner.RunSudo(ctx, append([]string{"iptables"}, RuleArgs("-C", rule)...)...); err == nil { + return nil + } + _, err := runner.RunSudo(ctx, append([]string{"iptables"}, RuleArgs("-A", rule)...)...) + return err +} + +func removeRule(ctx context.Context, runner system.CommandRunner, rule Rule) error { + if _, err := runner.RunSudo(ctx, append([]string{"iptables"}, RuleArgs("-C", rule)...)...); err != nil { + return nil + } + _, err := runner.RunSudo(ctx, append([]string{"iptables"}, RuleArgs("-D", rule)...)...) + return err +} diff --git a/internal/hostnat/hostnat_test.go b/internal/hostnat/hostnat_test.go new file mode 100644 index 0000000..7ffa90c --- /dev/null +++ b/internal/hostnat/hostnat_test.go @@ -0,0 +1,83 @@ +package hostnat + +import ( + "slices" + "testing" +) + +func TestParseDefaultUplink(t *testing.T) { + t.Parallel() + + output := "default via 192.168.1.1 dev enp5s0 proto dhcp src 192.168.1.40 metric 100\n" + uplink, err := ParseDefaultUplink(output) + if err != nil { + t.Fatalf("ParseDefaultUplink returned error: %v", err) + } + if uplink != "enp5s0" { + t.Fatalf("uplink = %q, want enp5s0", uplink) + } +} + +func TestParseDefaultUplinkFailsWithoutRoute(t *testing.T) { + t.Parallel() + + if _, err := ParseDefaultUplink("10.0.0.0/24 dev br-fc proto kernel scope link src 10.0.0.1\n"); err == nil { + t.Fatal("expected ParseDefaultUplink to fail without a default route") + } +} + +func TestRulesRequireRuntimeData(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + guestIP string + tapDevice string + uplink string + }{ + {name: "guest ip", tapDevice: "tap-fc-abcd1234", uplink: "eth0"}, + {name: "tap", guestIP: "172.16.0.8", uplink: "eth0"}, + {name: "uplink", guestIP: "172.16.0.8", tapDevice: "tap-fc-abcd1234"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if _, err := Rules(tt.guestIP, tt.tapDevice, tt.uplink); err == nil { + t.Fatalf("expected Rules to fail for missing %s", tt.name) + } + }) + } +} + +func TestRulePlans(t *testing.T) { + t.Parallel() + + rules, err := Rules("172.16.0.8", "tap-fc-abcd1234", "eth0") + if err != nil { + t.Fatalf("Rules returned error: %v", err) + } + if len(rules) != 3 { + t.Fatalf("rule count = %d, want 3", len(rules)) + } + + if got, want := RuleArgs("-A", rules[0]), []string{"-t", "nat", "-A", "POSTROUTING", "-s", "172.16.0.8/32", "-o", "eth0", "-j", "MASQUERADE"}; !slices.Equal(got, want) { + t.Fatalf("postrouting args = %v, want %v", got, want) + } + if got, want := RuleArgs("-A", rules[1]), []string{"-A", "FORWARD", "-i", "tap-fc-abcd1234", "-o", "eth0", "-j", "ACCEPT"}; !slices.Equal(got, want) { + t.Fatalf("forward-out args = %v, want %v", got, want) + } + if got, want := RuleArgs("-A", rules[2]), []string{"-A", "FORWARD", "-i", "eth0", "-o", "tap-fc-abcd1234", "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"}; !slices.Equal(got, want) { + t.Fatalf("forward-in args = %v, want %v", got, want) + } + + addPlan := AddPlan(rules) + if got, want := addPlan[0], []string{"sysctl", "-w", "net.ipv4.ip_forward=1"}; !slices.Equal(got, want) { + t.Fatalf("sysctl command = %v, want %v", got, want) + } + removePlan := RemovePlan(rules) + if got, want := removePlan[0], []string{"-t", "nat", "-D", "POSTROUTING", "-s", "172.16.0.8/32", "-o", "eth0", "-j", "MASQUERADE"}; !slices.Equal(got, want) { + t.Fatalf("remove NAT command = %v, want %v", got, want) + } +} diff --git a/internal/runtimebundle/bundle_test.go b/internal/runtimebundle/bundle_test.go index 32a26a1..a8de310 100644 --- a/internal/runtimebundle/bundle_test.go +++ b/internal/runtimebundle/bundle_test.go @@ -23,7 +23,6 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { "runtime/customize.sh": "#!/bin/bash\n", "runtime/packages.sh": "#!/bin/bash\n", "runtime/dns.sh": "#!/bin/bash\n", - "runtime/nat.sh": "#!/bin/bash\n", "runtime/packages.apt": "vim\n", "runtime/rootfs-docker.ext4": "rootfs", "runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel", diff --git a/nat.sh b/nat.sh deleted file mode 100755 index 45ab76d..0000000 --- a/nat.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -log() { - printf '[nat] %s\n' "$*" -} - -usage() { - cat <<'EOF' -Usage: ./nat.sh - -Manage per-VM NAT rules for internet access. -EOF -} - -find_vm_json() { - local query="$1" - local vm_json match_count=0 match="" - - for vm_json in state/vms/*/vm.json; do - [[ -f "$vm_json" ]] || continue - local id name - id="$(jq -r '.meta.id // empty' "$vm_json")" - name="$(jq -r '.meta.name // empty' "$vm_json")" - if [[ "$id" == "$query"* || "$name" == "$query"* ]]; then - match="$vm_json" - match_count=$((match_count + 1)) - fi - done - - if (( match_count == 0 )); then - log "no VM found for prefix: $query" - exit 1 - fi - if (( match_count > 1 )); then - log "multiple VMs found for prefix: $query" - exit 1 - fi - - printf '%s' "$match" -} - -default_uplink() { - ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' -} - -ensure_iptables() { - if ! command -v iptables >/dev/null 2>&1; then - log "iptables not found" - exit 1 - fi -} - -ACTION="${1:-}" -QUERY="${2:-}" -if [[ -z "$ACTION" || -z "$QUERY" ]]; then - usage - exit 1 -fi - -VM_JSON="$(find_vm_json "$QUERY")" -GUEST_IP="$(jq -r '.meta.guest_ip // empty' "$VM_JSON")" -TAP="$(jq -r '.meta.tap // empty' "$VM_JSON")" - -if [[ -z "$GUEST_IP" || -z "$TAP" ]]; then - log "missing guest_ip or tap in $VM_JSON" - exit 1 -fi - -UPLINK="$(default_uplink)" -if [[ -z "$UPLINK" ]]; then - log "failed to detect uplink interface" - exit 1 -fi - -ensure_iptables - -nat_rule=(-t nat -s "${GUEST_IP}/32" -o "$UPLINK" -j MASQUERADE) -fwd_out_rule=(-i "$TAP" -o "$UPLINK" -j ACCEPT) -fwd_in_rule=(-i "$UPLINK" -o "$TAP" -m state --state "RELATED,ESTABLISHED" -j ACCEPT) - -case "$ACTION" in - up) - sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null - sudo iptables -t nat -C POSTROUTING "${nat_rule[@]}" 2>/dev/null || \ - sudo iptables -t nat -A POSTROUTING "${nat_rule[@]}" - sudo iptables -C FORWARD "${fwd_out_rule[@]}" 2>/dev/null || \ - sudo iptables -A FORWARD "${fwd_out_rule[@]}" - sudo iptables -C FORWARD "${fwd_in_rule[@]}" 2>/dev/null || \ - sudo iptables -A FORWARD "${fwd_in_rule[@]}" - log "NAT enabled for $GUEST_IP via $UPLINK" - ;; - down) - sudo iptables -t nat -C POSTROUTING "${nat_rule[@]}" 2>/dev/null && \ - sudo iptables -t nat -D POSTROUTING "${nat_rule[@]}" || true - sudo iptables -C FORWARD "${fwd_out_rule[@]}" 2>/dev/null && \ - sudo iptables -D FORWARD "${fwd_out_rule[@]}" || true - sudo iptables -C FORWARD "${fwd_in_rule[@]}" 2>/dev/null && \ - sudo iptables -D FORWARD "${fwd_in_rule[@]}" || true - log "NAT disabled for $GUEST_IP" - ;; - status) - sysctl net.ipv4.ip_forward | sed 's/^/[nat] /' - if sudo iptables -t nat -C POSTROUTING "${nat_rule[@]}" 2>/dev/null; then - log "nat: installed" - else - log "nat: missing" - fi - if sudo iptables -C FORWARD "${fwd_out_rule[@]}" 2>/dev/null; then - log "forward out: installed" - else - log "forward out: missing" - fi - if sudo iptables -C FORWARD "${fwd_in_rule[@]}" 2>/dev/null; then - log "forward in: installed" - else - log "forward in: missing" - fi - ;; - *) - usage - exit 1 - ;; -esac diff --git a/runtime-bundle.toml b/runtime-bundle.toml index e393b27..d3d5c4a 100644 --- a/runtime-bundle.toml +++ b/runtime-bundle.toml @@ -10,7 +10,6 @@ required_paths = [ "customize.sh", "dns.sh", "packages.sh", - "nat.sh", "namegen", "packages.apt", "id_ed25519",