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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user