feat: add guest network autoconfiguration via Firecracker MMDS

Introduces optional per-clone IP assignment using the Firecracker Microvm
Metadata Service (MMDS). A background daemon (fc-net-init) is baked into
the rootfs during init and captured in the golden snapshot — on clone
resume it polls 169.254.169.254 and applies the IP/GW/DNS config injected
by the orchestrator immediately after snapshot restore.

- config.go: add AutoNetConfig bool (FC_AUTO_NET_CONFIG=1)
- orchestrator.go: embed fc-net-init daemon + MMDS link-local route in
  init script; set AllowMMDS: true on golden NIC; spawnOne/SpawnSingle
  accept net bool and propagate it via FC_AUTO_NET_CONFIG in proxy env
- console.go: set AllowMMDS: true on clone NIC; call configureMmds()
  after m.Start() when AutoNetConfig is enabled
- network.go: add configureMmds() — PUT /mmds with ip/gw/dns over the
  clone's Firecracker Unix socket
- serve.go: POST /clones accepts optional {"net": bool} body to override
  the global AutoNetConfig default per-request
- web/terminal.html: spawn button always sends {"net": true}
- docs/commands.md: document manual config + MMDS autoconfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 11:58:59 +00:00
parent 82c11dd2f8
commit 5e23e0ab4e
7 changed files with 197 additions and 19 deletions

View File

@@ -14,11 +14,12 @@ type Config struct {
Rootfs string // path to base rootfs.ext4
VCPUs int64
MemMiB int64
Bridge string // host bridge name, or "none" to skip networking
BridgeCIDR string // e.g. "172.30.0.1/24"
GuestPrefix string // e.g. "172.30.0" — clones get .10, .11, ...
GuestGW string
BootArgs string
Bridge string // host bridge name, or "none" to skip networking
BridgeCIDR string // e.g. "172.30.0.1/24"
GuestPrefix string // e.g. "172.30.0" — clones get .11, .12, ...
GuestGW string // default gateway for guest VMs
AutoNetConfig bool // inject guest IP/GW/DNS via MMDS on clone start
BootArgs string
}
func DefaultConfig() Config {
@@ -29,9 +30,10 @@ func DefaultConfig() Config {
MemMiB: envOrInt("FC_MEM_MIB", 128),
Bridge: envOr("FC_BRIDGE", "fcbr0"),
BridgeCIDR: envOr("FC_BRIDGE_CIDR", "172.30.0.1/24"),
GuestPrefix: envOr("FC_GUEST_PREFIX", "172.30.0"),
GuestGW: envOr("FC_GUEST_GW", "172.30.0.1"),
BootArgs: "console=ttyS0 reboot=k panic=1 pci=off i8042.noaux quiet loglevel=0",
GuestPrefix: envOr("FC_GUEST_PREFIX", "172.30.0"),
GuestGW: envOr("FC_GUEST_GW", "172.30.0.1"),
AutoNetConfig: envOr("FC_AUTO_NET_CONFIG", "") == "1",
BootArgs: "console=ttyS0 reboot=k panic=1 pci=off i8042.noaux quiet loglevel=0",
}
c.Kernel = envOr("FC_KERNEL", c.BaseDir+"/vmlinux")
c.KernelURL = envOr("FC_KERNEL_URL",

View File

@@ -88,6 +88,7 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
MacAddress: mac,
HostDevName: tapName,
},
AllowMMDS: true,
},
}
}
@@ -127,6 +128,17 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
}
elapsed := time.Since(start)
// Inject per-clone IP config via MMDS so the fc-net-init guest daemon
// can configure eth0 without any manual steps inside the VM.
if cfg.AutoNetConfig && cfg.Bridge != "none" {
guestIP := fmt.Sprintf("%s.%d/24", cfg.GuestPrefix, 10+id)
if err := configureMmds(ctx, sockPath, guestIP, cfg.GuestGW, "1.1.1.1"); err != nil {
logger.Warnf("MMDS config failed (guest network will be unconfigured): %v", err)
} else {
logger.Infof("MMDS: assigned %s gw %s to clone %d", guestIP, cfg.GuestGW, id)
}
}
// Release our copy of the slave — firecracker holds its own fd now.
// Closing here ensures we get EOF on ptm when firecracker exits.
pts.Close()

View File

@@ -1,7 +1,13 @@
package orchestrator
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
)
@@ -66,6 +72,54 @@ func destroyTap(name string) {
_ = run("ip", "link", "del", name)
}
// configureMmds writes per-clone IP config to the Firecracker MMDS so that
// the fc-net-init daemon running inside the guest can read and apply it.
// It makes two API calls to the Firecracker Unix socket:
//
// 1. PUT /mmds/config — associates MMDS with the guest's first NIC ("1")
// 2. PUT /mmds — stores ip/gw/dns values the guest daemon will read
func configureMmds(ctx context.Context, sockPath, ip, gw, dns string) error {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", sockPath)
},
},
}
doJSON := func(method, path string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal %s: %w", path, err)
}
req, err := http.NewRequestWithContext(ctx, method,
"http://localhost"+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("build request %s: %w", path, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s %s failed (%d): %s", method, path, resp.StatusCode, b)
}
return nil
}
// Store the network config the guest daemon will poll for.
// PUT /mmds/config (interface association) was already handled by the SDK
// via AllowMMDS: true on the NetworkInterface before the VM started.
return doJSON(http.MethodPut, "/mmds", map[string]string{
"ip": ip,
"gw": gw,
"dns": dns,
})
}
// run executes a command, returning an error if it fails.
func run(name string, args ...string) error {
return exec.Command(name, args...).Run()

View File

@@ -115,12 +115,36 @@ func (o *Orchestrator) buildRootfs() error {
return err
}
// write fc-net-init daemon: polls MMDS for IP config and applies it.
// Always embedded — harmless if MMDS is never populated (sleeps 1 s/loop).
// Captured in the golden snapshot so it runs on every clone resume too.
netInitScript := `#!/bin/sh
# Poll Firecracker MMDS for network config, apply it, then exit.
# Runs in background; loops until MMDS responds (survives snapshot resume).
while true; do
ip=$(wget -q -T1 -O- http://169.254.169.254/ip 2>/dev/null)
[ -n "$ip" ] || { sleep 1; continue; }
gw=$(wget -q -T1 -O- http://169.254.169.254/gw 2>/dev/null)
dns=$(wget -q -T1 -O- http://169.254.169.254/dns 2>/dev/null)
ip addr add "$ip" dev eth0 2>/dev/null
ip route add default via "$gw" dev eth0 2>/dev/null
printf "nameserver %s\n" "$dns" > /etc/resolv.conf
break
done
`
os.MkdirAll(filepath.Join(mnt, "sbin"), 0o755)
if err := os.WriteFile(filepath.Join(mnt, "sbin", "fc-net-init"), []byte(netInitScript), 0o755); err != nil {
return err
}
// write init script
initScript := `#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs devtmpfs /dev
ip link set eth0 up 2>/dev/null
ip route add 169.254.169.254 dev eth0 2>/dev/null
/sbin/fc-net-init &
`
initPath := filepath.Join(mnt, "etc", "init.d", "rcS")
os.MkdirAll(filepath.Dir(initPath), 0o755)
@@ -175,6 +199,7 @@ func (o *Orchestrator) Golden() error {
MacAddress: "AA:FC:00:00:00:01",
HostDevName: tap,
},
AllowMMDS: true,
},
}
}
@@ -294,7 +319,7 @@ func (o *Orchestrator) Spawn(count int) error {
for i := 0; i < count; i++ {
id := o.nextCloneID()
if err := o.spawnOne(id); err != nil {
if err := o.spawnOne(id, o.cfg.AutoNetConfig); err != nil {
o.log.Errorf("clone %d failed: %v", id, err)
continue
}
@@ -308,7 +333,10 @@ func (o *Orchestrator) Spawn(count int) error {
// SpawnSingle spawns exactly one new clone and returns its ID.
// It is safe to call from multiple goroutines (nextCloneID is serialised by the
// filesystem scan, and each clone gets its own directory/tap).
func (o *Orchestrator) SpawnSingle() (int, error) {
// SpawnSingle spawns one clone. net controls whether the guest receives
// automatic IP configuration via MMDS (overrides FC_AUTO_NET_CONFIG for this
// clone). Pass cfg.AutoNetConfig to preserve the global default.
func (o *Orchestrator) SpawnSingle(net bool) (int, error) {
goldenDir := o.goldenDir()
for _, f := range []string{"vmstate", "mem"} {
if _, err := os.Stat(filepath.Join(goldenDir, f)); err != nil {
@@ -323,7 +351,7 @@ func (o *Orchestrator) SpawnSingle() (int, error) {
}
}
id := o.nextCloneID()
if err := o.spawnOne(id); err != nil {
if err := o.spawnOne(id, net); err != nil {
return 0, err
}
return id, nil
@@ -349,7 +377,7 @@ func (o *Orchestrator) KillClone(id int) error {
return nil
}
func (o *Orchestrator) spawnOne(id int) error {
func (o *Orchestrator) spawnOne(id int, net bool) error {
goldenDir := o.goldenDir()
cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id))
os.MkdirAll(cloneDir, 0o755)
@@ -399,6 +427,18 @@ func (o *Orchestrator) spawnOne(id int) error {
proxyCmd.Stdin = nil
proxyCmd.Stdout = nil
proxyCmd.Stderr = nil
// Build proxy env: inherit parent env, then force FC_AUTO_NET_CONFIG to
// match the per-clone net flag so the proxy picks it up via DefaultConfig().
proxyEnv := make([]string, 0, len(os.Environ())+1)
for _, kv := range os.Environ() {
if !strings.HasPrefix(kv, "FC_AUTO_NET_CONFIG=") {
proxyEnv = append(proxyEnv, kv)
}
}
if net {
proxyEnv = append(proxyEnv, "FC_AUTO_NET_CONFIG=1")
}
proxyCmd.Env = proxyEnv
if err := proxyCmd.Start(); err != nil {
return fmt.Errorf("start console proxy: %w", err)

View File

@@ -53,7 +53,19 @@ func Serve(orch *Orchestrator, addr string) error {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ids) //nolint:errcheck
case http.MethodPost:
id, err := orch.SpawnSingle()
// Optional JSON body: {"net": bool}
// Defaults to the server's FC_AUTO_NET_CONFIG setting.
var req struct {
Net *bool `json:"net"`
}
if r.ContentLength > 0 {
json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck
}
net := orch.cfg.AutoNetConfig
if req.Net != nil {
net = *req.Net
}
id, err := orch.SpawnSingle(net)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@@ -200,7 +200,11 @@
spawnBtn.disabled = true;
spawnBtn.textContent = 'Spawning…';
clearError();
fetch('/clones', { method: 'POST' })
fetch('/clones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ net: true }),
})
.then(r => {
if (!r.ok) return r.text().then(t => { throw new Error(t); });
return r.json();