From 0a0b0b617b39297d42426763a49cb6f07cebfe41 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Tue, 17 Mar 2026 15:49:35 -0300 Subject: [PATCH] Replace mapdns with daemon DNS Serve daemon-managed .vm names directly from bangerd on 127.0.0.1:42069 instead of shelling out to mapdns. This keeps DNS state tied to VM lifecycle and lets the daemon rebuild records from running VMs after startup or reconcile. Add a small in-process authoritative DNS server, register and remove records from the VM start/stop/delete paths, and show the listener in daemon status. Remove the mapdns config and preflight surface, stop helper-flow DNS publishing in customize.sh and interactive.sh, drop dns.sh from the runtime bundle, and update docs/tests for the new local-resolver integration model. Validated with GOCACHE=/tmp/banger-gocache go test ./..., GOCACHE=/tmp/banger-gocache make build, and bash -n customize.sh interactive.sh. --- AGENTS.md | 2 +- Makefile | 2 +- README.md | 17 +- customize.sh | 8 +- dns.sh | 104 ----------- go.mod | 12 +- go.sum | 30 +-- interactive.sh | 8 +- internal/cli/banger.go | 5 +- internal/cli/cli_test.go | 3 + internal/config/config.go | 17 -- internal/config/config_test.go | 10 +- internal/daemon/daemon.go | 45 ++++- internal/daemon/daemon_test.go | 54 ++---- internal/daemon/images.go | 6 - internal/daemon/logger_test.go | 6 +- internal/daemon/preflight.go | 14 -- internal/daemon/vm.go | 61 +++--- internal/daemon/vm_test.go | 63 ++++++- internal/model/types.go | 2 - internal/runtimebundle/bundle_test.go | 1 - internal/vmdns/server.go | 257 ++++++++++++++++++++++++++ internal/vmdns/server_test.go | 126 +++++++++++++ runtime-bundle.toml | 1 - 24 files changed, 576 insertions(+), 278 deletions(-) delete mode 100644 dns.sh create mode 100644 internal/vmdns/server.go create mode 100644 internal/vmdns/server_test.go diff --git a/AGENTS.md b/AGENTS.md index cd4f1a1..bce8650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure & Module Organization - `cmd/banger` and `cmd/bangerd` are the primary user-facing entrypoints. - `internal/` contains the daemon, CLI, RPC, storage, Firecracker, and system integration code. -- `customize.sh`, `make-rootfs.sh`, and `interactive.sh` remain as image-build/customization helpers; normal VM lifecycle and NAT management are handled by the Go control plane. +- `customize.sh`, `make-rootfs.sh`, and `interactive.sh` remain as image-build/customization helpers; normal VM lifecycle, NAT, and `.vm` DNS are handled by the Go control plane. - Source checkouts use a generated `./runtime/` bundle for Firecracker, kernels, modules, rootfs images, and helper copies. Bundle defaults come from `./runtime/bundle.json` when present. Those runtime artifacts are not meant to be tracked directly in Git. - The daemon keeps state under XDG directories rather than the old repo-local `state/` layout. diff --git a/Makefile b/Makefile index 5283935..8a50f85 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 namegen +RUNTIME_EXECUTABLES := firecracker customize.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 c5b7659..ae668df 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ Persistent Firecracker development VMs managed through a Go daemon, CLI, and TUI - Guest rootfs patching: `e2cp`, `e2rm`, `debugfs` - Guest work disk creation/resizing: `mkfs.ext4`, `e2fsck`, `resize2fs`, `mount`, `umount`, `cp` - SSH and logs: `ssh` -- DNS publishing: `mapdns` - Optional NAT: `iptables`, `sysctl` - Image build helper flow: `bash`, `curl`, `jq`, `sha256sum` @@ -127,8 +126,8 @@ banger daemon socket banger daemon stop ``` -`banger daemon status` prints the daemon PID, socket path, and `bangerd.log` -location. +`banger daemon status` prints the daemon PID, socket path, daemon log path, and +the built-in DNS listener address. State lives under XDG directories: - config: `~/.config/banger` @@ -141,15 +140,10 @@ the executable. Source-checkout binaries resolve it from `./runtime` next to the repo-built `./banger`. You can override either with `runtime_dir` in `~/.config/banger/config.toml` or `BANGER_RUNTIME_DIR`. -`mapdns` uses its own default data store unless you set `mapdns_data_file` or -`BANGER_MAPDNS_DATA_FILE`. - Useful config keys: - `log_level` - `runtime_dir` - `firecracker_bin` -- `mapdns_bin` -- `mapdns_data_file` - `ssh_key_path` - `namegen_path` - `customize_script` @@ -202,7 +196,10 @@ 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`. +`bangerd` also serves a tiny authoritative DNS service on `127.0.0.1:42069` +for daemon-managed VMs. Known `A` records resolve `.vm` to the VM's +guest IPv4 address. Integrate your local resolver separately if you want +transparent `.vm` lookups on the host. ## Storage Model - VMs share a read-only base rootfs image. @@ -249,5 +246,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 -- `packages.sh`, `dns.sh`: shell helper libraries +- `packages.sh`: shell helper library - `verify.sh`: smoke test for the Go workflow (`./verify.sh --nat` adds NAT coverage) diff --git a/customize.sh b/customize.sh index 9039c2c..4312069 100755 --- a/customize.sh +++ b/customize.sh @@ -40,7 +40,6 @@ if [[ ! -d "$RUNTIME_DIR" ]]; then log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR" exit 1 fi -source "$RUNTIME_DIR/dns.sh" source "$RUNTIME_DIR/packages.sh" STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/image-build}" VM_ROOT="$STATE/vms" @@ -235,7 +234,6 @@ mkdir -p "$VM_DIR" API_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/banger/fc-$VM_TAG.sock" LOG_FILE="$VM_DIR/firecracker.log" TAP_DEV="tap-fc-$VM_TAG" -DNS_NAME="" # Allocate guest IP NEXT_IP_FILE="$STATE/next_ip" @@ -252,7 +250,6 @@ cleanup() { fi sudo ip link del "$TAP_DEV" 2>/dev/null || true rm -f "$API_SOCK" - banger_dns_remove_record_name "${DNS_NAME:-}" rm -rf "$VM_DIR" } trap cleanup EXIT @@ -340,8 +337,6 @@ fi VM_CONFIG_JSON="$(sudo -E curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)" CREATED_AT="$(date -Iseconds)" -DNS_NAME="$(banger_dns_name "$VM_NAME")" -banger_dns_write_record "$VM_NAME" "$GUEST_IP" jq -n \ --arg id "$VM_ID" \ --arg name "$VM_NAME" \ @@ -353,9 +348,8 @@ jq -n \ --arg log "$LOG_FILE" \ --arg rootfs "$OUT_ROOTFS" \ --arg kernel "$KERNEL" \ - --arg dns_name "$DNS_NAME" \ --argjson config "$VM_CONFIG_JSON" \ - '{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel,dns_name:$dns_name},config:$config}' \ + '{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel},config:$config}' \ > "$VM_DIR/vm.json" log "enabling NAT for customization" diff --git a/dns.sh b/dns.sh deleted file mode 100644 index 34142ce..0000000 --- a/dns.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash - -MAPDNS_BIN="${MAPDNS_BIN:-${BANGER_MAPDNS_BIN:-mapdns}}" -MAPDNS_DATA_FILE="${MAPDNS_DATA_FILE:-${BANGER_MAPDNS_DATA_FILE:-}}" - -banger_mapdns_cmd() { - local subcommand="$1" - shift - - if [[ -n "$MAPDNS_DATA_FILE" ]]; then - "$MAPDNS_BIN" "$subcommand" --data-file "$MAPDNS_DATA_FILE" "$@" - return - fi - "$MAPDNS_BIN" "$subcommand" "$@" -} - -banger_dns_name() { - local vm_name="$1" - printf '%s.vm' "$vm_name" -} - -banger_dns_write_record() { - local vm_name="$1" - local guest_ip="$2" - local dns_name - - if [[ -n "$MAPDNS_DATA_FILE" ]]; then - mkdir -p "$(dirname "$MAPDNS_DATA_FILE")" - fi - dns_name="$(banger_dns_name "$vm_name")" - banger_mapdns_cmd set "$dns_name" "$guest_ip" >/dev/null -} - -banger_dns_record_exists() { - local dns_name="$1" - - [[ -n "$dns_name" ]] || return 1 - banger_mapdns_cmd list | awk '{print $1}' | rg -Fxq "$dns_name" -} - -banger_dns_remove_record_name() { - local dns_name="${1:-}" - [[ -n "$dns_name" ]] || return 0 - if ! banger_dns_record_exists "$dns_name"; then - return 0 - fi - banger_mapdns_cmd rm "$dns_name" >/dev/null -} - -banger_vm_process_running() { - local pid="$1" - local api_sock="$2" - - [[ -n "$pid" && -n "$api_sock" ]] || return 1 - ps -p "$pid" -o comm=,args= 2>/dev/null | rg -q "firecracker.*--api-sock $api_sock" -} - -banger_wait_for_vm_exit() { - local pid="$1" - local api_sock="$2" - local timeout_secs="${3:-30}" - local deadline=$((SECONDS + timeout_secs)) - - while banger_vm_process_running "$pid" "$api_sock"; do - if (( SECONDS >= deadline )); then - return 1 - fi - sleep 0.1 - done -} - -banger_teardown_vm_runtime() { - local tap="${1:-}" - local api_sock="${2:-}" - local dm_name="${3:-}" - local dm_dev="${4:-}" - local cow_loop="${5:-}" - local base_loop="${6:-}" - - if [[ -n "$tap" ]]; then - sudo ip link del "$tap" 2>/dev/null || true - fi - if [[ -n "$api_sock" ]]; then - rm -f "$api_sock" - fi - if [[ -n "$dm_name" || -n "$dm_dev" ]]; then - sudo dmsetup remove "${dm_name:-$dm_dev}" 2>/dev/null || true - fi - if [[ -n "$cow_loop" ]]; then - sudo losetup -d "$cow_loop" 2>/dev/null || true - fi - if [[ -n "$base_loop" ]]; then - sudo losetup -d "$base_loop" 2>/dev/null || true - fi -} - -banger_mark_vm_stopped() { - local vm_json="$1" - - [[ -f "$vm_json" ]] || return 0 - jq \ - 'del(.meta.pid, .meta.base_loop, .meta.cow_loop, .meta.dm_dev)' \ - "$vm_json" > "$vm_json.tmp" && mv "$vm_json.tmp" "$vm_json" -} diff --git a/go.mod b/go.mod index e957a07..d699d5a 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/lipgloss v0.5.1-0.20220407020210-a86f21a0ae43 github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 github.com/mattn/go-isatty v0.0.20 + github.com/miekg/dns v1.1.72 github.com/pelletier/go-toml v1.9.5 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.8.1 @@ -59,10 +60,13 @@ require ( github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect go.mongodb.org/mongo-driver v1.8.3 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.40.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index a48e332..e9f1f16 100644 --- a/go.sum +++ b/go.sum @@ -516,6 +516,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mdlayher/socket v0.2.0/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/vsock v1.1.1/go.mod h1:Y43jzcy7KM3QB+/FK15pfqGxDMCMzUXWegEfIbSM18U= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -783,8 +785,9 @@ golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -817,8 +820,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -858,8 +861,9 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -876,8 +880,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -953,12 +957,13 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -967,8 +972,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1016,8 +1022,8 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/interactive.sh b/interactive.sh index 2902b6b..b89114b 100755 --- a/interactive.sh +++ b/interactive.sh @@ -41,7 +41,6 @@ if [[ ! -d "$RUNTIME_DIR" ]]; then log "run 'make runtime-bundle' or set BANGER_RUNTIME_DIR" exit 1 fi -source "$RUNTIME_DIR/dns.sh" STATE="${BANGER_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/banger/interactive}" VM_ROOT="$STATE/vms" mkdir -p "$VM_ROOT" @@ -180,7 +179,6 @@ mkdir -p "$VM_DIR" API_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/banger/fc-$VM_TAG.sock" LOG_FILE="$VM_DIR/firecracker.log" TAP_DEV="tap-fc-$VM_TAG" -DNS_NAME="" # Allocate guest IP NEXT_IP_FILE="$STATE/next_ip" @@ -197,7 +195,6 @@ cleanup() { fi sudo ip link del "$TAP_DEV" 2>/dev/null || true rm -f "$API_SOCK" - banger_dns_remove_record_name "${DNS_NAME:-}" rm -rf "$VM_DIR" } trap cleanup EXIT @@ -281,8 +278,6 @@ fi VM_CONFIG_JSON="$(sudo -E curl --unix-socket "$API_SOCK" -sS http://localhost/vm/config)" CREATED_AT="$(date -Iseconds)" -DNS_NAME="$(banger_dns_name "$VM_NAME")" -banger_dns_write_record "$VM_NAME" "$GUEST_IP" jq -n \ --arg id "$VM_ID" \ --arg name "$VM_NAME" \ @@ -294,9 +289,8 @@ jq -n \ --arg log "$LOG_FILE" \ --arg rootfs "$OUT_ROOTFS" \ --arg kernel "$KERNEL" \ - --arg dns_name "$DNS_NAME" \ --argjson config "$VM_CONFIG_JSON" \ - '{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel,dns_name:$dns_name},config:$config}' \ + '{meta:{id:$id,name:$name,pid:$pid,created_at:$created_at,guest_ip:$guest_ip,tap:$tap,api_sock:$api_sock,log:$log,rootfs:$rootfs,kernel:$kernel},config:$config}' \ > "$VM_DIR/vm.json" log "enabling NAT for interactive session" diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 089bb83..7184c61 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -20,6 +20,7 @@ import ( "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" + "banger/internal/vmdns" "github.com/spf13/cobra" ) @@ -112,10 +113,10 @@ func newDaemonCommand() *cobra.Command { } ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{}) if pingErr != nil { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\n", layout.SocketPath, layout.DaemonLog) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } - _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, }, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4671c39..6a60f8b 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -263,6 +263,9 @@ func TestDaemonStatusIncludesLogPathWhenStopped(t *testing.T) { if !strings.Contains(output, "log: "+filepath.Join(stateHome, "banger", "bangerd.log")) { t.Fatalf("output = %q, want daemon log path", output) } + if !strings.Contains(output, "dns: 127.0.0.1:42069") { + t.Fatalf("output = %q, want dns listener", output) + } } func TestBuildDaemonCommandIsDetachedFromCallerContext(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 52f650c..6fbd4e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,8 +18,6 @@ type fileConfig struct { RepoRoot string `toml:"repo_root"` LogLevel string `toml:"log_level"` FirecrackerBin string `toml:"firecracker_bin"` - MapDNSBin string `toml:"mapdns_bin"` - MapDNSDataFile string `toml:"mapdns_data_file"` SSHKeyPath string `toml:"ssh_key_path"` NamegenPath string `toml:"namegen_path"` CustomizeScript string `toml:"customize_script"` @@ -80,12 +78,6 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { if file.LogLevel != "" { cfg.LogLevel = file.LogLevel } - if file.MapDNSBin != "" { - cfg.MapDNSBin = file.MapDNSBin - } - if file.MapDNSDataFile != "" { - cfg.MapDNSDataFile = file.MapDNSDataFile - } if file.SSHKeyPath != "" { cfg.SSHKeyPath = file.SSHKeyPath } @@ -149,18 +141,9 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { } cfg.MetricsPollInterval = duration } - if value := os.Getenv("BANGER_MAPDNS_BIN"); value != "" { - cfg.MapDNSBin = value - } - if value := os.Getenv("BANGER_MAPDNS_DATA_FILE"); value != "" { - cfg.MapDNSDataFile = value - } if value := os.Getenv("BANGER_LOG_LEVEL"); value != "" { cfg.LogLevel = value } - if cfg.MapDNSBin == "" { - cfg.MapDNSBin = "mapdns" - } return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 37d6aa4..39ddc7c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -127,9 +127,7 @@ func TestLoadFallsBackToLegacyRuntimeLayoutWithoutBundleMetadata(t *testing.T) { } } -func TestLoadAppliesMapDNSEnvOverrides(t *testing.T) { - t.Setenv("BANGER_MAPDNS_BIN", "/opt/bin/mapdns") - t.Setenv("BANGER_MAPDNS_DATA_FILE", "/tmp/mapdns-records.json") +func TestLoadAppliesLogLevelEnvOverride(t *testing.T) { t.Setenv("BANGER_LOG_LEVEL", "debug") cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) @@ -137,12 +135,6 @@ func TestLoadAppliesMapDNSEnvOverrides(t *testing.T) { t.Fatalf("Load: %v", err) } - if cfg.MapDNSBin != "/opt/bin/mapdns" { - t.Fatalf("MapDNSBin = %q", cfg.MapDNSBin) - } - if cfg.MapDNSDataFile != "/tmp/mapdns-records.json" { - t.Fatalf("MapDNSDataFile = %q", cfg.MapDNSDataFile) - } if cfg.LogLevel != "debug" { t.Fatalf("LogLevel = %q", cfg.LogLevel) } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f7b1918..82a7917 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -22,6 +22,7 @@ import ( "banger/internal/rpc" "banger/internal/store" "banger/internal/system" + "banger/internal/vmdns" ) type Daemon struct { @@ -35,10 +36,11 @@ type Daemon struct { once sync.Once pid int listener net.Listener + vmDNS *vmdns.Server requestHandler func(context.Context, rpc.Request) rpc.Response } -func Open(ctx context.Context) (*Daemon, error) { +func Open(ctx context.Context) (d *Daemon, err error) { layout, err := paths.Resolve() if err != nil { return nil, err @@ -59,7 +61,7 @@ func Open(ctx context.Context) (*Daemon, error) { if err != nil { return nil, err } - d := &Daemon{ + d = &Daemon{ layout: layout, config: cfg, store: db, @@ -69,11 +71,20 @@ func Open(ctx context.Context) (*Daemon, error) { pid: os.Getpid(), } d.logger.Info("daemon opened", "socket", layout.SocketPath, "state_dir", layout.StateDir, "runtime_dir", cfg.RuntimeDir, "log_level", cfg.LogLevel) - if err := d.ensureDefaultImage(ctx); err != nil { + if err = d.startVMDNS(vmdns.DefaultListenAddr); err != nil { + d.logger.Error("daemon open failed", "stage", "start_vm_dns", "error", err.Error()) + return nil, err + } + defer func() { + if err != nil { + _ = d.stopVMDNS() + } + }() + if err = d.ensureDefaultImage(ctx); err != nil { d.logger.Error("daemon open failed", "stage", "ensure_default_image", "error", err.Error()) return nil, err } - if err := d.reconcile(ctx); err != nil { + if err = d.reconcile(ctx); err != nil { d.logger.Error("daemon open failed", "stage", "reconcile", "error", err.Error()) return nil, err } @@ -90,7 +101,7 @@ func (d *Daemon) Close() error { if d.listener != nil { _ = d.listener.Close() } - err = d.store.Close() + err = errors.Join(d.stopVMDNS(), d.store.Close()) }) return err } @@ -358,6 +369,27 @@ func (d *Daemon) backgroundLoop() { } } +func (d *Daemon) startVMDNS(addr string) error { + server, err := vmdns.New(addr, d.logger) + if err != nil { + return err + } + d.vmDNS = server + if d.logger != nil { + d.logger.Info("vm dns serving", "dns_addr", server.Addr()) + } + return nil +} + +func (d *Daemon) stopVMDNS() error { + if d.vmDNS == nil { + return nil + } + err := d.vmDNS.Close() + d.vmDNS = nil + return err +} + func (d *Daemon) ensureDefaultImage(ctx context.Context) error { if d.config.DefaultImageName == "" { return nil @@ -477,6 +509,9 @@ func (d *Daemon) reconcile(ctx context.Context) error { return op.fail(err, vmLogAttrs(vm)...) } } + if err := d.rebuildDNS(ctx); err != nil { + return op.fail(err) + } op.done() return nil } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 80211c0..1197f6e 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -283,58 +283,36 @@ func writeDefaultImageArtifacts(t *testing.T, dir string) (rootfs, kernel, initr return rootfs, kernel, initrd, modulesDir, packages } -func TestSetDNSUsesConfiguredMapDNSDataFile(t *testing.T) { +func TestStartVMDNSFailsWhenAddressBusy(t *testing.T) { t.Parallel() - dataFile := filepath.Join(t.TempDir(), "mapdns", "records.json") - runner := &scriptedRunner{ - t: t, - steps: []runnerStep{ - { - call: runnerCall{ - name: "custom-mapdns", - args: []string{"set", "--data-file", dataFile, "devbox.vm", "172.16.0.8"}, - }, - }, - }, - } - d := &Daemon{ - runner: runner, - config: model.DaemonConfig{ - MapDNSBin: "custom-mapdns", - MapDNSDataFile: dataFile, - }, + packetConn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) } + defer packetConn.Close() - if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { - t.Fatalf("setDNS: %v", err) + d := &Daemon{} + if err := d.startVMDNS(packetConn.LocalAddr().String()); err == nil { + t.Fatal("startVMDNS() succeeded on occupied address, want failure") } - runner.assertExhausted() } -func TestSetDNSUsesMapDNSDefaultsWhenDataFileUnset(t *testing.T) { +func TestSetDNSPublishesIntoDaemonServer(t *testing.T) { t.Parallel() - runner := &scriptedRunner{ - t: t, - steps: []runnerStep{ - { - call: runnerCall{ - name: "mapdns", - args: []string{"set", "devbox.vm", "172.16.0.8"}, - }, - }, - }, - } - d := &Daemon{ - runner: runner, - config: model.DaemonConfig{}, + d := &Daemon{} + if err := d.startVMDNS("127.0.0.1:0"); err != nil { + t.Fatalf("startVMDNS: %v", err) } + defer d.stopVMDNS() if err := d.setDNS(context.Background(), "devbox", "172.16.0.8"); err != nil { t.Fatalf("setDNS: %v", err) } - runner.assertExhausted() + if _, ok := d.vmDNS.Lookup("devbox.vm"); !ok { + t.Fatal("devbox.vm missing after setDNS") + } } func TestDispatchUsesPassedContext(t *testing.T) { diff --git a/internal/daemon/images.go b/internal/daemon/images.go index 8215d5e..18f9599 100644 --- a/internal/daemon/images.go +++ b/internal/daemon/images.go @@ -109,12 +109,6 @@ func (d *Daemon) BuildImage(ctx context.Context, params api.ImageBuildParams) (i "BANGER_RUNTIME_DIR="+d.config.RuntimeDir, "BANGER_STATE_DIR="+filepath.Join(d.layout.StateDir, "image-build"), ) - if d.config.MapDNSBin != "" { - cmd.Env = append(cmd.Env, "BANGER_MAPDNS_BIN="+d.config.MapDNSBin) - } - if d.config.MapDNSDataFile != "" { - cmd.Env = append(cmd.Env, "BANGER_MAPDNS_DATA_FILE="+d.config.MapDNSDataFile) - } if err := cmd.Run(); err != nil { _ = os.RemoveAll(artifactDir) return model.Image{}, err diff --git a/internal/daemon/logger_test.go b/internal/daemon/logger_test.go index 9bf2eb8..1c73813 100644 --- a/internal/daemon/logger_test.go +++ b/internal/daemon/logger_test.go @@ -48,7 +48,7 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { for _, name := range []string{ "sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps", "chown", "chmod", "kill", "e2cp", "e2rm", "debugfs", "mkfs.ext4", "mount", - "umount", "cp", "mapdns", + "umount", "cp", } { writeFakeExecutable(t, filepath.Join(binDir, name)) } @@ -98,7 +98,6 @@ func TestStartVMLockedLogsBridgeFailure(t *testing.T) { BridgeIP: model.DefaultBridgeIP, DefaultDNS: model.DefaultDNS, FirecrackerBin: firecrackerBin, - MapDNSBin: "mapdns", StatsPollInterval: model.DefaultStatsPollInterval, }, runner: runner, @@ -130,7 +129,7 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { } binDir := t.TempDir() - for _, name := range []string{"sudo", "ip", "curl", "ssh", "jq", "sha256sum", "e2fsck", "resize2fs", "mapdns"} { + for _, name := range []string{"sudo", "ip", "curl", "ssh", "jq", "sha256sum", "e2fsck", "resize2fs"} { writeFakeExecutable(t, filepath.Join(binDir, name)) } bashPath, err := exec.LookPath("bash") @@ -169,7 +168,6 @@ func TestBuildImagePreservesBuildLogOnFailure(t *testing.T) { config: model.DaemonConfig{ RuntimeDir: t.TempDir(), CustomizeScript: script, - MapDNSBin: "mapdns", DefaultImageName: "default", }, store: store, diff --git a/internal/daemon/preflight.go b/internal/daemon/preflight.go index 8441a0b..3df2e66 100644 --- a/internal/daemon/preflight.go +++ b/internal/daemon/preflight.go @@ -2,8 +2,6 @@ package daemon import ( "context" - "os" - "path/filepath" "strings" "banger/internal/model" @@ -19,7 +17,6 @@ func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, im checks.RequireCommand(command, toolHint(command)) } checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", hint) - checks.RequireExecutable(d.config.MapDNSBin, "mapdns binary", `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN`) checks.RequireFile(image.RootfsPath, "rootfs image", "select a valid image or rebuild the runtime bundle") checks.RequireFile(image.KernelPath, "kernel image", `set "default_kernel" or refresh the runtime bundle`) if strings.TrimSpace(image.InitrdPath) != "" { @@ -33,14 +30,6 @@ func (d *Daemon) validateStartPrereqs(ctx context.Context, vm model.VMRecord, im if vm.Spec.NATEnabled { d.addNATPrereqs(ctx, checks) } - if dataFile := strings.TrimSpace(d.config.MapDNSDataFile); dataFile != "" { - parent := filepath.Dir(dataFile) - if parent != "." && parent != "" { - if _, err := os.Stat(parent); err != nil && !os.IsNotExist(err) { - checks.Addf("mapdns data directory %s is not accessible (%v)", parent, err) - } - } - } return checks.Err("vm start preflight failed") } @@ -52,7 +41,6 @@ func (d *Daemon) validateImageBuildPrereqs(ctx context.Context, baseRootfs, kern checks.RequireCommand(command, toolHint(command)) } checks.RequireExecutable(d.config.CustomizeScript, "customize.sh helper", hint) - checks.RequireExecutable(d.config.MapDNSBin, "mapdns binary", `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN`) checks.RequireFile(baseRootfs, "base rootfs image", `pass --base-rootfs or set "default_base_rootfs"`) checks.RequireFile(kernelPath, "kernel image", `pass --kernel or set "default_kernel"`) if strings.TrimSpace(initrdPath) != "" { @@ -109,8 +97,6 @@ func toolHint(command string) string { return "install jq" case "sha256sum": return "install coreutils" - case "mapdns": - return `install mapdns or set "mapdns_bin" / BANGER_MAPDNS_BIN` case "ssh": return "install openssh-client" case "bash": diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 3f3984c..7088a74 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -15,6 +15,7 @@ import ( "banger/internal/model" "banger/internal/paths" "banger/internal/system" + "banger/internal/vmdns" ) func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm model.VMRecord, err error) { @@ -100,7 +101,7 @@ func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm mo Runtime: model.VMRuntime{ State: model.VMStateCreated, GuestIP: guestIP, - DNSName: name + ".vm", + DNSName: vmdns.RecordName(name), VMDir: vmDir, SystemOverlay: filepath.Join(vmDir, "system.cow"), WorkDiskPath: filepath.Join(vmDir, "root.ext4"), @@ -859,30 +860,44 @@ func clearRuntimeHandles(vm *model.VMRecord) { } func (d *Daemon) setDNS(ctx context.Context, vmName, guestIP string) error { - if dataFile := strings.TrimSpace(d.config.MapDNSDataFile); dataFile != "" { - if err := os.MkdirAll(filepath.Dir(dataFile), 0o755); err != nil { - return err - } + if d.vmDNS == nil { + return nil } - _, err := d.runner.Run(ctx, d.mapdnsBinary(), d.mapdnsArgs("set", vmName+".vm", guestIP)...) - if err == nil && d.logger != nil { - d.logger.Debug("dns record set", "dns_name", vmName+".vm", "guest_ip", guestIP) - } - return err + return d.vmDNS.Set(vmdns.RecordName(vmName), guestIP) } func (d *Daemon) removeDNS(ctx context.Context, dnsName string) error { if dnsName == "" { return nil } - _, err := d.runner.Run(ctx, d.mapdnsBinary(), d.mapdnsArgs("rm", dnsName)...) - if err != nil && strings.Contains(err.Error(), "not found") { + if d.vmDNS == nil { return nil } - if err == nil && d.logger != nil { - d.logger.Debug("dns record removed", "dns_name", dnsName) + return d.vmDNS.Remove(dnsName) +} + +func (d *Daemon) rebuildDNS(ctx context.Context) error { + if d.vmDNS == nil { + return nil } - return err + vms, err := d.store.ListVMs(ctx) + if err != nil { + return err + } + records := make(map[string]string) + for _, vm := range vms { + if vm.State != model.VMStateRunning { + continue + } + if !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + continue + } + if strings.TrimSpace(vm.Runtime.GuestIP) == "" { + continue + } + records[vmdns.RecordName(vm.Name)] = vm.Runtime.GuestIP + } + return d.vmDNS.Replace(records) } func (d *Daemon) killVMProcess(ctx context.Context, pid int) error { @@ -927,19 +942,3 @@ func validateOptionalPositiveSetting(label string, value *int) error { } return nil } - -func (d *Daemon) mapdnsBinary() string { - if value := strings.TrimSpace(d.config.MapDNSBin); value != "" { - return value - } - return "mapdns" -} - -func (d *Daemon) mapdnsArgs(subcommand string, args ...string) []string { - out := []string{subcommand} - if value := strings.TrimSpace(d.config.MapDNSDataFile); value != "" { - out = append(out, "--data-file", value) - } - out = append(out, args...) - return out -} diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 098fc17..ffed6d1 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -15,6 +15,7 @@ import ( "banger/internal/model" "banger/internal/paths" "banger/internal/store" + "banger/internal/vmdns" ) func TestFindVMPrefixResolution(t *testing.T) { @@ -143,6 +144,65 @@ func TestReconcileStopsStaleRunningVMAndClearsRuntimeHandles(t *testing.T) { } } +func TestRebuildDNSIncludesOnlyLiveRunningVMs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db := openDaemonStore(t) + + liveSock := filepath.Join(t.TempDir(), "live.sock") + liveCmd := startFakeFirecrackerProcess(t, liveSock) + t.Cleanup(func() { + _ = liveCmd.Process.Kill() + _ = liveCmd.Wait() + }) + + live := testVM("live", "image-live", "172.16.0.21") + live.State = model.VMStateRunning + live.Runtime.State = model.VMStateRunning + live.Runtime.PID = liveCmd.Process.Pid + live.Runtime.APISockPath = liveSock + + stale := testVM("stale", "image-stale", "172.16.0.22") + stale.State = model.VMStateRunning + stale.Runtime.State = model.VMStateRunning + stale.Runtime.PID = 999999 + stale.Runtime.APISockPath = filepath.Join(t.TempDir(), "stale.sock") + + stopped := testVM("stopped", "image-stopped", "172.16.0.23") + + for _, vm := range []model.VMRecord{live, stale, stopped} { + if err := db.UpsertVM(ctx, vm); err != nil { + t.Fatalf("UpsertVM(%s): %v", vm.Name, err) + } + } + + server, err := vmdns.New("127.0.0.1:0", nil) + if err != nil { + t.Fatalf("vmdns.New: %v", err) + } + t.Cleanup(func() { + if err := server.Close(); err != nil { + t.Fatalf("server.Close: %v", err) + } + }) + + d := &Daemon{store: db, vmDNS: server} + if err := d.rebuildDNS(ctx); err != nil { + t.Fatalf("rebuildDNS: %v", err) + } + + if _, ok := server.Lookup("live.vm"); !ok { + t.Fatal("live.vm missing after rebuildDNS") + } + if _, ok := server.Lookup("stale.vm"); ok { + t.Fatal("stale.vm should not be published") + } + if _, ok := server.Lookup("stopped.vm"); ok { + t.Fatal("stopped.vm should not be published") + } +} + func TestSetVMRejectsStoppedOnlyChangesForRunningVM(t *testing.T) { t.Parallel() @@ -316,7 +376,7 @@ func TestValidateStartPrereqsReportsNATUplinkFailure(t *testing.T) { for _, name := range []string{ "sudo", "ip", "dmsetup", "losetup", "blockdev", "truncate", "pgrep", "ps", "chown", "chmod", "kill", "e2cp", "e2rm", "debugfs", "mkfs.ext4", "mount", - "umount", "cp", "iptables", "sysctl", "mapdns", + "umount", "cp", "iptables", "sysctl", } { writeFakeExecutable(t, filepath.Join(binDir, name)) } @@ -339,7 +399,6 @@ func TestValidateStartPrereqsReportsNATUplinkFailure(t *testing.T) { runner: runner, config: model.DaemonConfig{ FirecrackerBin: firecrackerBin, - MapDNSBin: "mapdns", }, } vm := testVM("nat", "image-nat", "172.16.0.12") diff --git a/internal/model/types.go b/internal/model/types.go index 25219d2..1536746 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -38,8 +38,6 @@ type DaemonConfig struct { RuntimeDir string LogLevel string FirecrackerBin string - MapDNSBin string - MapDNSDataFile string SSHKeyPath string NamegenPath string CustomizeScript string diff --git a/internal/runtimebundle/bundle_test.go b/internal/runtimebundle/bundle_test.go index a8de310..b4683b4 100644 --- a/internal/runtimebundle/bundle_test.go +++ b/internal/runtimebundle/bundle_test.go @@ -22,7 +22,6 @@ func TestBootstrapExtractsBundleAndValidatesChecksum(t *testing.T) { "runtime/namegen": "namegen", "runtime/customize.sh": "#!/bin/bash\n", "runtime/packages.sh": "#!/bin/bash\n", - "runtime/dns.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/internal/vmdns/server.go b/internal/vmdns/server.go new file mode 100644 index 0000000..277144c --- /dev/null +++ b/internal/vmdns/server.go @@ -0,0 +1,257 @@ +package vmdns + +import ( + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +const ( + DefaultListenAddr = "127.0.0.1:42069" + recordTTLSeconds = 5 + vmZoneSuffix = ".vm." +) + +type Server struct { + logger *slog.Logger + + mu sync.RWMutex + records map[string]netip.Addr + + addr string + server *dns.Server + conn net.PacketConn + done chan error +} + +func New(addr string, logger *slog.Logger) (*Server, error) { + packetConn, err := net.ListenPacket("udp", addr) + if err != nil { + return nil, err + } + + s := &Server{ + logger: logger, + records: make(map[string]netip.Addr), + addr: packetConn.LocalAddr().String(), + conn: packetConn, + done: make(chan error, 1), + } + s.server = &dns.Server{ + PacketConn: packetConn, + Handler: dns.HandlerFunc(s.handleDNS), + } + + go func() { + s.done <- s.server.ActivateAndServe() + close(s.done) + }() + + return s, nil +} + +func (s *Server) Addr() string { + if s == nil { + return "" + } + return s.addr +} + +func (s *Server) Close() error { + if s == nil || s.server == nil { + return nil + } + connErr := error(nil) + if s.conn != nil { + connErr = s.conn.Close() + s.conn = nil + } + shutdownErr := s.server.Shutdown() + if isIgnorableCloseErr(shutdownErr) { + shutdownErr = nil + } + var serveErr error + select { + case serveErr = <-s.done: + case <-time.After(2 * time.Second): + serveErr = errors.New("timed out waiting for vm dns server shutdown") + } + if isClosedServeErr(serveErr) { + serveErr = nil + } + s.server = nil + s.done = nil + return errors.Join(connErr, shutdownErr, serveErr) +} + +func (s *Server) Set(name, guestIP string) error { + if s == nil { + return nil + } + addr, err := netip.ParseAddr(strings.TrimSpace(guestIP)) + if err != nil { + return fmt.Errorf("parse guest IP %q: %w", guestIP, err) + } + if !addr.Is4() { + return fmt.Errorf("guest IP must be IPv4: %q", guestIP) + } + fqdn, err := normalizeVMName(name) + if err != nil { + return err + } + s.mu.Lock() + s.records[fqdn] = addr + s.mu.Unlock() + if s.logger != nil { + s.logger.Debug("vm dns record set", "dns_name", displayName(fqdn), "guest_ip", addr.String()) + } + return nil +} + +func (s *Server) Remove(name string) error { + if s == nil { + return nil + } + fqdn, err := normalizeVMName(name) + if err != nil { + return nil + } + s.mu.Lock() + delete(s.records, fqdn) + s.mu.Unlock() + if s.logger != nil { + s.logger.Debug("vm dns record removed", "dns_name", displayName(fqdn)) + } + return nil +} + +func (s *Server) Replace(records map[string]string) error { + if s == nil { + return nil + } + next := make(map[string]netip.Addr, len(records)) + for name, guestIP := range records { + fqdn, err := normalizeVMName(name) + if err != nil { + return err + } + addr, err := netip.ParseAddr(strings.TrimSpace(guestIP)) + if err != nil { + return fmt.Errorf("parse guest IP for %s: %w", name, err) + } + if !addr.Is4() { + return fmt.Errorf("guest IP for %s must be IPv4: %q", name, guestIP) + } + next[fqdn] = addr + } + s.mu.Lock() + s.records = next + s.mu.Unlock() + return nil +} + +func (s *Server) Lookup(name string) (netip.Addr, bool) { + if s == nil { + return netip.Addr{}, false + } + fqdn, err := normalizeVMName(name) + if err != nil { + return netip.Addr{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + addr, ok := s.records[fqdn] + return addr, ok +} + +func RecordName(vmName string) string { + name := strings.TrimSpace(strings.ToLower(vmName)) + name = strings.TrimSuffix(name, ".") + if strings.HasSuffix(name, ".vm") { + return name + } + return name + ".vm" +} + +func normalizeVMName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("dns name is required") + } + fqdn := strings.ToLower(dns.Fqdn(name)) + if !strings.HasSuffix(fqdn, vmZoneSuffix) { + return "", fmt.Errorf("dns name must end with .vm: %q", name) + } + return fqdn, nil +} + +func displayName(fqdn string) string { + return strings.TrimSuffix(fqdn, ".") +} + +func isVMQueryName(name string) bool { + return strings.HasSuffix(strings.ToLower(dns.Fqdn(name)), vmZoneSuffix) +} + +func (s *Server) handleDNS(w dns.ResponseWriter, req *dns.Msg) { + resp := new(dns.Msg) + resp.SetReply(req) + resp.Authoritative = true + + if len(req.Question) == 0 { + resp.Rcode = dns.RcodeFormatError + _ = w.WriteMsg(resp) + return + } + + question := req.Question[0] + if !isVMQueryName(question.Name) { + resp.Rcode = dns.RcodeRefused + _ = w.WriteMsg(resp) + return + } + + addr, ok := s.Lookup(question.Name) + if !ok { + resp.Rcode = dns.RcodeNameError + _ = w.WriteMsg(resp) + return + } + + if question.Qtype == dns.TypeA { + resp.Answer = []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: strings.ToLower(dns.Fqdn(question.Name)), + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: recordTTLSeconds, + }, + A: net.IP(addr.AsSlice()), + }, + } + } + + _ = w.WriteMsg(resp) +} + +func isClosedServeErr(err error) bool { + if err == nil { + return true + } + return errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "closed") +} + +func isIgnorableCloseErr(err error) bool { + if err == nil { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "server not started") +} diff --git a/internal/vmdns/server_test.go b/internal/vmdns/server_test.go new file mode 100644 index 0000000..95cfad0 --- /dev/null +++ b/internal/vmdns/server_test.go @@ -0,0 +1,126 @@ +package vmdns + +import ( + "net" + "testing" + + "github.com/miekg/dns" +) + +func TestRecordName(t *testing.T) { + if got := RecordName("DevBox"); got != "devbox.vm" { + t.Fatalf("RecordName = %q, want devbox.vm", got) + } + if got := RecordName("already.vm"); got != "already.vm" { + t.Fatalf("RecordName = %q, want already.vm", got) + } +} + +func TestServerAnswersVMQueries(t *testing.T) { + server := startTestServer(t) + if err := server.Set("devbox.vm", "172.16.0.8"); err != nil { + t.Fatalf("Set: %v", err) + } + + t.Run("A record", func(t *testing.T) { + resp := exchangeQuery(t, server.Addr(), "devbox.vm.", dns.TypeA) + if resp.Rcode != dns.RcodeSuccess { + t.Fatalf("rcode = %d, want success", resp.Rcode) + } + if len(resp.Answer) != 1 { + t.Fatalf("answer count = %d, want 1", len(resp.Answer)) + } + a, ok := resp.Answer[0].(*dns.A) + if !ok { + t.Fatalf("answer type = %T, want *dns.A", resp.Answer[0]) + } + if got := a.A.String(); got != "172.16.0.8" { + t.Fatalf("A = %q, want 172.16.0.8", got) + } + }) + + t.Run("known AAAA returns NODATA", func(t *testing.T) { + resp := exchangeQuery(t, server.Addr(), "devbox.vm.", dns.TypeAAAA) + if resp.Rcode != dns.RcodeSuccess { + t.Fatalf("rcode = %d, want success", resp.Rcode) + } + if len(resp.Answer) != 0 { + t.Fatalf("answer count = %d, want 0", len(resp.Answer)) + } + }) + + t.Run("unknown name returns NXDOMAIN", func(t *testing.T) { + resp := exchangeQuery(t, server.Addr(), "missing.vm.", dns.TypeA) + if resp.Rcode != dns.RcodeNameError { + t.Fatalf("rcode = %d, want NXDOMAIN", resp.Rcode) + } + }) + + t.Run("outside zone returns REFUSED", func(t *testing.T) { + resp := exchangeQuery(t, server.Addr(), "example.com.", dns.TypeA) + if resp.Rcode != dns.RcodeRefused { + t.Fatalf("rcode = %d, want REFUSED", resp.Rcode) + } + }) +} + +func TestServerReplaceSwapsRecordSet(t *testing.T) { + server := startTestServer(t) + if err := server.Replace(map[string]string{ + "alpha.vm": "172.16.0.2", + "beta.vm": "172.16.0.3", + }); err != nil { + t.Fatalf("Replace: %v", err) + } + if _, ok := server.Lookup("alpha.vm"); !ok { + t.Fatal("alpha.vm missing after replace") + } + if err := server.Replace(map[string]string{"beta.vm": "172.16.0.4"}); err != nil { + t.Fatalf("Replace second set: %v", err) + } + if _, ok := server.Lookup("alpha.vm"); ok { + t.Fatal("alpha.vm should have been removed by replace") + } + addr, ok := server.Lookup("beta.vm") + if !ok || addr.String() != "172.16.0.4" { + t.Fatalf("beta.vm = %v, %v, want 172.16.0.4", addr, ok) + } +} + +func TestServerFailsWhenAddressAlreadyInUse(t *testing.T) { + packetConn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) + } + defer packetConn.Close() + + if _, err := New(packetConn.LocalAddr().String(), nil); err == nil { + t.Fatal("New() succeeded on occupied UDP address, want failure") + } +} + +func startTestServer(t *testing.T) *Server { + t.Helper() + server, err := New("127.0.0.1:0", nil) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { + if err := server.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + }) + return server +} + +func exchangeQuery(t *testing.T, addr, name string, qtype uint16) *dns.Msg { + t.Helper() + client := &dns.Client{Net: "udp"} + req := new(dns.Msg) + req.SetQuestion(name, qtype) + resp, _, err := client.Exchange(req, addr) + if err != nil { + t.Fatalf("Exchange(%s, %d): %v", name, qtype, err) + } + return resp +} diff --git a/runtime-bundle.toml b/runtime-bundle.toml index d3d5c4a..2e8075d 100644 --- a/runtime-bundle.toml +++ b/runtime-bundle.toml @@ -8,7 +8,6 @@ bundle_root = "runtime" required_paths = [ "firecracker", "customize.sh", - "dns.sh", "packages.sh", "namegen", "packages.apt",