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>
127 lines
3.7 KiB
Go
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()
|
|
}
|