Files
firecracker-orchestrator/orchestrator/network.go
Honza Novak 5e23e0ab4e 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>
2026-04-13 11:58:59 +00:00

127 lines
3.7 KiB
Go

package orchestrator
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
)
// setupBridge creates the host bridge + enables NAT if it doesn't exist.
func (o *Orchestrator) setupBridge() error {
if o.cfg.Bridge == "none" {
return nil
}
// check if bridge already exists
if err := run("ip", "link", "show", o.cfg.Bridge); err == nil {
return nil // already up
}
if err := run("ip", "link", "add", o.cfg.Bridge, "type", "bridge"); err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := run("ip", "addr", "add", o.cfg.BridgeCIDR, "dev", o.cfg.Bridge); err != nil {
return fmt.Errorf("add bridge addr: %w", err)
}
if err := run("ip", "link", "set", o.cfg.Bridge, "up"); err != nil {
return fmt.Errorf("bring bridge up: %w", err)
}
// find default route interface for NAT
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err == nil {
fields := strings.Fields(string(out))
for i, f := range fields {
if f == "dev" && i+1 < len(fields) {
iface := fields[i+1]
_ = run("sysctl", "-qw", "net.ipv4.ip_forward=1")
// idempotent: ignore error if rule exists
_ = run("iptables", "-t", "nat", "-A", "POSTROUTING",
"-o", iface, "-j", "MASQUERADE")
break
}
}
}
o.log.Infof("bridge %s up on %s", o.cfg.Bridge, o.cfg.BridgeCIDR)
return nil
}
// createTap creates a tap device and attaches it to the bridge.
func (o *Orchestrator) createTap(name string) error {
if err := run("ip", "tuntap", "add", "dev", name, "mode", "tap"); err != nil {
return fmt.Errorf("create tap %s: %w", name, err)
}
if err := run("ip", "link", "set", name, "up"); err != nil {
return fmt.Errorf("bring tap %s up: %w", name, err)
}
if o.cfg.Bridge != "none" {
if err := run("ip", "link", "set", name, "master", o.cfg.Bridge); err != nil {
return fmt.Errorf("attach tap %s to bridge: %w", name, err)
}
}
return nil
}
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()
}