AppManager: Clean up VPN node and auth keys upon app removal

Change-Id: Ie76278556247d16806ba81286621adca973e3f6e
diff --git a/core/installer/app.go b/core/installer/app.go
index 3a16abe..91f290f 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -202,7 +202,7 @@
 		networks []Network,
 		values map[string]any,
 		charts map[string]helmv2.HelmChartTemplateSpec,
-		vpnKeyGen VPNAuthKeyGenerator,
+		vpnKeyGen VPNAPIClient,
 	) (EnvAppRendered, error)
 }
 
@@ -459,7 +459,7 @@
 	networks []Network,
 	values map[string]any,
 	charts map[string]helmv2.HelmChartTemplateSpec,
-	vpnKeyGen VPNAuthKeyGenerator,
+	vpnKeyGen VPNAPIClient,
 ) (EnvAppRendered, error) {
 	derived, err := deriveValues(values, values, a.Schema(), networks, vpnKeyGen)
 	if err != nil {
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 013c05d..d31d9ff 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -30,13 +30,13 @@
 var ErrorNotFound = errors.New("not found")
 
 type AppManager struct {
-	l          sync.Locker
-	repoIO     soft.RepoIO
-	nsc        NamespaceCreator
-	jc         JobCreator
-	hf         HelmFetcher
-	vpnKeyGen  VPNAuthKeyGenerator
-	appDirRoot string
+	l            sync.Locker
+	repoIO       soft.RepoIO
+	nsc          NamespaceCreator
+	jc           JobCreator
+	hf           HelmFetcher
+	vpnAPIClient VPNAPIClient
+	appDirRoot   string
 }
 
 func NewAppManager(
@@ -44,7 +44,7 @@
 	nsc NamespaceCreator,
 	jc JobCreator,
 	hf HelmFetcher,
-	vpnKeyGen VPNAuthKeyGenerator,
+	vpnKeyGen VPNAPIClient,
 	appDirRoot string,
 ) (*AppManager, error) {
 	return &AppManager{
@@ -468,7 +468,7 @@
 		RepoAddr:      m.repoIO.FullAddress(),
 		AppDir:        appDir,
 	}
-	rendered, err := app.Render(release, env, networks, values, nil, m.vpnKeyGen)
+	rendered, err := app.Render(release, env, networks, values, nil, m.vpnAPIClient)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -500,7 +500,7 @@
 	if o.FetchContainerImages {
 		release.ImageRegistry = imageRegistry
 	}
-	rendered, err = app.Render(release, env, networks, values, localCharts, m.vpnKeyGen)
+	rendered, err = app.Render(release, env, networks, values, localCharts, m.vpnAPIClient)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -593,7 +593,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	rendered, err := app.Render(config.Release, env, networks, values, renderedCfg.LocalCharts, m.vpnKeyGen)
+	rendered, err := app.Render(config.Release, env, networks, values, renderedCfg.LocalCharts, m.vpnAPIClient)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -613,14 +613,14 @@
 	if err := m.repoIO.Pull(); err != nil {
 		return err
 	}
-	var portForward []PortForward
+	var cfg renderedInstance
 	if _, err := m.repoIO.Do(func(r soft.RepoFS) (string, error) {
 		instanceDir := filepath.Join(m.appDirRoot, instanceId)
 		renderedCfg, err := readRendered(m.repoIO, filepath.Join(instanceDir, "rendered.json"))
 		if err != nil {
 			return "", err
 		}
-		portForward = renderedCfg.PortForward
+		cfg = renderedCfg
 		r.RemoveDir(instanceDir)
 		kustPath := filepath.Join(m.appDirRoot, "kustomization.yaml")
 		kust, err := soft.ReadKustomization(r, kustPath)
@@ -633,9 +633,22 @@
 	}); err != nil {
 		return err
 	}
-	if err := closePorts(portForward); err != nil {
+	if err := closePorts(cfg.PortForward); err != nil {
 		return err
 	}
+	for vmName, vmCfg := range cfg.Out.VM {
+		if vmCfg.VPN.Enabled {
+			if err := m.vpnAPIClient.ExpireNode(vmCfg.Username, vmName); err != nil {
+				return err
+			}
+			if err := m.vpnAPIClient.ExpireKey(vmCfg.Username, vmCfg.VPN.AuthKey); err != nil {
+				return err
+			}
+			if err := m.vpnAPIClient.RemoveNode(vmCfg.Username, vmName); err != nil {
+				return err
+			}
+		}
+	}
 	return nil
 }
 
@@ -951,6 +964,19 @@
 type renderedInstance struct {
 	LocalCharts map[string]helmv2.HelmChartTemplateSpec `json:"localCharts"`
 	PortForward []PortForward                           `json:"portForward"`
+	Out         outRendered                             `json:"out"`
+}
+
+type outRendered struct {
+	VM map[string]vmRendered `json:"vm"`
+}
+
+type vmRendered struct {
+	Username string `json:"username"`
+	VPN      struct {
+		Enabled bool   `json:"enabled"`
+		AuthKey string `json:"authKey"`
+	} `json:"vpn"`
 }
 
 func readRendered(fs soft.RepoFS, path string) (renderedInstance, error) {
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 508cd3e..d99f02b 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -69,7 +69,7 @@
 	values any,
 	schema Schema,
 	networks []Network,
-	vpnKeyGen VPNAuthKeyGenerator,
+	vpnKeyGen VPNAPIClient,
 ) (map[string]any, error) {
 	ret := make(map[string]any)
 	for _, f := range schema.Fields() {
@@ -100,7 +100,7 @@
 						return nil, fmt.Errorf("could not resolve username: %+v %s %+v", def.Meta(), v, root)
 					}
 				}
-				authKey, err := vpnKeyGen.Generate(username)
+				authKey, err := vpnKeyGen.GenerateAuthKey(username)
 				if err != nil {
 					return nil, err
 				}
diff --git a/core/installer/derived_test.go b/core/installer/derived_test.go
index 7a34154..8f638b3 100644
--- a/core/installer/derived_test.go
+++ b/core/installer/derived_test.go
@@ -6,10 +6,22 @@
 
 type testKeyGen struct{}
 
-func (g testKeyGen) Generate(username string) (string, error) {
+func (g testKeyGen) GenerateAuthKey(username string) (string, error) {
 	return username, nil
 }
 
+func (g testKeyGen) ExpireKey(username, key string) error {
+	return nil
+}
+
+func (g testKeyGen) ExpireNode(username, node string) error {
+	return nil
+}
+
+func (g testKeyGen) RemoveNode(username, node string) error {
+	return nil
+}
+
 func TestDeriveVPNAuthKey(t *testing.T) {
 	schema := structSchema{
 		"input",
diff --git a/core/installer/vpn.go b/core/installer/vpn.go
index 4739bf2..01161df 100644
--- a/core/installer/vpn.go
+++ b/core/installer/vpn.go
@@ -2,25 +2,34 @@
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 )
 
-type VPNAuthKeyGenerator interface {
-	Generate(username string) (string, error)
+type VPNAPIClient interface {
+	GenerateAuthKey(username string) (string, error)
+	ExpireKey(username, key string) error
+	ExpireNode(username, node string) error
+	RemoveNode(username, node string) error
 }
 
 type headscaleAPIClient struct {
+	c       *http.Client
 	apiAddr string
 }
 
-func NewHeadscaleAPIClient(apiAddr string) VPNAuthKeyGenerator {
-	return &headscaleAPIClient{apiAddr}
+func NewHeadscaleAPIClient(apiAddr string) VPNAPIClient {
+	return &headscaleAPIClient{
+		&http.Client{},
+		apiAddr,
+	}
 }
 
-func (g *headscaleAPIClient) Generate(username string) (string, error) {
+func (g *headscaleAPIClient) GenerateAuthKey(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
@@ -32,3 +41,70 @@
 	}
 	return buf.String(), nil
 }
+
+type expirePreAuthKeyReq struct {
+	AuthKey string `json:"authKey"`
+}
+
+func (g *headscaleAPIClient) ExpireKey(username, key string) error {
+	addr, err := url.Parse(fmt.Sprintf("%s/user/%s/preauthkey", g.apiAddr, username))
+	if err != nil {
+		return err
+	}
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(expirePreAuthKeyReq{key}); err != nil {
+		return err
+	}
+	resp, err := g.c.Do(&http.Request{
+		URL:    addr,
+		Method: http.MethodDelete,
+		Body:   io.NopCloser(&buf),
+	})
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		return errors.New(buf.String())
+	}
+	return nil
+}
+
+func (g *headscaleAPIClient) ExpireNode(username, node string) error {
+	resp, err := g.c.Post(
+		fmt.Sprintf("%s/user/%s/node/%s/expire", g.apiAddr, username, node),
+		"text/plain",
+		nil,
+	)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		return errors.New(buf.String())
+	}
+	return nil
+}
+
+func (g *headscaleAPIClient) RemoveNode(username, node string) error {
+	addr, err := url.Parse(fmt.Sprintf("%s/user/%s/node/%s", g.apiAddr, username, node))
+	if err != nil {
+		return err
+	}
+	resp, err := g.c.Do(&http.Request{
+		URL:    addr,
+		Method: http.MethodDelete,
+		Body:   nil,
+	})
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		return errors.New(buf.String())
+	}
+	return nil
+}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 57de8b2..9f60449 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -103,7 +103,7 @@
 	env               installer.EnvConfig
 	nsc               installer.NamespaceCreator
 	jc                installer.JobCreator
-	vpnKeyGen         installer.VPNAuthKeyGenerator
+	vpnKeyGen         installer.VPNAPIClient
 	workers           map[string]map[string]struct{}
 	appConfigs        map[string]appConfig
 	tmplts            dodoAppTmplts
@@ -135,7 +135,7 @@
 	envAppManagerAddr string,
 	nsc installer.NamespaceCreator,
 	jc installer.JobCreator,
-	vpnKeyGen installer.VPNAuthKeyGenerator,
+	vpnKeyGen installer.VPNAPIClient,
 	env installer.EnvConfig,
 	external bool,
 	fetchUsersAddr string,