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}