Move helper NAT management into Go
Remove the last shell-owned NAT surface by extracting the iptables logic into a shared Go package and using it from both bangerd and a hidden helper bridge in the CLI. Route customize.sh and interactive.sh through banger internal nat up/down so the remaining shell helpers reuse the same rule logic, resolve the local banger binary explicitly, and tear NAT back down during cleanup. Drop nat.sh from the runtime bundle and docs now that NAT is Go-managed everywhere, and keep coverage aligned with the new shared package and helper command. Validation: go test ./..., bash -n customize.sh interactive.sh verify.sh, make build, and a live ./verify.sh --nat run that installed host rules, reached outbound network access, and cleaned them up successfully.
This commit is contained in:
parent
60294e8c90
commit
430f66d5dd
13 changed files with 378 additions and 250 deletions
2
Makefile
2
Makefile
|
|
@ -13,7 +13,7 @@ RUNTIME_SOURCE_DIR ?= runtime
|
||||||
RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz
|
RUNTIME_ARCHIVE ?= dist/banger-runtime.tar.gz
|
||||||
BINARIES := banger bangerd
|
BINARIES := banger bangerd
|
||||||
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
|
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_DATA_FILES := packages.apt id_ed25519 rootfs-docker.ext4
|
||||||
RUNTIME_OPTIONAL_DATA_FILES := rootfs.ext4 bundle.json
|
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
|
RUNTIME_BOOT_FILES := wtf/root/boot/vmlinux-6.8.0-94-generic wtf/root/boot/initrd.img-6.8.0-94-generic
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,9 @@ banger vm set web --nat
|
||||||
banger vm set web --no-nat
|
banger vm set web --no-nat
|
||||||
```
|
```
|
||||||
|
|
||||||
For daemon-managed VMs, NAT is applied directly by `bangerd` using host `iptables`
|
NAT is applied by the Go control plane using host `iptables` rules derived from
|
||||||
rules derived from the VM's current guest IP and TAP device.
|
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-name>.vm` through `mapdns`.
|
Running VMs are published as `<vm-name>.vm` through `mapdns`.
|
||||||
|
|
||||||
|
|
@ -248,6 +249,5 @@ The runtime VM lifecycle is managed through `banger`. The remaining shell script
|
||||||
`BANGER_STATE_DIR`/XDG state
|
`BANGER_STATE_DIR`/XDG state
|
||||||
- `make-rootfs.sh`: convenience wrapper for rebuilding `./runtime/rootfs-docker.ext4`
|
- `make-rootfs.sh`: convenience wrapper for rebuilding `./runtime/rootfs-docker.ext4`
|
||||||
- `interactive.sh`: manual one-off rootfs customization over SSH
|
- `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
|
- `packages.sh`, `dns.sh`: shell helper libraries
|
||||||
- `verify.sh`: smoke test for the Go workflow (`./verify.sh --nat` adds NAT coverage)
|
- `verify.sh`: smoke test for the Go workflow (`./verify.sh --nat` adds NAT coverage)
|
||||||
|
|
|
||||||
32
customize.sh
32
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")"
|
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")"
|
INITRD="$(bundle_path default_initrd "$RUNTIME_DIR/wtf/root/boot/initrd.img-6.8.0-94-generic")"
|
||||||
SSH_KEY="$RUNTIME_DIR/id_ed25519"
|
SSH_KEY="$RUNTIME_DIR/id_ed25519"
|
||||||
NAT_SCRIPT="$RUNTIME_DIR/nat.sh"
|
|
||||||
|
|
||||||
BR_DEV="br-fc"
|
BR_DEV="br-fc"
|
||||||
BR_IP="172.16.0.1"
|
BR_IP="172.16.0.1"
|
||||||
CIDR="24"
|
CIDR="24"
|
||||||
DNS_SERVER="1.1.1.1"
|
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=""
|
BASE_ROOTFS=""
|
||||||
OUT_ROOTFS=""
|
OUT_ROOTFS=""
|
||||||
SIZE_SPEC=""
|
SIZE_SPEC=""
|
||||||
|
|
@ -223,6 +247,9 @@ sudo -v
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
sudo kill "${FC_PID:-}" 2>/dev/null || true
|
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
|
sudo ip link del "$TAP_DEV" 2>/dev/null || true
|
||||||
rm -f "$API_SOCK"
|
rm -f "$API_SOCK"
|
||||||
banger_dns_remove_record_name "${DNS_NAME:-}"
|
banger_dns_remove_record_name "${DNS_NAME:-}"
|
||||||
|
|
@ -332,7 +359,8 @@ jq -n \
|
||||||
> "$VM_DIR/vm.json"
|
> "$VM_DIR/vm.json"
|
||||||
|
|
||||||
log "enabling NAT for customization"
|
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"
|
log "waiting for SSH"
|
||||||
SSH_READY=0
|
SSH_READY=0
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,31 @@ BR_IP="172.16.0.1"
|
||||||
CIDR="24"
|
CIDR="24"
|
||||||
DNS_SERVER="1.1.1.1"
|
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=""
|
BASE_ROOTFS=""
|
||||||
OUT_ROOTFS=""
|
OUT_ROOTFS=""
|
||||||
SIZE_SPEC=""
|
SIZE_SPEC=""
|
||||||
|
|
@ -167,6 +192,9 @@ sudo -v
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
sudo kill "${FC_PID:-}" 2>/dev/null || true
|
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
|
sudo ip link del "$TAP_DEV" 2>/dev/null || true
|
||||||
rm -f "$API_SOCK"
|
rm -f "$API_SOCK"
|
||||||
banger_dns_remove_record_name "${DNS_NAME:-}"
|
banger_dns_remove_record_name "${DNS_NAME:-}"
|
||||||
|
|
@ -272,7 +300,8 @@ jq -n \
|
||||||
> "$VM_DIR/vm.json"
|
> "$VM_DIR/vm.json"
|
||||||
|
|
||||||
log "enabling NAT for interactive session"
|
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 "waiting for SSH"
|
||||||
log "guest ip: $GUEST_IP"
|
log "guest ip: $GUEST_IP"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
"banger/internal/api"
|
"banger/internal/api"
|
||||||
"banger/internal/config"
|
"banger/internal/config"
|
||||||
|
"banger/internal/hostnat"
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
"banger/internal/paths"
|
"banger/internal/paths"
|
||||||
"banger/internal/rpc"
|
"banger/internal/rpc"
|
||||||
|
|
@ -39,10 +40,60 @@ func NewBangerCommand() *cobra.Command {
|
||||||
RunE: helpNoArgs,
|
RunE: helpNoArgs,
|
||||||
}
|
}
|
||||||
root.CompletionOptions.DisableDefaultCmd = true
|
root.CompletionOptions.DisableDefaultCmd = true
|
||||||
root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand())
|
root.AddCommand(newDaemonCommand(), newVMCommand(), newImageCommand(), newTUICommand(), newInternalCommand())
|
||||||
return root
|
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 <ip> --tap <tap-device>"),
|
||||||
|
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 {
|
func newDaemonCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "daemon",
|
Use: "daemon",
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,33 @@ func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
|
||||||
for _, sub := range cmd.Commands() {
|
for _, sub := range cmd.Commands() {
|
||||||
names = append(names, sub.Name())
|
names = append(names, sub.Name())
|
||||||
}
|
}
|
||||||
want := []string{"daemon", "image", "tui", "vm"}
|
want := []string{"daemon", "image", "internal", "tui", "vm"}
|
||||||
if !reflect.DeepEqual(names, want) {
|
if !reflect.DeepEqual(names, want) {
|
||||||
t.Fatalf("subcommands = %v, want %v", 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) {
|
func TestVMCreateFlagsExist(t *testing.T) {
|
||||||
root := NewBangerCommand()
|
root := NewBangerCommand()
|
||||||
vm, _, err := root.Find([]string{"vm"})
|
vm, _, err := root.Find([]string{"vm"})
|
||||||
|
|
|
||||||
|
|
@ -2,46 +2,16 @@ package daemon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"banger/internal/hostnat"
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
type natRule struct {
|
type natRule = hostnat.Rule
|
||||||
table string
|
|
||||||
chain string
|
|
||||||
args []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error {
|
func (d *Daemon) ensureNAT(ctx context.Context, vm model.VMRecord, enable bool) error {
|
||||||
uplink, err := d.validateNATPrereqs(ctx)
|
return hostnat.Ensure(ctx, d.runner, vm.Runtime.GuestIP, vm.Runtime.TapDevice, enable)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Daemon) validateNATPrereqs(ctx context.Context) (string, error) {
|
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) {
|
func (d *Daemon) defaultUplink(ctx context.Context) (string, error) {
|
||||||
out, err := d.runner.Run(ctx, "ip", "route", "show", "default")
|
return hostnat.DefaultUplink(ctx, d.runner)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return parseDefaultUplink(string(out))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDefaultUplink(output string) (string, error) {
|
func parseDefaultUplink(output string) (string, error) {
|
||||||
for _, line := range strings.Split(output, "\n") {
|
return hostnat.ParseDefaultUplink(output)
|
||||||
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 natRulesForVM(vm model.VMRecord, uplink string) ([]natRule, error) {
|
func natRulesForVM(vm model.VMRecord, uplink string) ([]natRule, error) {
|
||||||
guestIP := strings.TrimSpace(vm.Runtime.GuestIP)
|
return hostnat.Rules(vm.Runtime.GuestIP, vm.Runtime.TapDevice, uplink)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func natRuleArgs(action string, rule natRule) []string {
|
func natRuleArgs(action string, rule natRule) []string {
|
||||||
args := make([]string, 0, len(rule.args)+4)
|
return hostnat.RuleArgs(action, rule)
|
||||||
if rule.table != "" {
|
|
||||||
args = append(args, "-t", rule.table)
|
|
||||||
}
|
|
||||||
args = append(args, action, rule.chain)
|
|
||||||
args = append(args, rule.args...)
|
|
||||||
return args
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func natAddPlan(rules []natRule) [][]string {
|
func natAddPlan(rules []natRule) [][]string {
|
||||||
plan := make([][]string, 0, len(rules)+1)
|
return hostnat.AddPlan(rules)
|
||||||
plan = append(plan, []string{"sysctl", "-w", "net.ipv4.ip_forward=1"})
|
|
||||||
for _, rule := range rules {
|
|
||||||
plan = append(plan, natRuleArgs("-A", rule))
|
|
||||||
}
|
|
||||||
return plan
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func natRemovePlan(rules []natRule) [][]string {
|
func natRemovePlan(rules []natRule) [][]string {
|
||||||
plan := make([][]string, 0, len(rules))
|
return hostnat.RemovePlan(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func natRuleKey(rule natRule) string {
|
func natRuleKey(rule natRule) string {
|
||||||
return fmt.Sprintf("%s:%s:%s", rule.table, rule.chain, strings.Join(rule.args, " "))
|
return hostnat.RuleKey(rule)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,8 @@ func TestNATPlans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rules := []natRule{
|
rules := []natRule{
|
||||||
{table: "nat", chain: "POSTROUTING", args: []string{"-s", "172.16.0.8/32", "-o", "eth0", "-j", "MASQUERADE"}},
|
{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"}},
|
{Chain: "FORWARD", Args: []string{"-i", "tap-fc-abcd1234", "-o", "eth0", "-j", "ACCEPT"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
addPlan := natAddPlan(rules)
|
addPlan := natAddPlan(rules)
|
||||||
|
|
|
||||||
145
internal/hostnat/hostnat.go
Normal file
145
internal/hostnat/hostnat.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
83
internal/hostnat/hostnat_test.go
Normal file
83
internal/hostnat/hostnat_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,6 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) {
|
||||||
"runtime/customize.sh": "#!/bin/bash\n",
|
"runtime/customize.sh": "#!/bin/bash\n",
|
||||||
"runtime/packages.sh": "#!/bin/bash\n",
|
"runtime/packages.sh": "#!/bin/bash\n",
|
||||||
"runtime/dns.sh": "#!/bin/bash\n",
|
"runtime/dns.sh": "#!/bin/bash\n",
|
||||||
"runtime/nat.sh": "#!/bin/bash\n",
|
|
||||||
"runtime/packages.apt": "vim\n",
|
"runtime/packages.apt": "vim\n",
|
||||||
"runtime/rootfs-docker.ext4": "rootfs",
|
"runtime/rootfs-docker.ext4": "rootfs",
|
||||||
"runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel",
|
"runtime/wtf/root/boot/vmlinux-6.8.0-94-generic": "kernel",
|
||||||
|
|
|
||||||
124
nat.sh
124
nat.sh
|
|
@ -1,124 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
log() {
|
|
||||||
printf '[nat] %s\n' "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: ./nat.sh <up|down|status> <id-or-name-prefix>
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -10,7 +10,6 @@ required_paths = [
|
||||||
"customize.sh",
|
"customize.sh",
|
||||||
"dns.sh",
|
"dns.sh",
|
||||||
"packages.sh",
|
"packages.sh",
|
||||||
"nat.sh",
|
|
||||||
"namegen",
|
"namegen",
|
||||||
"packages.apt",
|
"packages.apt",
|
||||||
"id_ed25519",
|
"id_ed25519",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue