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:
Thales Maciel 2026-03-17 15:07:49 -03:00
parent 60294e8c90
commit 430f66d5dd
13 changed files with 378 additions and 250 deletions

View file

@ -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 <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 {
cmd := &cobra.Command{
Use: "daemon",

View file

@ -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"})

View file

@ -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)
}

View file

@ -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)

145
internal/hostnat/hostnat.go Normal file
View 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
}

View 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)
}
}

View file

@ -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",