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

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