Canvas: Add VM/PROXY dev modes support

- Update ServiceSchema to discriminate between VM and PROXY dev modes
- Add DevDisabled, DevVM, DevProxy TypeScript types
- Update ServiceData type in graph.ts for new dev structure
- Update generateDodoConfig to handle both VM and PROXY modes
- Update configToGraph to properly convert dev configurations
- Maintain backward compatibility with existing dev configurations
- Update UI and introduce two new DevVM and DevProxy components
- Fetch user machine list from headscale API

Change-Id: I8f9df4ab9bd34c049fffadb748115335e8260a54
diff --git a/apps/canvas/back/.env.dev b/apps/canvas/back/.env.dev
index 2db1e80..e672e62 100644
--- a/apps/canvas/back/.env.dev
+++ b/apps/canvas/back/.env.dev
@@ -1,2 +1,4 @@
 DATABASE_URL=file:/home/gio/dodo.db
-INTERNAL_API_ADDR=http://10.42.1.95:8081
+VPN_API_ADDR=http://headscale-api.hgrz-app-headscale.svc.cluster.local
+INTERNAL_API_ADDR=http://10.42.0.193:8081
+
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 97df61f..f00549f 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -26,6 +26,7 @@
 import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
 import LogStore from "./log.js";
 import { GraphOrConfigSchema, GraphSchema, GraphConfigOrDraft, AgentAccess } from "config/dist/graph.js";
+import { MachineManager } from "./machine_manager.js";
 
 async function generateKey(root: string): Promise<[string, string]> {
 	const privKeyPath = path.join(root, "key");
@@ -41,6 +42,7 @@
 const db = new PrismaClient();
 const logStore = new LogStore(db);
 const appManager = new AppManager();
+const machineManager = new MachineManager(env.VPN_API_ADDR!);
 
 const projectMonitors = new Map<number, ProjectMonitor>();
 
@@ -791,6 +793,20 @@
 	};
 };
 
+const handleMachines: express.Handler = async (req, resp) => {
+	try {
+		const machines = await machineManager.getUserMachines(resp.locals.username);
+		resp.status(200);
+		resp.write(JSON.stringify(machines));
+	} catch (error) {
+		console.error("Error getting machines:", error);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Internal server error" }));
+	} finally {
+		resp.end();
+	}
+};
+
 const handleEnv: express.Handler = async (req, resp) => {
 	const projectId = Number(req.params["projectId"]);
 	try {
@@ -1268,6 +1284,9 @@
 	// Public webhook route - no auth needed
 	app.post("/api/webhook/github/push", handleGithubPushWebhook);
 
+	// Public machines route - no auth needed
+	app.get("/api/machines", auth, handleMachines);
+
 	// Authenticated project routes
 	const projectRouter = express.Router();
 	projectRouter.use(auth);
diff --git a/apps/canvas/back/src/machine_manager.ts b/apps/canvas/back/src/machine_manager.ts
new file mode 100644
index 0000000..ab4d684
--- /dev/null
+++ b/apps/canvas/back/src/machine_manager.ts
@@ -0,0 +1,10 @@
+import { Machines, MachinesSchema } from "config";
+
+export class MachineManager {
+	constructor(private readonly apiAddr: string) {}
+
+	async getUserMachines(username: string): Promise<Machines> {
+		const response = await fetch(`${this.apiAddr}/user/${username}/node`);
+		return MachinesSchema.parse(await response.json());
+	}
+}
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index f5957d4..7fd7066 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -112,25 +112,35 @@
 						? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
 						: [],
 					dev: n.data.dev?.enabled
-						? {
-								enabled: true,
-								mode: "VM",
-								username: env.user.username,
-								codeServer:
-									n.data.dev.expose != null
-										? {
-												network: networkMap.get(n.data.dev.expose.network)!,
-												subdomain: n.data.dev.expose.subdomain,
-											}
-										: undefined,
-								ssh:
-									n.data.dev.expose != null
-										? {
-												network: networkMap.get(n.data.dev.expose.network)!,
-												subdomain: n.data.dev.expose.subdomain,
-											}
-										: undefined,
-							}
+						? n.data.dev.mode === "VM"
+							? {
+									enabled: true,
+									mode: "VM",
+									username: env.user.username,
+									codeServer:
+										n.data.dev.expose != null
+											? {
+													network: networkMap.get(n.data.dev.expose.network)!,
+													subdomain: n.data.dev.expose.subdomain,
+												}
+											: undefined,
+									ssh:
+										n.data.dev.expose != null
+											? {
+													network: networkMap.get(n.data.dev.expose.network)!,
+													subdomain: n.data.dev.expose.subdomain,
+												}
+											: undefined,
+								}
+							: {
+									enabled: true,
+									mode: "PROXY",
+									address: n.data.dev.address,
+									vpn: {
+										enabled: true,
+										username: env.user.username,
+									},
+								}
 						: {
 								enabled: false,
 							},
@@ -311,7 +321,28 @@
 							? { name: "gemini", apiKey: s.model.geminiApiKey }
 							: { name: "claude", apiKey: s.model.anthropicApiKey },
 				}),
-				// TODO(gio): dev
+				dev: s.dev?.enabled
+					? s.dev.mode === "VM"
+						? {
+								enabled: true,
+								mode: "VM",
+								expose: s.dev.ssh
+									? {
+											network: s.dev.ssh.network,
+											subdomain: s.dev.ssh.subdomain,
+										}
+									: undefined,
+								codeServerNodeId: uuidv4(), // TODO: proper node tracking
+								sshNodeId: uuidv4(), // TODO: proper node tracking
+							}
+						: {
+								enabled: true,
+								mode: "PROXY",
+								address: s.dev.address,
+							}
+					: {
+							enabled: false,
+						},
 				isChoosingPortToConnect: false,
 			},
 			// TODO(gio): generate position
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index a4634cb..fc3258e 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -210,9 +210,15 @@
 		  }
 		| {
 				enabled: true;
+				mode: "VM";
 				expose?: Domain;
 				codeServerNodeId: string;
 				sshNodeId: string;
+		  }
+		| {
+				enabled: true;
+				mode: "PROXY";
+				address: string;
 		  };
 	model?: {
 		name: "gemini" | "claude";
@@ -496,17 +502,23 @@
 				preBuildCommands: z.string().optional(),
 				isChoosingPortToConnect: z.boolean().optional(),
 				dev: z
-					.discriminatedUnion("enabled", [
+					.union([
 						z.object({
 							enabled: z.literal(false),
 							expose: DomainSchema.optional(),
 						}),
 						z.object({
 							enabled: z.literal(true),
+							mode: z.literal("VM"),
 							expose: DomainSchema.optional(),
 							codeServerNodeId: z.string(),
 							sshNodeId: z.string(),
 						}),
+						z.object({
+							enabled: z.literal(true),
+							mode: z.literal("PROXY"),
+							address: z.string(),
+						}),
 					])
 					.optional(),
 				model: z
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index f6cc9a5..df1be47 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -7,6 +7,10 @@
 	ConfigWithInputSchema,
 	Domain,
 	Ingress,
+	Machine,
+	MachineSchema,
+	Machines,
+	MachinesSchema,
 	MongoDB,
 	PortDomain,
 	PortValue,
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index a49c2d5..4146f75 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -98,15 +98,26 @@
 	expose: z.array(PortDomainSchema).optional(),
 	volume: z.array(z.string()).optional(),
 	preBuildCommands: z.array(z.object({ bin: z.string() })).optional(),
-	dev: z.discriminatedUnion("enabled", [
+	dev: z.union([
 		z.object({ enabled: z.literal(false) }),
 		z.object({
 			enabled: z.literal(true),
-			mode: z.string(),
+			mode: z.literal("VM"),
 			username: z.string().optional(),
 			ssh: DomainSchema.optional(),
 			codeServer: DomainSchema.optional(),
 		}),
+		z.object({
+			enabled: z.literal(true),
+			mode: z.literal("PROXY"),
+			address: z.string(),
+			vpn: z
+				.discriminatedUnion("enabled", [
+					z.object({ enabled: z.literal(false) }),
+					z.object({ enabled: z.literal(true), username: z.string() }),
+				])
+				.optional(),
+		}),
 	]),
 	model: ModelSchema.optional(),
 });
@@ -139,6 +150,12 @@
 	expose: z.array(DomainSchema).optional(),
 });
 
+export const MachineSchema = z.object({
+	name: z.string(),
+});
+
+export const MachinesSchema = z.array(MachineSchema);
+
 export const ConfigSchema = z.object({
 	service: z.array(ServiceSchema).optional(),
 	agent: z.array(AgentSchema).optional(),
@@ -180,5 +197,23 @@
 export type MongoDB = z.infer<typeof MongoDBSchema>;
 export type Config = z.infer<typeof ConfigSchema>;
 export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;
+export type Machine = z.infer<typeof MachineSchema>;
+export type Machines = z.infer<typeof MachinesSchema>;
+
+// Dev configuration types
+export type DevDisabled = { enabled: false };
+export type DevVM = {
+	enabled: true;
+	mode: "VM";
+	username?: string;
+	ssh?: Domain;
+	codeServer?: Domain;
+};
+export type DevProxy = {
+	enabled: true;
+	mode: "PROXY";
+	address: string;
+};
+export type Dev = DevDisabled | DevVM | DevProxy;
 
 export const isAgent = (s: Service): s is Agent => s.type === "sketch:latest";
diff --git a/apps/canvas/front/package-lock.json b/apps/canvas/front/package-lock.json
index 247a513..2f44d68 100644
--- a/apps/canvas/front/package-lock.json
+++ b/apps/canvas/front/package-lock.json
@@ -18,6 +18,7 @@
 				"@radix-ui/react-icons": "^1.3.1",
 				"@radix-ui/react-label": "^2.1.7",
 				"@radix-ui/react-popover": "^1.1.2",
+				"@radix-ui/react-radio-group": "^1.3.7",
 				"@radix-ui/react-scroll-area": "^1.2.0",
 				"@radix-ui/react-select": "^2.1.2",
 				"@radix-ui/react-separator": "^1.1.0",
@@ -2762,6 +2763,311 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-radio-group": {
+			"version": "1.3.7",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+			"integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/primitive": "1.1.2",
+				"@radix-ui/react-compose-refs": "1.1.2",
+				"@radix-ui/react-context": "1.1.2",
+				"@radix-ui/react-direction": "1.1.1",
+				"@radix-ui/react-presence": "1.1.4",
+				"@radix-ui/react-primitive": "2.1.3",
+				"@radix-ui/react-roving-focus": "1.1.10",
+				"@radix-ui/react-use-controllable-state": "1.2.2",
+				"@radix-ui/react-use-previous": "1.1.1",
+				"@radix-ui/react-use-size": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+			"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+			"license": "MIT"
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
+			"version": "1.1.7",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+			"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.2",
+				"@radix-ui/react-context": "1.1.2",
+				"@radix-ui/react-primitive": "2.1.3",
+				"@radix-ui/react-slot": "1.2.3"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+			"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+			"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-direction": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+			"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-id": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+			"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-use-layout-effect": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
+			"version": "1.1.4",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
+			"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.2",
+				"@radix-ui/react-use-layout-effect": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
+			"version": "2.1.3",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+			"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-slot": "1.2.3"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
+			"version": "1.1.10",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
+			"integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/primitive": "1.1.2",
+				"@radix-ui/react-collection": "1.1.7",
+				"@radix-ui/react-compose-refs": "1.1.2",
+				"@radix-ui/react-context": "1.1.2",
+				"@radix-ui/react-direction": "1.1.1",
+				"@radix-ui/react-id": "1.1.1",
+				"@radix-ui/react-primitive": "2.1.3",
+				"@radix-ui/react-use-callback-ref": "1.1.1",
+				"@radix-ui/react-use-controllable-state": "1.2.2"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
+			"version": "1.2.3",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+			"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.2"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+			"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-controllable-state": {
+			"version": "1.2.2",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+			"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-use-effect-event": "0.0.2",
+				"@radix-ui/react-use-layout-effect": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-layout-effect": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+			"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-previous": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+			"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+			"license": "MIT",
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-size": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+			"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@radix-ui/react-use-layout-effect": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-roving-focus": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
diff --git a/apps/canvas/front/package.json b/apps/canvas/front/package.json
index ea53fe0..43aa538 100644
--- a/apps/canvas/front/package.json
+++ b/apps/canvas/front/package.json
@@ -26,6 +26,7 @@
 		"@radix-ui/react-icons": "^1.3.1",
 		"@radix-ui/react-label": "^2.1.7",
 		"@radix-ui/react-popover": "^1.1.2",
+		"@radix-ui/react-radio-group": "^1.3.7",
 		"@radix-ui/react-scroll-area": "^1.2.0",
 		"@radix-ui/react-select": "^2.1.2",
 		"@radix-ui/react-separator": "^1.1.0",
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 15e2fa3..317b7e0 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,8 +1,19 @@
 import { v4 as uuidv4 } from "uuid";
 import { NodeRect } from "./node-rect";
 import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
-import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
-import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
+import {
+	ServiceNode,
+	ServiceTypes,
+	GatewayHttpsNode,
+	GatewayTCPNode,
+	BoundEnvVar,
+	AppNode,
+	GithubNode,
+	Machines,
+	Machine,
+	MachinesSchema,
+} from "config";
+import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
 import { z } from "zod";
 import { useForm, EventType, DeepPartial } from "react-hook-form";
 import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,6 +35,9 @@
 import { Gateway } from "@/Gateways";
 import { Port } from "config";
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
+import { useToast } from "@/hooks/use-toast";
+import { LoaderCircle } from "lucide-react";
 
 const sourceSchema = z.object({
 	id: z.string().min(1, "required"),
@@ -33,6 +47,7 @@
 
 const devSchema = z.object({
 	enabled: z.boolean(),
+	mode: z.enum(["VM", "PROXY"]).optional(),
 });
 
 const exposeSchema = z.object({
@@ -45,6 +60,10 @@
 	apiKey: z.string().optional(),
 });
 
+const proxySchema = z.object({
+	address: z.string().min(1, "required"),
+});
+
 const portExposeSchema = z
 	.object({
 		type: z.enum(["https", "tcp"]),
@@ -322,10 +341,16 @@
 
 export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
 	const { data } = node;
+	const defaultTab = useMemo(() => {
+		if (data.dev?.enabled) {
+			return "dev";
+		}
+		return "runtime";
+	}, [data]);
 	return (
 		<>
 			{showName ? <Name node={node} disabled={disabled} /> : null}
-			<Tabs defaultValue="runtime">
+			<Tabs defaultValue={defaultTab}>
 				<TabsList className="w-full flex flex-row justify-between">
 					<TabsTrigger value="runtime">
 						{isOverview ? (
@@ -1157,140 +1182,163 @@
 	);
 }
 
-function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+function usePrevious<T>(value: T) {
+	const ref = useRef<T>();
+	useEffect(() => {
+		ref.current = value;
+	}, [value]);
+	return ref.current;
+}
+
+function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
 	const { id, data } = node;
+	const { dev } = data;
+	const prevDev = usePrevious(dev);
 	const env = useEnv();
 	const store = useStateStore();
-	const devForm = useForm<z.infer<typeof devSchema>>({
-		resolver: zodResolver(devSchema),
-		mode: "onChange",
-		defaultValues: {
-			enabled: data.dev ? data.dev.enabled : false,
-		},
-	});
 	useEffect(() => {
-		const sub = devForm.watch((value, { name }) => {
-			if (name === "enabled") {
-				if (value.enabled) {
-					const csGateway: Omit<GatewayHttpsNode, "position"> = {
-						id: uuidv4(),
-						type: "gateway-https",
-						data: {
-							readonly: true,
-							https: {
-								serviceId: id,
-								portId: `${id}-code-server`,
-							},
-							network: data.dev?.expose?.network,
-							subdomain: data.dev?.expose?.subdomain,
-							label: "",
-							envVars: [],
-							ports: [],
-						},
-					};
-					const sshGateway: Omit<GatewayTCPNode, "position"> = {
-						id: uuidv4(),
-						type: "gateway-tcp",
-						data: {
-							readonly: true,
-							exposed: [
-								{
-									serviceId: id,
-									portId: `${id}-ssh`,
-								},
-							],
-							network: data.dev?.expose?.network,
-							subdomain: data.dev?.expose?.subdomain,
-							label: "",
-							envVars: [],
-							ports: [],
-						},
-					};
-					store.addNode(csGateway);
-					store.addNode(sshGateway);
+		console.log("DDDEV", prevDev, dev);
+		if (!dev && !prevDev) {
+			return;
+		}
+		if (
+			dev &&
+			prevDev &&
+			dev.enabled === prevDev.enabled &&
+			"mode" in dev &&
+			"mode" in prevDev &&
+			dev.mode === prevDev.mode
+		) {
+			return;
+		}
+		if (!dev?.enabled || dev.mode !== "VM") {
+			if (prevDev?.enabled && prevDev.mode === "VM") {
+				store.setNodes(
+					store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
+				);
+				store.setEdges(
+					store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
+				);
+				if (dev?.enabled) {
 					store.updateNodeData<"app">(id, {
 						dev: {
-							enabled: true,
-							expose: data.dev?.expose,
-							codeServerNodeId: csGateway.id,
-							sshNodeId: sshGateway.id,
+							enabled: dev.enabled,
+							mode: dev.mode,
 						},
-						ports: (data.ports || []).concat(
-							{
-								id: `${id}-code-server`,
-								name: "code-server",
-								value: 9090,
-							},
-							{
-								id: `${id}-ssh`,
-								name: "ssh",
-								value: 22,
-							},
-						),
+						ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
 					});
-					let edges = store.edges.concat([
-						{
-							id: uuidv4(),
-							source: id,
-							sourceHandle: "ports",
-							target: csGateway.id,
-							targetHandle: "https",
-						},
-						{
-							id: uuidv4(),
-							source: id,
-							sourceHandle: "ports",
-							target: sshGateway.id,
-							targetHandle: "tcp",
-						},
-					]);
-					if (data.dev?.expose?.network !== undefined) {
-						edges = edges.concat([
-							{
-								id: uuidv4(),
-								source: csGateway.id,
-								sourceHandle: "subdomain",
-								target: data.dev.expose.network,
-								targetHandle: "subdomain",
-							},
-							{
-								id: uuidv4(),
-								source: sshGateway.id,
-								sourceHandle: "subdomain",
-								target: data.dev.expose.network,
-								targetHandle: "subdomain",
-							},
-						]);
-					}
-					store.setEdges(edges);
 				} else {
-					const { dev } = data;
-					if (dev?.enabled) {
-						store.setNodes(
-							store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
-						);
-						store.setEdges(
-							store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
-						);
-					}
 					store.updateNodeData<"app">(id, {
 						dev: {
 							enabled: false,
-							expose: dev?.expose,
 						},
 						ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
 					});
 				}
 			}
-		});
-		return () => sub.unsubscribe();
-	}, [id, data, devForm, store]);
+		} else {
+			if (!prevDev?.enabled || prevDev.mode !== "VM") {
+				const csGateway: Omit<GatewayHttpsNode, "position"> = {
+					id: uuidv4(),
+					type: "gateway-https",
+					data: {
+						readonly: true,
+						https: {
+							serviceId: id,
+							portId: `${id}-code-server`,
+						},
+						network: dev?.expose?.network,
+						subdomain: dev?.expose?.subdomain,
+						label: "",
+						envVars: [],
+						ports: [],
+					},
+				};
+				const sshGateway: Omit<GatewayTCPNode, "position"> = {
+					id: uuidv4(),
+					type: "gateway-tcp",
+					data: {
+						readonly: true,
+						exposed: [
+							{
+								serviceId: id,
+								portId: `${id}-ssh`,
+							},
+						],
+						network: dev?.expose?.network,
+						subdomain: dev?.expose?.subdomain,
+						label: "",
+						envVars: [],
+						ports: [],
+					},
+				};
+				store.addNode(csGateway);
+				store.addNode(sshGateway);
+				store.updateNodeData<"app">(id, {
+					dev: {
+						enabled: true,
+						mode: "VM",
+						expose: dev?.expose,
+						codeServerNodeId: csGateway.id,
+						sshNodeId: sshGateway.id,
+					},
+					ports: (data.ports || []).concat(
+						{
+							id: `${id}-code-server`,
+							name: "code-server",
+							value: 9090,
+						},
+						{
+							id: `${id}-ssh`,
+							name: "ssh",
+							value: 22,
+						},
+					),
+				});
+				let edges = store.edges.concat([
+					{
+						id: uuidv4(),
+						source: id,
+						sourceHandle: "ports",
+						target: csGateway.id,
+						targetHandle: "https",
+					},
+					{
+						id: uuidv4(),
+						source: id,
+						sourceHandle: "ports",
+						target: sshGateway.id,
+						targetHandle: "tcp",
+					},
+				]);
+				if (dev?.expose?.network !== undefined) {
+					edges = edges.concat([
+						{
+							id: uuidv4(),
+							source: csGateway.id,
+							sourceHandle: "subdomain",
+							target: dev.expose.network,
+							targetHandle: "subdomain",
+						},
+						{
+							id: uuidv4(),
+							source: sshGateway.id,
+							sourceHandle: "subdomain",
+							target: dev.expose.network,
+							targetHandle: "subdomain",
+						},
+					]);
+				}
+				store.setEdges(edges);
+			}
+		}
+	}, [id, data, dev, prevDev, store]);
 	const exposeForm = useForm<z.infer<typeof exposeSchema>>({
 		resolver: zodResolver(exposeSchema),
 		mode: "onChange",
 		defaultValues: {
-			network: data.dev?.expose?.network,
-			subdomain: data.dev?.expose?.subdomain,
+			network: dev && "expose" in dev ? dev.expose?.network : undefined,
+			subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
 		},
 	});
 	useEffect(() => {
@@ -1300,7 +1348,7 @@
 				{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
 			) => {
 				const { dev } = data;
-				if (!dev?.enabled) {
+				if (!dev?.enabled || dev.mode !== "VM") {
 					return;
 				}
 				if (name === "network") {
@@ -1365,31 +1413,12 @@
 			},
 		);
 		return () => sub.unsubscribe();
-	}, [id, data, exposeForm, store]);
+	}, [id, data, dev, prevDev, exposeForm, store]);
+	if (!dev?.enabled || dev.mode !== "VM") {
+		return null;
+	}
 	return (
-		<>
-			<Form {...devForm}>
-				<form className="space-y-2">
-					<FormField
-						control={devForm.control}
-						name="enabled"
-						render={({ field }) => (
-							<FormItem>
-								<div className="flex flex-row gap-1 items-center">
-									<Switch
-										id="devEnabled"
-										onCheckedChange={field.onChange}
-										checked={field.value}
-										disabled={disabled}
-									/>
-									<Label htmlFor="devEnabled">Dev VM</Label>
-								</div>
-								<FormMessage />
-							</FormItem>
-						)}
-					/>
-				</form>
-			</Form>
+		<div>
 			{data.dev && data.dev.enabled && (
 				<Form {...exposeForm}>
 					<form className="space-y-2">
@@ -1438,6 +1467,250 @@
 					</form>
 				</Form>
 			)}
+		</div>
+	);
+}
+
+function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+	const { id, data } = node;
+	const store = useStateStore();
+	const { toast } = useToast();
+	const [machines, setMachines] = useState<Machines>([]);
+	const [loading, setLoading] = useState(false);
+	const [error, setError] = useState<string | null>(null);
+
+	const fetchMachines = useCallback(async () => {
+		setLoading(true);
+		setError(null);
+		try {
+			const response = await fetch("/api/machines", {
+				method: "GET",
+				headers: {
+					"Content-Type": "application/json",
+				},
+			});
+
+			if (!response.ok) {
+				throw new Error(`Failed to fetch machines: ${response.statusText}`);
+			}
+
+			const machinesData = MachinesSchema.safeParse(await response.json());
+			if (machinesData.success) {
+				setMachines(machinesData.data);
+			} else {
+				throw new Error("Invalid machines data");
+			}
+		} catch (err) {
+			const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
+			setError(errorMessage);
+			toast({
+				variant: "destructive",
+				title: "Error",
+				description: errorMessage,
+			});
+		} finally {
+			setLoading(false);
+		}
+	}, [toast]);
+
+	useEffect(() => {
+		if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
+			fetchMachines();
+		}
+	}, [data.dev, fetchMachines]);
+
+	const proxyForm = useForm<z.infer<typeof proxySchema>>({
+		resolver: zodResolver(proxySchema),
+		mode: "onChange",
+		defaultValues: {
+			address: data.dev && "address" in data.dev ? data.dev.address : undefined,
+		},
+	});
+
+	useEffect(() => {
+		const sub = proxyForm.watch((value, { name }) => {
+			if (name === "address" && value.address) {
+				store.updateNodeData<"app">(id, {
+					dev: {
+						enabled: true,
+						mode: "PROXY",
+						address: value.address,
+					},
+				});
+			}
+		});
+		return () => sub.unsubscribe();
+	}, [id, proxyForm, store]);
+
+	if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
+		return null;
+	}
+	return (
+		<div className="space-y-2">
+			<Form {...proxyForm}>
+				<form className="space-y-2">
+					<FormField
+						control={proxyForm.control}
+						name="address"
+						render={({ field }) => (
+							<FormItem>
+								<Select
+									onValueChange={field.onChange}
+									value={field.value || ""}
+									disabled={disabled || loading}
+								>
+									<FormControl>
+										<SelectTrigger>
+											{loading ? (
+												<div className="flex items-center gap-2">
+													<LoaderCircle className="h-4 w-4 animate-spin" />
+													<span>Loading machines...</span>
+												</div>
+											) : (
+												<SelectValue placeholder="Select a machine" />
+											)}
+										</SelectTrigger>
+									</FormControl>
+									<SelectContent>
+										{loading ? (
+											<div className="flex items-center justify-center p-4">
+												<LoaderCircle className="h-4 w-4 animate-spin" />
+												<span className="ml-2">Loading...</span>
+											</div>
+										) : error ? (
+											<div className="flex flex-col items-center justify-center p-4 text-destructive">
+												<span className="text-sm">Failed to load machines</span>
+												<Button
+													variant="ghost"
+													size="sm"
+													className="mt-2"
+													onClick={fetchMachines}
+												>
+													Retry
+												</Button>
+											</div>
+										) : machines.length === 0 ? (
+											<div className="flex items-center justify-center p-4 text-muted-foreground">
+												<span className="text-sm">No machines available</span>
+											</div>
+										) : (
+											machines.map((machine: Machine) => (
+												<SelectItem key={machine.name} value={machine.name}>
+													{machine.name}
+												</SelectItem>
+											))
+										)}
+									</SelectContent>
+								</Select>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+				</form>
+			</Form>
+		</div>
+	);
+}
+
+function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+	const { id, data } = node;
+	const store = useStateStore();
+	const devForm = useForm<z.infer<typeof devSchema>>({
+		resolver: zodResolver(devSchema),
+		mode: "onChange",
+		defaultValues: {
+			enabled: data.dev ? data.dev.enabled : false,
+			mode: data.dev?.enabled ? data.dev.mode : undefined,
+		},
+	});
+	useEffect(() => {
+		const sub = devForm.watch((value, { name }) => {
+			console.log("DDDEVV", name, value, data.dev);
+			if (name === "enabled") {
+				if (value.enabled) {
+					if (data.dev?.enabled && data.dev.mode === "VM") {
+						return;
+					}
+					store.updateNodeData<"app">(id, {
+						dev: {
+							enabled: true,
+							mode: "VM",
+						},
+					});
+					devForm.setValue("mode", "VM");
+				} else {
+					store.updateNodeData<"app">(id, {
+						dev: {
+							enabled: false,
+						},
+					});
+				}
+			} else if (name === "mode") {
+				if (data.dev?.enabled && data.dev.mode === value.mode) {
+					return;
+				}
+				store.updateNodeData<"app">(id, {
+					dev: {
+						enabled: true,
+						mode: value.mode,
+					},
+				});
+			}
+		});
+		return () => sub.unsubscribe();
+	}, [id, data, devForm, store]);
+	return (
+		<>
+			<Form {...devForm}>
+				<form className="space-y-2">
+					<FormField
+						control={devForm.control}
+						name="enabled"
+						render={({ field }) => (
+							<FormItem>
+								<div className="flex flex-row gap-1 items-center">
+									<Switch
+										id="devEnabled"
+										onCheckedChange={field.onChange}
+										checked={field.value}
+										disabled={disabled}
+									/>
+									<Label htmlFor="devEnabled">Development Mode</Label>
+								</div>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+					{data.dev?.enabled && (
+						<FormField
+							control={devForm.control}
+							name="mode"
+							render={({ field }) => (
+								<FormItem>
+									<div className="flex flex-row gap-1 items-center">
+										<RadioGroup
+											onValueChange={field.onChange}
+											value={field.value}
+											disabled={disabled}
+										>
+											<div className="flex items-center space-x-2">
+												<RadioGroupItem value="VM" id="vm" />
+												<Label htmlFor="vm">Create a VM</Label>
+											</div>
+											<div className="flex items-center space-x-2">
+												<RadioGroupItem value="PROXY" id="proxy" />
+												<Label htmlFor="proxy">Proxy to existing machine</Label>
+											</div>
+										</RadioGroup>
+									</div>
+								</FormItem>
+							)}
+						/>
+					)}
+				</form>
+			</Form>
+			<DevVM node={node} disabled={disabled} />
+			<DevProxy node={node} disabled={disabled} />
 		</>
 	);
 }
diff --git a/apps/canvas/front/src/components/ui/radio-group.tsx b/apps/canvas/front/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..6e25b18
--- /dev/null
+++ b/apps/canvas/front/src/components/ui/radio-group.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { cn } from "@/lib/utils";
+import { DotFilledIcon } from "@radix-ui/react-icons";
+
+const RadioGroup = React.forwardRef<
+	React.ElementRef<typeof RadioGroupPrimitive.Root>,
+	React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+	return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+	React.ElementRef<typeof RadioGroupPrimitive.Item>,
+	React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+	return (
+		<RadioGroupPrimitive.Item
+			ref={ref}
+			className={cn(
+				"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+				className,
+			)}
+			{...props}
+		>
+			<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+				<DotFilledIcon className="h-3.5 w-3.5 fill-primary" />
+			</RadioGroupPrimitive.Indicator>
+		</RadioGroupPrimitive.Item>
+	);
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };