VirtualMachine: Implement virtual machines using KubeVirt

Auto adds new VM into given user's Tailscale network

Change-Id: I16847a0b9eacc17b0e794d3b4913eb1d80a93f0a
diff --git a/core/installer/Makefile b/core/installer/Makefile
index 296e780..abdd656 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -32,7 +32,7 @@
 	./pcloud --kubeconfig=../../priv/kubeconfig create-env --admin-priv-key=/Users/lekva/.ssh/id_rsa --name=lekva --ip=192.168.0.211 --admin-username=gio
 
 appmanager:
-	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/config --port=9090 # --app-repo-addr=http://localhost:8080
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/config --port=9090 --headscale-api-addr=http://10.43.193.154 # --app-repo-addr=http://localhost:8080
 
 dodo-app:
 	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://10.43.196.174/test
diff --git a/core/installer/app.go b/core/installer/app.go
index 0c9cf6b..cee8a5f 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -196,7 +196,14 @@
 
 type EnvApp interface {
 	App
-	Render(release Release, env EnvConfig, networks []Network, values map[string]any, charts map[string]helmv2.HelmChartTemplateSpec) (EnvAppRendered, error)
+	Render(
+		release Release,
+		env EnvConfig,
+		networks []Network,
+		values map[string]any,
+		charts map[string]helmv2.HelmChartTemplateSpec,
+		vpnKeyGen VPNAuthKeyGenerator,
+	) (EnvAppRendered, error)
 }
 
 type cueApp struct {
@@ -452,8 +459,9 @@
 	networks []Network,
 	values map[string]any,
 	charts map[string]helmv2.HelmChartTemplateSpec,
+	vpnKeyGen VPNAuthKeyGenerator,
 ) (EnvAppRendered, error) {
-	derived, err := deriveValues(values, a.Schema(), networks)
+	derived, err := deriveValues(values, values, a.Schema(), networks, vpnKeyGen)
 	if err != nil {
 		return EnvAppRendered{}, err
 	}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index c27b641..3157a45 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -35,6 +35,7 @@
 	nsc        NamespaceCreator
 	jc         JobCreator
 	hf         HelmFetcher
+	vpnKeyGen  VPNAuthKeyGenerator
 	appDirRoot string
 }
 
@@ -43,6 +44,7 @@
 	nsc NamespaceCreator,
 	jc JobCreator,
 	hf HelmFetcher,
+	vpnKeyGen VPNAuthKeyGenerator,
 	appDirRoot string,
 ) (*AppManager, error) {
 	return &AppManager{
@@ -51,6 +53,7 @@
 		nsc,
 		jc,
 		hf,
+		vpnKeyGen,
 		appDirRoot,
 	}, nil
 }
@@ -457,7 +460,7 @@
 		RepoAddr:      m.repoIO.FullAddress(),
 		AppDir:        appDir,
 	}
-	rendered, err := app.Render(release, env, networks, values, nil)
+	rendered, err := app.Render(release, env, networks, values, nil, m.vpnKeyGen)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -489,7 +492,7 @@
 	if o.FetchContainerImages {
 		release.ImageRegistry = imageRegistry
 	}
-	rendered, err = app.Render(release, env, networks, values, localCharts)
+	rendered, err = app.Render(release, env, networks, values, localCharts, m.vpnKeyGen)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -582,7 +585,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	rendered, err := app.Render(config.Release, env, networks, values, renderedCfg.LocalCharts)
+	rendered, err := app.Render(config.Release, env, networks, values, renderedCfg.LocalCharts, m.vpnKeyGen)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -989,6 +992,8 @@
 		return []string{}
 	case KindPort:
 		return []string{""}
+	case KindVPNAuthKey:
+		return []string{}
 	default:
 		panic("MUST NOT REACH!")
 	}
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 381a84f..4ce7c9d 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -20,6 +20,7 @@
 
 var storeEnvAppConfigs = []string{
 	"values-tmpl/dodo-app.cue",
+	"values-tmpl/virtual-machine.cue",
 	"values-tmpl/coder.cue",
 	"values-tmpl/url-shortener.cue",
 	"values-tmpl/matrix.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index bf0ceac..f0f0e64 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -82,7 +82,7 @@
 				"groups":  "a,b",
 			},
 		}
-		rendered, err := a.Render(release, env, networks, values, nil)
+		rendered, err := a.Render(release, env, networks, values, nil, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -112,7 +112,7 @@
 				"enabled": false,
 			},
 		}
-		rendered, err := a.Render(release, env, networks, values, nil)
+		rendered, err := a.Render(release, env, networks, values, nil, nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -138,7 +138,7 @@
 		"network":    "Public",
 		"authGroups": "foo,bar",
 	}
-	rendered, err := a.Render(release, env, networks, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -168,7 +168,7 @@
 		},
 		"sshPort": 22,
 	}
-	rendered, err := a.Render(release, env, networks, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -193,7 +193,7 @@
 		"subdomain": "jenkins",
 		"network":   "Private",
 	}
-	rendered, err := a.Render(release, env, networks, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -252,7 +252,7 @@
 		},
 		"sshPrivateKey": "private",
 	}
-	rendered, err := a.Render(release, env, networks, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -285,7 +285,7 @@
 			"groups":  "a,b",
 		},
 	}
-	rendered, err := app.Render(release, env, networks, values, nil)
+	rendered, err := app.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -360,7 +360,7 @@
 		"managerAddr":   "",
 		"appId":         "",
 		"sshPrivateKey": "",
-	}, nil)
+	}, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -383,7 +383,7 @@
 		"repoHost":         "",
 		"gitRepoPublicKey": "",
 	}
-	rendered, err := a.Render(release, env, networks, values, nil)
+	rendered, err := a.Render(release, env, networks, values, nil, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index c03c83e..a6224a0 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -16,10 +16,11 @@
 )
 
 var appManagerFlags struct {
-	sshKey      string
-	repoAddr    string
-	port        int
-	appRepoAddr string
+	sshKey           string
+	repoAddr         string
+	port             int
+	appRepoAddr      string
+	headscaleAPIAddr string
 }
 
 func appManagerCmd() *cobra.Command {
@@ -51,6 +52,12 @@
 		"",
 		"",
 	)
+	cmd.Flags().StringVar(
+		&appManagerFlags.headscaleAPIAddr,
+		"headscale-api-addr",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -85,7 +92,8 @@
 		return err
 	}
 	hf := installer.NewGitHelmFetcher()
-	m, err := installer.NewAppManager(repoIO, nsc, jc, hf, "/apps")
+	vpnKeyGen := installer.NewHeadscaleAPIClient(appManagerFlags.headscaleAPIAddr)
+	m, err := installer.NewAppManager(repoIO, nsc, jc, hf, vpnKeyGen, "/apps")
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index f61ee78..2fe1697 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -32,6 +32,7 @@
 	db                string
 	networks          []string
 	fetchUsersAddr    string
+	headscaleAPIAddr  string
 }
 
 func dodoAppCmd() *cobra.Command {
@@ -123,6 +124,12 @@
 		[]string{},
 		"",
 	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.headscaleAPIAddr,
+		"headscale-api-addr",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -193,6 +200,7 @@
 			// &tasks.KustomizationReconciler{},
 		},
 	}
+	vpnKeyGen := installer.NewHeadscaleAPIClient(dodoAppFlags.headscaleAPIAddr)
 	s, err := welcome.NewDodoAppServer(
 		st,
 		nf,
@@ -208,6 +216,7 @@
 		dodoAppFlags.envAppManagerAddr,
 		nsc,
 		jc,
+		vpnKeyGen,
 		env,
 		dodoAppFlags.external,
 		dodoAppFlags.fetchUsersAddr,
diff --git a/core/installer/cmd/launcher.go b/core/installer/cmd/launcher.go
index 0af1d1e..e671fe6 100644
--- a/core/installer/cmd/launcher.go
+++ b/core/installer/cmd/launcher.go
@@ -74,7 +74,7 @@
 	if err != nil {
 		return err
 	}
-	appManager, err := installer.NewAppManager(repoIO, nil, nil, nil, "/apps")
+	appManager, err := installer.NewAppManager(repoIO, nil, nil, nil, nil, "/apps")
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/cmd/rewrite.go b/core/installer/cmd/rewrite.go
index 3ebb390..0562c0e 100644
--- a/core/installer/cmd/rewrite.go
+++ b/core/installer/cmd/rewrite.go
@@ -62,7 +62,7 @@
 	log.Println("Creating repository")
 	r := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	hf := installer.NewGitHelmFetcher()
-	mgr, err := installer.NewAppManager(repoIO, nil, nil, hf, "/apps")
+	mgr, err := installer.NewAppManager(repoIO, nil, nil, hf, nil, "/apps")
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 7a5e5f3..aabda98 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -3,6 +3,7 @@
 import (
 	"fmt"
 	"html/template"
+	"strings"
 )
 
 type Release struct {
@@ -55,7 +56,21 @@
 	return ret
 }
 
-func deriveValues(values any, schema Schema, networks []Network) (map[string]any, error) {
+func getField(v any, f string) any {
+	for _, i := range strings.Split(f, ".") {
+		vm := v.(map[string]any)
+		v = vm[i]
+	}
+	return v
+}
+
+func deriveValues(
+	root any,
+	values any,
+	schema Schema,
+	networks []Network,
+	vpnKeyGen VPNAuthKeyGenerator,
+) (map[string]any, error) {
 	ret := make(map[string]any)
 	for _, f := range schema.Fields() {
 		k := f.Name
@@ -74,6 +89,16 @@
 					"private": string(key.RawPrivateKey()),
 				}
 			}
+			if def.Kind() == KindVPNAuthKey {
+				usernameField := def.Meta()["usernameField"]
+				// TODO(gio): Improve getField
+				username := getField(root, usernameField)
+				authKey, err := vpnKeyGen.Generate(username.(string))
+				if err != nil {
+					return nil, err
+				}
+				ret[k] = authKey
+			}
 			continue
 		}
 		switch def.Kind() {
@@ -85,6 +110,8 @@
 			ret[k] = v
 		case KindPort:
 			ret[k] = v
+		case KindVPNAuthKey:
+			ret[k] = v
 		case KindArrayString:
 			a, ok := v.([]string)
 			if !ok {
@@ -120,19 +147,19 @@
 			}
 			ret[k] = picked
 		case KindAuth:
-			r, err := deriveValues(v, AuthSchema, networks)
+			r, err := deriveValues(v, v, AuthSchema, networks, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
 			ret[k] = r
 		case KindSSHKey:
-			r, err := deriveValues(v, SSHKeySchema, networks)
+			r, err := deriveValues(v, v, SSHKeySchema, networks, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
 			ret[k] = r
 		case KindStruct:
-			r, err := deriveValues(v, def, networks)
+			r, err := deriveValues(v, v, def, networks, vpnKeyGen)
 			if err != nil {
 				return nil, err
 			}
@@ -163,6 +190,8 @@
 			ret[k] = v
 		case KindPort:
 			ret[k] = v
+		case KindVPNAuthKey:
+			ret[k] = v
 		case KindArrayString:
 			a, ok := v.([]string)
 			if !ok {
diff --git a/core/installer/derived_test.go b/core/installer/derived_test.go
new file mode 100644
index 0000000..7a34154
--- /dev/null
+++ b/core/installer/derived_test.go
@@ -0,0 +1,34 @@
+package installer
+
+import (
+	"testing"
+)
+
+type testKeyGen struct{}
+
+func (g testKeyGen) Generate(username string) (string, error) {
+	return username, nil
+}
+
+func TestDeriveVPNAuthKey(t *testing.T) {
+	schema := structSchema{
+		"input",
+		[]Field{
+			Field{"username", basicSchema{"username", KindString, false, nil}},
+			Field{"authKey", basicSchema{"authKey", KindVPNAuthKey, false, map[string]string{
+				"usernameField": "username",
+			}}},
+		},
+		false,
+	}
+	input := map[string]any{
+		"username": "foo",
+	}
+	v, err := deriveValues(input, input, schema, nil, testKeyGen{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if key, ok := v["authKey"].(string); !ok || key != "foo" {
+		t.Fatal(v)
+	}
+}
diff --git a/core/installer/schema.go b/core/installer/schema.go
index 8b49905..b02f3b7 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -22,6 +22,7 @@
 	KindNumber            = 4
 	KindArrayString       = 8
 	KindPort              = 9
+	KindVPNAuthKey        = 11
 )
 
 type Field struct {
@@ -34,13 +35,14 @@
 	Kind() Kind
 	Fields() []Field
 	Advanced() bool
+	Meta() map[string]string
 }
 
 var AuthSchema Schema = structSchema{
 	name: "Auth",
 	fields: []Field{
-		Field{"enabled", basicSchema{"Enabled", KindBoolean, false}},
-		Field{"groups", basicSchema{"Groups", KindString, false}},
+		Field{"enabled", basicSchema{"Enabled", KindBoolean, false, nil}},
+		Field{"groups", basicSchema{"Groups", KindString, false, nil}},
 	},
 	advanced: false,
 }
@@ -48,8 +50,8 @@
 var SSHKeySchema Schema = structSchema{
 	name: "SSH Key",
 	fields: []Field{
-		Field{"public", basicSchema{"Public Key", KindString, false}},
-		Field{"private", basicSchema{"Private Key", KindString, false}},
+		Field{"public", basicSchema{"Public Key", KindString, false, nil}},
+		Field{"private", basicSchema{"Private Key", KindString, false, nil}},
 	},
 	advanced: true,
 }
@@ -166,6 +168,7 @@
 	name     string
 	kind     Kind
 	advanced bool
+	meta     map[string]string
 }
 
 func (s basicSchema) Name() string {
@@ -184,6 +187,10 @@
 	return s.advanced
 }
 
+func (s basicSchema) Meta() map[string]string {
+	return s.meta
+}
+
 type structSchema struct {
 	name     string
 	fields   []Field
@@ -206,6 +213,10 @@
 	return s.advanced
 }
 
+func (s structSchema) Meta() map[string]string {
+	return map[string]string{}
+}
+
 func NewCueSchema(name string, v cue.Value) (Schema, error) {
 	nameAttr := v.Attribute("name")
 	if nameAttr.Err() == nil {
@@ -218,29 +229,36 @@
 	}
 	switch v.IncompleteKind() {
 	case cue.StringKind:
-		return basicSchema{name, KindString, false}, nil
+		if role == "vpnauthkey" {
+			meta := map[string]string{}
+			usernameAttr := v.Attribute("usernameField")
+			meta["usernameField"] = strings.ToLower(usernameAttr.Contents())
+			return basicSchema{name, KindVPNAuthKey, true, meta}, nil
+		} else {
+			return basicSchema{name, KindString, false, nil}, nil
+		}
 	case cue.BoolKind:
-		return basicSchema{name, KindBoolean, false}, nil
+		return basicSchema{name, KindBoolean, false, nil}, nil
 	case cue.NumberKind:
-		return basicSchema{name, KindNumber, false}, nil
+		return basicSchema{name, KindNumber, false, nil}, nil
 	case cue.IntKind:
 		if role == "port" {
-			return basicSchema{name, KindPort, true}, nil
+			return basicSchema{name, KindPort, true, nil}, nil
 		} else {
-			return basicSchema{name, KindInt, false}, nil
+			return basicSchema{name, KindInt, false, nil}, nil
 		}
 	case cue.ListKind:
 		if isMultiNetwork(v) {
-			return basicSchema{name, KindMultiNetwork, false}, nil
+			return basicSchema{name, KindMultiNetwork, false, nil}, nil
 		}
-		return basicSchema{name, KindArrayString, false}, nil
+		return basicSchema{name, KindArrayString, false, nil}, nil
 	case cue.StructKind:
 		if isNetwork(v) {
-			return basicSchema{name, KindNetwork, false}, nil
+			return basicSchema{name, KindNetwork, false, nil}, nil
 		} else if isAuth(v) {
-			return basicSchema{name, KindAuth, false}, nil
+			return basicSchema{name, KindAuth, false, nil}, nil
 		} else if isSSHKey(v) {
-			return basicSchema{name, KindSSHKey, true}, nil
+			return basicSchema{name, KindSSHKey, true, nil}, nil
 		}
 		s := structSchema{name, make([]Field, 0), false}
 		f, err := v.Fields(cue.Schema())
diff --git a/core/installer/schema_test.go b/core/installer/schema_test.go
index cca0bd7..61739fb 100644
--- a/core/installer/schema_test.go
+++ b/core/installer/schema_test.go
@@ -8,17 +8,17 @@
 	scm := structSchema{
 		"a",
 		[]Field{
-			Field{"x", basicSchema{"x", KindString, false}},
-			Field{"y", basicSchema{"y", KindInt, false}},
-			Field{"z", basicSchema{"z", KindPort, false}},
+			Field{"x", basicSchema{"x", KindString, false, nil}},
+			Field{"y", basicSchema{"y", KindInt, false, nil}},
+			Field{"z", basicSchema{"z", KindPort, false, nil}},
 			Field{
 				"w",
 				structSchema{
 					"w",
 					[]Field{
-						Field{"x", basicSchema{"x", KindString, false}},
-						Field{"y", basicSchema{"y", KindInt, false}},
-						Field{"z", basicSchema{"z", KindPort, false}},
+						Field{"x", basicSchema{"x", KindString, false, nil}},
+						Field{"y", basicSchema{"y", KindInt, false, nil}},
+						Field{"z", basicSchema{"z", KindPort, false, nil}},
 					},
 					false,
 				},
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 9d0c011..3682533 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -18,7 +18,7 @@
 		if err != nil {
 			return err
 		}
-		appManager, err := installer.NewAppManager(r, st.nsCreator, st.jc, st.hf, "/apps")
+		appManager, err := installer.NewAppManager(r, st.nsCreator, st.jc, st.hf, nil, "/apps")
 		if err != nil {
 			return err
 		}
diff --git a/core/installer/values-tmpl/appmanager.cue b/core/installer/values-tmpl/appmanager.cue
index 75358a3..aad8f52 100644
--- a/core/installer/values-tmpl/appmanager.cue
+++ b/core/installer/values-tmpl/appmanager.cue
@@ -68,6 +68,7 @@
 		values: {
 			repoAddr: input.repoAddr
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+			headscaleAPIAddr: "http://headscale-api.\(global.namespacePrefix)app-headscale.svc.cluster.local"
 			ingress: {
 				className: input.network.ingressClass
 				domain: _domain
diff --git a/core/installer/values-tmpl/virtual-machine.cue b/core/installer/values-tmpl/virtual-machine.cue
new file mode 100644
index 0000000..292c082
--- /dev/null
+++ b/core/installer/values-tmpl/virtual-machine.cue
@@ -0,0 +1,72 @@
+input: {
+	name: string @name(Hostname)
+	username: string @name(Username)
+	authKey: string @name(Auth Key) @role(VPNAuthKey) @usernameField(username)
+	cpuCores: int | *1 @name(CPU Cores)
+	memory: string | *"2Gi" @name(Memory)
+}
+
+name: "Virutal Machine"
+namespace: "app-vm"
+readme: "Virtual Machine"
+description: "Virtual Machine"
+icon: """
+<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 2048 2048"><path fill="currentColor" d="M1280 384H640V256h640zm0 1024H640v-128h640zm0 256H640v-128h640zM1408 0q27 0 50 10t40 27t28 41t10 50v1792H384V128q0-27 10-50t27-40t41-28t50-10zm0 128H512v1664h896z"/></svg>"""
+
+charts: {
+	virtualMachine: {
+		kind: "GitRepository"
+		address: "https://code.v1.dodo.cloud/helm-charts"
+		branch: "main"
+		path: "charts/virtual-machine"
+	}
+}
+
+helm: {
+	"virtual-machine": {
+		chart: charts.virtualMachine
+		values: {
+			name: input.name
+			cpuCores: input.cpuCores
+			memory: input.memory
+			disk: {
+				source: "https://cloud.debian.org/images/cloud/bookworm-backports/latest/debian-12-backports-generic-amd64.qcow2"
+				size: "64Gi"
+			}
+			ports: [22, 8080]
+			cloudInit: userData: _cloudInitUserData
+		}
+	}
+}
+
+_cloudInitUserData: {
+	system_info: {
+		default_user: {
+			name: input.username
+			home: "/home/\(input.username)"
+		}
+	}
+	password: "dodo" // TODO(gio): remove if possible
+	chpasswd: {
+		expire: false
+	}
+	hostname: input.name
+	ssh_pwauth: true
+	disable_root: false
+	ssh_authorized_keys: [
+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOa7FUrmXzdY3no8qNGUk7OPaRcIUi8G7MVbLlff9eB/ lekva@gl-mbp-m1-max.local"
+    ]
+	runcmd: [
+		["sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"],
+		// TODO(gio): take auth key from input
+		// TODO(gio): enable tailscale ssh
+		["sh", "-c", "tailscale up --login-server=https://headscale.\(global.domain) --auth-key=\(input.authKey) --accept-routes"],
+		["sh", "-c", "curl -fsSL https://code-server.dev/install.sh | HOME=/home/\(input.username) sh"],
+		["sh", "-c", "systemctl enable --now code-server@\(input.username)"],
+		["sh", "-c", "sleep 10"],
+		// TODO(gio): listen only on tailscale interface
+		["sh", "-c", "sed -i -e 's/127.0.0.1/0.0.0.0/g' /home/\(input.username)/.config/code-server/config.yaml"],
+		["sh", "-c", "sed -i -e 's/auth: password/auth: none/g' /home/\(input.username)/.config/code-server/config.yaml"],
+		["sh", "-c", "systemctl restart --now code-server@\(input.username)"],
+    ]
+}
diff --git a/core/installer/vpn.go b/core/installer/vpn.go
new file mode 100644
index 0000000..4739bf2
--- /dev/null
+++ b/core/installer/vpn.go
@@ -0,0 +1,34 @@
+package installer
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+type VPNAuthKeyGenerator interface {
+	Generate(username string) (string, error)
+}
+
+type headscaleAPIClient struct {
+	apiAddr string
+}
+
+func NewHeadscaleAPIClient(apiAddr string) VPNAuthKeyGenerator {
+	return &headscaleAPIClient{apiAddr}
+}
+
+func (g *headscaleAPIClient) Generate(username string) (string, error) {
+	resp, err := http.Post(fmt.Sprintf("%s/user/%s/preauthkey", g.apiAddr, username), "application/json", nil)
+	if err != nil {
+		return "", err
+	}
+	var buf bytes.Buffer
+	io.Copy(&buf, resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return "", errors.New(buf.String())
+	}
+	return buf.String(), nil
+}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 3f40e9f..bd72aef 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -101,6 +101,7 @@
 	env               installer.EnvConfig
 	nsc               installer.NamespaceCreator
 	jc                installer.JobCreator
+	vpnKeyGen         installer.VPNAuthKeyGenerator
 	workers           map[string]map[string]struct{}
 	appConfigs        map[string]appConfig
 	tmplts            dodoAppTmplts
@@ -132,6 +133,7 @@
 	envAppManagerAddr string,
 	nsc installer.NamespaceCreator,
 	jc installer.JobCreator,
+	vpnKeyGen installer.VPNAuthKeyGenerator,
 	env installer.EnvConfig,
 	external bool,
 	fetchUsersAddr string,
@@ -166,6 +168,7 @@
 		env,
 		nsc,
 		jc,
+		vpnKeyGen,
 		map[string]map[string]struct{}{},
 		map[string]appConfig{},
 		tmplts,
@@ -919,7 +922,7 @@
 		return err
 	}
 	hf := installer.NewGitHelmFetcher()
-	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, "/")
+	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, "/")
 	if err != nil {
 		return err
 	}
@@ -1044,7 +1047,7 @@
 		return installer.ReleaseResources{}, err
 	}
 	hf := installer.NewGitHelmFetcher()
-	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
+	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, "/.dodo")
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 9e45de5..08a8a1f 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -214,7 +214,7 @@
 	}
 	// TODO(gio): remove this once auto user sync is implemented
 	{
-		appManager, err := installer.NewAppManager(s.repo, s.nsCreator, nil, s.hf, "/apps")
+		appManager, err := installer.NewAppManager(s.repo, s.nsCreator, nil, s.hf, nil, "/apps")
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return