auto-update-daily-20260202
  1# MicroVM host module for running ephemeral Claude Code agents
  2#
  3# Based on: https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/
  4#
  5# Usage:
  6#   services.microvm-host = {
  7#     enable = true;
  8#     vms.claude-home = {
  9#       ip = "192.168.83.2";
 10#       workspace = "/home/vincent/src/home";
 11#     };
 12#   };
 13{
 14  config,
 15  lib,
 16  pkgs,
 17  inputs,
 18  globals,
 19  ...
 20}:
 21with lib;
 22let
 23  cfg = config.services.microvm-host;
 24
 25  # VM configuration type
 26  vmType = types.submodule (
 27    { ... }:
 28    {
 29      options = {
 30        ip = mkOption {
 31          type = types.str;
 32          description = "Static IP address for the VM";
 33          example = "192.168.83.2";
 34        };
 35        workspace = mkOption {
 36          type = types.str;
 37          description = "Host path to mount as /workspace in the VM";
 38          example = "/home/vincent/src/home";
 39        };
 40        extraPackages = mkOption {
 41          type = types.listOf types.package;
 42          default = [ ];
 43          description = "Additional packages to install in the VM";
 44        };
 45        vcpu = mkOption {
 46          type = types.int;
 47          default = 8;
 48          description = "Number of virtual CPUs";
 49        };
 50        mem = mkOption {
 51          type = types.int;
 52          default = 4096;
 53          description = "Memory in MB";
 54        };
 55        autostart = mkOption {
 56          type = types.bool;
 57          default = false;
 58          description = "Whether to start the VM automatically";
 59        };
 60      };
 61    }
 62  );
 63
 64  # Generate MAC address from VM name (deterministic)
 65  vmMac =
 66    name:
 67    let
 68      hash = builtins.hashString "sha256" name;
 69    in
 70    "02:00:00:01:${builtins.substring 0 2 hash}:${builtins.substring 2 2 hash}";
 71
 72  # Generate short tap interface name (Linux limit is 15 chars)
 73  # Use "mv" prefix + first 11 chars of hash
 74  tapName =
 75    name:
 76    let
 77      hash = builtins.hashString "sha256" name;
 78    in
 79    "mv${builtins.substring 0 11 hash}";
 80
 81  # Build VM NixOS configuration
 82  mkVmConfig = name: vmCfg: {
 83    imports = [
 84      inputs.microvm.nixosModules.microvm
 85      inputs.home-manager.nixosModules.home-manager
 86    ];
 87
 88    # Basic system config
 89    system.stateVersion = "24.11";
 90    networking.hostName = name;
 91
 92    # MicroVM configuration
 93    microvm = {
 94      hypervisor = "cloud-hypervisor";
 95      vcpu = vmCfg.vcpu;
 96      mem = vmCfg.mem;
 97
 98      # Don't build a store image, use host's store via virtiofs
 99      storeOnDisk = false;
100
101      # Shared directories via virtiofs
102      shares = [
103        {
104          proto = "virtiofs";
105          tag = "nix-store";
106          source = "/nix/store";
107          mountPoint = "/nix/store";
108        }
109        {
110          proto = "virtiofs";
111          tag = "workspace";
112          source = vmCfg.workspace;
113          mountPoint = "/workspace";
114        }
115        {
116          proto = "virtiofs";
117          tag = "claude-config";
118          source = "/home/vincent/.claude";
119          mountPoint = "/home/vincent/.claude";
120        }
121        {
122          proto = "virtiofs";
123          tag = "ssh-host-keys";
124          source = "${cfg.stateDir}/${name}/ssh-host-keys";
125          mountPoint = "/etc/ssh/host-keys";
126        }
127      ];
128
129      # Network interface (name must be <=15 chars for Linux)
130      interfaces = [
131        {
132          type = "tap";
133          id = tapName name;
134          mac = vmMac name;
135        }
136      ];
137    };
138
139    # Static IP networking (using systemd-networkd, microvm default)
140    networking = {
141      useDHCP = false;
142      defaultGateway = {
143        address = "${cfg.subnet}.1";
144        interface = "eth0";
145      };
146      nameservers = [
147        "1.1.1.1"
148        "8.8.8.8"
149      ];
150      firewall.enable = false; # Trust NAT isolation
151    };
152
153    # Configure network via systemd-networkd
154    systemd.network = {
155      enable = true;
156      networks."10-eth0" = {
157        matchConfig.Name = "eth0";
158        networkConfig = {
159          Address = "${vmCfg.ip}/24";
160          Gateway = "${cfg.subnet}.1";
161          DNS = [
162            "1.1.1.1"
163            "8.8.8.8"
164          ];
165        };
166      };
167    };
168
169    # SSH server with persistent host keys
170    services.openssh = {
171      enable = true;
172      settings = {
173        PasswordAuthentication = false;
174        PermitRootLogin = "no";
175      };
176      hostKeys = [
177        {
178          path = "/etc/ssh/host-keys/ssh_host_ed25519_key";
179          type = "ed25519";
180        }
181      ];
182    };
183
184    # User configuration
185    users.users.vincent = {
186      isNormalUser = true;
187      home = "/home/vincent";
188      shell = pkgs.zsh;
189      extraGroups = [ "wheel" ];
190      openssh.authorizedKeys.keys = globals.ssh.vincent;
191    };
192    security.sudo.wheelNeedsPassword = false;
193
194    # Enable zsh system-wide
195    programs.zsh.enable = true;
196
197    # Home-manager for shell setup
198    home-manager = {
199      useGlobalPkgs = true;
200      useUserPackages = true;
201      users.vincent = import ./vm-home.nix {
202        inherit pkgs;
203        extraPackages = vmCfg.extraPackages;
204      };
205    };
206
207    # Base packages
208    environment.systemPackages = with pkgs; [
209      git
210      vim
211      curl
212      wget
213      htop
214    ];
215  };
216in
217{
218  # Import the microvm host module at module level (not in config)
219  imports = [ inputs.microvm.nixosModules.host ];
220
221  options.services.microvm-host = {
222    enable = mkEnableOption "MicroVM host for Claude Code agents";
223
224    bridge = mkOption {
225      type = types.str;
226      default = "microbr";
227      description = "Name of the bridge interface for VM networking";
228    };
229
230    subnet = mkOption {
231      type = types.str;
232      default = "192.168.83";
233      description = "First three octets of the VM subnet (e.g., 192.168.83)";
234    };
235
236    stateDir = mkOption {
237      type = types.str;
238      default = "/home/vincent/microvm";
239      description = "Directory for persistent VM state (SSH keys, etc.)";
240    };
241
242    externalInterface = mkOption {
243      type = types.str;
244      default = "enp0s31f6";
245      description = "External network interface for NAT";
246    };
247
248    vms = mkOption {
249      type = types.attrsOf vmType;
250      default = { };
251      description = "VM definitions";
252    };
253  };
254
255  config = mkIf cfg.enable {
256    # Enable microvm host
257    microvm.host.enable = true;
258
259    # Keep declarative config for firewall/NAT
260    networking.bridges.${cfg.bridge}.interfaces = [ ];
261    networking.interfaces.${cfg.bridge} = {
262      ipv4.addresses = [
263        {
264          address = "${cfg.subnet}.1";
265          prefixLength = 24;
266        }
267      ];
268    };
269
270    # NAT for VM internet access
271    networking.nat = {
272      enable = true;
273      internalInterfaces = [ cfg.bridge ];
274      externalInterface = cfg.externalInterface;
275    };
276
277    # Trust the microvm bridge
278    networking.firewall.trustedInterfaces = [ cfg.bridge ];
279
280    # IP forwarding
281    boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
282
283    # Define VMs
284    microvm.vms = mapAttrs (name: vmCfg: {
285      inherit (vmCfg) autostart;
286      restartIfChanged = true;
287
288      # Pass through special args
289      specialArgs = {
290        inherit inputs globals;
291      };
292
293      config = mkVmConfig name vmCfg;
294    }) cfg.vms;
295
296    # Auto-generate SSH host keys for each VM
297    system.activationScripts.microvm-ssh-keys = {
298      text = concatStringsSep "\n" (
299        mapAttrsToList (name: _vmCfg: ''
300          keyDir="${cfg.stateDir}/${name}/ssh-host-keys"
301          if [ ! -f "$keyDir/ssh_host_ed25519_key" ]; then
302            echo "Generating SSH host keys for microvm ${name}..."
303            mkdir -p "$keyDir"
304            ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -N "" -f "$keyDir/ssh_host_ed25519_key"
305            chown -R vincent:users "$keyDir"
306            chmod 600 "$keyDir/ssh_host_ed25519_key"
307            chmod 644 "$keyDir/ssh_host_ed25519_key.pub"
308          fi
309        '') cfg.vms
310      );
311      deps = [ ];
312    };
313
314    # Create state directories
315    systemd.tmpfiles.rules = mapAttrsToList (
316      name: _vmCfg: "d ${cfg.stateDir}/${name} 0755 vincent users -"
317    ) cfg.vms;
318
319    # Create bridge and attach tap interfaces
320    systemd.services = {
321      # Create bridge for VM networking
322      # (networking.bridges doesn't work reliably with all network setups)
323      "microvm-bridge-setup" = {
324        description = "Create MicroVM bridge";
325        wantedBy = [ "network.target" ];
326        before = [ "network.target" ];
327        after = [ "network-pre.target" ];
328        serviceConfig = {
329          Type = "oneshot";
330          RemainAfterExit = true;
331          ExecStart = pkgs.writeShellScript "create-microvm-bridge" ''
332            set -e
333            if ! ${pkgs.iproute2}/bin/ip link show ${cfg.bridge} &>/dev/null; then
334              ${pkgs.iproute2}/bin/ip link add name ${cfg.bridge} type bridge
335            fi
336            ${pkgs.iproute2}/bin/ip addr replace ${cfg.subnet}.1/24 dev ${cfg.bridge}
337            ${pkgs.iproute2}/bin/ip link set ${cfg.bridge} up
338          '';
339          ExecStop = "${pkgs.iproute2}/bin/ip link delete ${cfg.bridge}";
340        };
341      };
342    }
343    // mapAttrs' (
344      name: _vmCfg:
345      let
346        tap = tapName name;
347      in
348      nameValuePair "microvm-bridge-${name}" {
349        description = "Attach MicroVM '${name}' tap to bridge";
350        after = [
351          "microvm-tap-interfaces@${name}.service"
352          "microvm-bridge-setup.service"
353        ];
354        requires = [
355          "microvm-tap-interfaces@${name}.service"
356          "microvm-bridge-setup.service"
357        ];
358        before = [ "microvm@${name}.service" ];
359        partOf = [ "microvm@${name}.service" ];
360        serviceConfig = {
361          Type = "oneshot";
362          RemainAfterExit = true;
363          ExecStart = "${pkgs.iproute2}/bin/ip link set ${tap} master ${cfg.bridge}";
364          ExecStop = "${pkgs.iproute2}/bin/ip link set ${tap} nomaster";
365        };
366      }
367    ) cfg.vms;
368  };
369}