499 lines
18 KiB
Go
Executable File
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)
|
|
} |