sorcery-go/pkg/runtime/firecracker.go

499 lines
18 KiB
Go
Executable File

// Firecracker runtime adapter for Sorcery-Go Sanctums.
//
// Firecracker is an AWS open-source microVM that provides strong VM-level
// isolation with sub-millisecond boot times and a minimal attack surface.
// Each Sanctum runs in its own Firecracker microVM with dedicated kernel
// and rootfs — providing hardware-level isolation that containers cannot match.
//
// This adapter communicates with the Firecracker VMM process via its
// Unix socket API (the "Machine Controller"). Each microVM is managed as:
// 1. Allocate a VMM Unix socket
// 2. Configure boot source (kernel + initrd or rootfs)
// 3. Configure root drive
// 4. Configure network interface (tap device)
// 5. Start the instance
//
// Note: Firecracker does NOT use cgroups for process management, so the
// eBPF cgroup filter attachment is not applicable. The Tomb Guard LSM
// hook still protects the host's Tomb from any process, including the
// Firecracker VMM process itself. Network isolation is handled by the
// Firecracker jailer's chroot + seccomp + network namespace.
package runtime
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// DefaultFirecrackerKernelPath is the fallback kernel image path when
// no per-VM KernelPath is specified via CreateOpts.
const DefaultFirecrackerKernelPath = "/var/lib/sorcery-go/vmlinux"
// DefaultFirecrackerBootArgs are the base kernel command-line arguments.
// IP configuration is appended when the VM has a network interface.
const DefaultFirecrackerBootArgs = "console=ttyS0 reboot=k panic=1 pci=off"
// FirecrackerRuntime manages Firecracker microVMs.
type FirecrackerRuntime struct {
mu sync.Mutex
binPath string // path to firecracker binary
jailerPath string // path to jailer binary
socketDir string // directory for VMM sockets
vms map[string]*fcVM // active VMs by sanctum ID
}
// fcVM tracks the state of a running Firecracker microVM.
type fcVM struct {
ID string
SocketPath string
PID int
RootFSPath string
KernelPath string
DrivePath string
TapDevice string
IP string
Started time.Time
}
// Firecracker API request/response types.
type fcBootSource struct {
KernelImage string `json:"kernel_image_path"`
BootArgs string `json:"boot_args,omitempty"`
InitrdPath string `json:"initrd_path,omitempty"`
}
type fcDrive struct {
DriveID string `json:"drive_id"`
PathOnHost string `json:"path_on_host"`
IsRootDevice bool `json:"is_root_device"`
IsReadOnly bool `json:"is_read_only"`
Partuuid string `json:"partuuid,omitempty"`
}
type fcInterface struct {
IfaceID string `json:"iface_id"`
GuestMac string `json:"guest_mac"`
HostDevName string `json:"host_dev_name"`
}
type fcInstanceAction struct {
ActionType string `json:"action_type"`
}
// NewFirecrackerRuntime creates a Firecracker runtime adapter.
func NewFirecrackerRuntime() *FirecrackerRuntime {
return &FirecrackerRuntime{
binPath: "firecracker",
socketDir: "/run/sorcery-go/firecracker",
vms: make(map[string]*fcVM),
}
}
func (r *FirecrackerRuntime) Type() Type { return RuntimeFirecracker }
func (r *FirecrackerRuntime) Name() string { return "Firecracker microVMs" }
// Probe checks that firecracker is available.
func (r *FirecrackerRuntime) Probe() error {
if _, err := exec.LookPath("firecracker"); err != nil {
return fmt.Errorf("firecracker: binary not found in PATH: %w", err)
}
// Jailer is optional but recommended.
if _, err := exec.LookPath("jailer"); err == nil {
r.jailerPath = "jailer"
}
return nil
}
// Create provisions a new Firecracker microVM.
// This sets up the socket and configuration but does not start the VM.
func (r *FirecrackerRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := os.MkdirAll(r.socketDir, 0755); err != nil {
return "", fmt.Errorf("firecracker: mkdir %s: %w", r.socketDir, err)
}
socketPath := filepath.Join(r.socketDir, opts.Name+".sock")
vm := &fcVM{
ID: opts.Name,
SocketPath: socketPath,
KernelPath: opts.KernelPath,
DrivePath: opts.RootDrivePath,
RootFSPath: opts.RootFS,
Started: time.Now(),
}
// Generate a MAC address for the VM.
if opts.NetworkConfig != nil && opts.NetworkConfig.Type != "none" {
vm.TapDevice = "tap-" + opts.Name
vm.IP = opts.NetworkConfig.IP
}
r.vms[opts.Name] = vm
return opts.Name, nil
}
// Start boots a Firecracker microVM.
func (r *FirecrackerRuntime) Start(ctx context.Context, sanctumID string) error {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
// Clean up old socket if present.
if err := os.Remove(vm.SocketPath); err != nil {
log.Printf("firecracker: remove old socket %s: %v\n", vm.SocketPath, err)
}
// Start the Firecracker VMM process.
args := []string{"--api-sock", vm.SocketPath}
cmd := exec.CommandContext(ctx, r.binPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("firecracker: start VMM %s: %w", sanctumID, err)
}
vm.PID = cmd.Process.Pid
// Wait for the socket to appear (channel-based, no busy-wait).
if err := waitForFile(vm.SocketPath, 5*time.Second); err != nil {
return fmt.Errorf("firecracker: VMM socket not ready: %w", err)
}
client := newFCClient(vm.SocketPath)
// Set boot source.
if vm.KernelPath == "" {
vm.KernelPath = DefaultFirecrackerKernelPath
}
bootArgs := DefaultFirecrackerBootArgs
if vm.IP != "" {
bootArgs += " ip=" + vm.IP
}
if err := client.putBootSource(fcBootSource{
KernelImage: vm.KernelPath,
BootArgs: bootArgs,
}); err != nil {
return fmt.Errorf("firecracker: configure boot source: %w", err)
}
// Set root drive.
if vm.DrivePath != "" {
if err := client.putDrive(fcDrive{
DriveID: "rootfs",
PathOnHost: vm.DrivePath,
IsRootDevice: true,
IsReadOnly: false,
}); err != nil {
return fmt.Errorf("firecracker: configure root drive: %w", err)
}
}
// Configure network interface.
if vm.TapDevice != "" {
if err := client.putInterface(fcInterface{
IfaceID: "eth0",
GuestMac: generateMAC(sanctumID),
HostDevName: vm.TapDevice,
}); err != nil {
return fmt.Errorf("firecracker: configure network: %w", err)
}
// Create the tap device on the host.
if out, err := exec.Command("ip", "tuntap", "add", "dev", vm.TapDevice, "mode", "tap").CombinedOutput(); err != nil {
return fmt.Errorf("firecracker: create tap %s: %w (%s)", vm.TapDevice, err, string(out))
}
if out, err := exec.Command("ip", "link", "set", vm.TapDevice, "up").CombinedOutput(); err != nil {
return fmt.Errorf("firecracker: bring up tap %s: %w (%s)", vm.TapDevice, err, string(out))
}
}
// Start the instance (the actual boot).
if err := client.putInstanceAction(fcInstanceAction{ActionType: "InstanceStart"}); err != nil {
return fmt.Errorf("firecracker: start instance: %w", err)
}
return nil
}
// Stop sends an ACPI shutdown to the Firecracker VM.
func (r *FirecrackerRuntime) Stop(ctx context.Context, sanctumID string) error {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
client := newFCClient(vm.SocketPath)
if err := client.putInstanceAction(fcInstanceAction{ActionType: "SendCtrlAltDel"}); err != nil {
// VM may already be stopped — log but continue cleanup.
}
// Wait for the VMM process to exit with a context-aware wait.
if vm.PID > 0 {
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := waitForPID(waitCtx, vm.PID); err != nil {
// Force-kill if graceful shutdown timed out.
_ = syscall.Kill(vm.PID, syscall.SIGKILL)
}
}
// Clean up tap device.
if vm.TapDevice != "" {
exec.Command("ip", "link", "set", vm.TapDevice, "down").Run()
exec.Command("ip", "tuntap", "del", "dev", vm.TapDevice, "mode", "tap").Run()
}
return nil
}
// Freeze is a no-op for Firecracker VMs. MicroVMs can be paused
// at the VMM level but this requires snapshot support which is
// a more advanced feature. For now, we report that freeze is
// not supported for Firecracker and suggest using Stop instead.
func (r *FirecrackerRuntime) Freeze(ctx context.Context, sanctumID string) error {
return fmt.Errorf("firecracker: freeze not supported for microVMs — use Stop instead")
}
// Thaw is a no-op for Firecracker VMs.
func (r *FirecrackerRuntime) Thaw(ctx context.Context, sanctumID string) error {
return fmt.Errorf("firecracker: thaw not supported for microVMs — use Start instead")
}
// Destroy stops and removes a Firecracker microVM.
func (r *FirecrackerRuntime) Destroy(ctx context.Context, sanctumID string) error {
r.Stop(ctx, sanctumID)
r.mu.Lock()
defer r.mu.Unlock()
vm, ok := r.vms[sanctumID]
if !ok {
return nil // already destroyed
}
os.Remove(vm.SocketPath)
delete(r.vms, sanctumID)
return nil
}
// Exec runs a command inside a Firecracker VM via serial console.
// Note: This is a simplified implementation that uses the serial console.
// A production implementation would use an SSH connection or a virtio-serial
// channel with a guest agent.
func (r *FirecrackerRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
// Firecracker doesn't have a built-in exec mechanism.
// In production, this would use:
// 1. mmds (MicroVM Metadata Service) for command dispatch
// 2. SSH into the VM's IP address
// 3. virtio-serial guest agent
// For now, return an error indicating this is not available.
return nil, fmt.Errorf("firecracker: exec not supported — use SSH to %s (IP: %s)",
sanctumID, r.getVMIP(sanctumID))
}
// Status returns the state of a Firecracker microVM.
func (r *FirecrackerRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
status := StatusStopped
if vm.PID > 0 {
// Check if the VMM process is still running.
if _, err := os.FindProcess(vm.PID); err == nil {
// Send signal 0 to check if process exists.
if exec.Command("kill", "-0", strconv.Itoa(vm.PID)).Run() == nil {
status = StatusRunning
}
}
}
return &SanctumInfo{
ID: vm.ID,
Name: vm.ID,
Runtime: RuntimeFirecracker,
Status: status,
Arch: "x86_64", // Firecracker is x86_64 only (aarch64 experimental)
IP: vm.IP,
PID: uint32(vm.PID),
RootFS: vm.DrivePath,
Created: vm.Started,
Cgroup: "", // Firecracker doesn't use cgroups for the guest
Metadata: map[string]string{
"socket": vm.SocketPath,
"tap_device": vm.TapDevice,
"kernel": vm.KernelPath,
},
}, nil
}
// List returns all Firecracker microVMs managed by this runtime.
func (r *FirecrackerRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
r.mu.Lock()
defer r.mu.Unlock()
var infos []*SanctumInfo
for _, vm := range r.vms {
info, _ := r.Status(ctx, vm.ID)
if info != nil {
infos = append(infos, info)
}
}
return infos, nil
}
// CgroupPath returns "" for Firecracker since it doesn't use cgroups.
func (r *FirecrackerRuntime) CgroupPath(sanctumID string) string {
// Firecracker VMs are not managed via cgroups on the host.
// The VMM process itself runs in the host's cgroup, but the
// guest processes are isolated in their own kernel.
// The eBPF LSM hook still protects the host Tomb regardless.
return ""
}
func (r *FirecrackerRuntime) getVMIP(id string) string {
r.mu.Lock()
defer r.mu.Unlock()
if vm, ok := r.vms[id]; ok {
return vm.IP
}
return ""
}
// waitForPID polls until the given PID exits or the context expires.
func waitForPID(ctx context.Context, pid int) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := syscall.Kill(pid, 0); err != nil {
return nil // process gone
}
}
}
}
// --- Firecracker API client ---
type fcAPIClient struct {
socketPath string
httpClient *http.Client
baseURL string
}
func newFCClient(socketPath string) *fcAPIClient {
dialer := func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", socketPath)
}
transport := &http.Transport{
Dial: dialer,
}
return &fcAPIClient{
socketPath: socketPath,
httpClient: &http.Client{Transport: transport, Timeout: 10 * time.Second},
baseURL: "http://localhost",
}
}
func (c *fcAPIClient) putBootSource(bs fcBootSource) error {
return c.put("/boot-source", bs)
}
func (c *fcAPIClient) putDrive(d fcDrive) error {
return c.put("/drives/"+d.DriveID, d)
}
func (c *fcAPIClient) putInterface(i fcInterface) error {
return c.put("/network-interfaces/"+i.IfaceID, i)
}
func (c *fcAPIClient) putInstanceAction(a fcInstanceAction) error {
return c.put("/actions", a)
}
func (c *fcAPIClient) put(path string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", c.baseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("firecracker API %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firecracker API %s: %s (body: %s)", path, resp.Status, string(body))
}
return nil
}
// --- helpers ---
// waitForFile waits for a file to appear within a timeout using
// a channel-based ticker (SEI CERT: no busy-wait / sleep-in-loop).
func waitForFile(path string, timeout time.Duration) error {
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-ticker.C:
if _, err := os.Stat(path); err == nil {
return nil
}
case <-timer.C:
return fmt.Errorf("timeout waiting for %s", path)
}
}
}
// generateMAC produces a deterministic, locally-administered MAC (second bit
// of the first octet set) from the sanctum ID. Uses FNV-1a for better
// distribution than the former integer-overflow hash (SEI CERT EXP00-J).
func generateMAC(id string) string {
h := uint32(2166136261)
for _, c := range id {
h ^= uint32(c)
h *= 16777619
}
return fmt.Sprintf("02:FC:%02X:%02X:%02X:%02X",
(h>>24)&0xFF, (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF)
}