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

Change-Id: Ie76278556247d16806ba81286621adca973e3f6e
diff --git a/core/headscale/client.go b/core/headscale/client.go
index ce82291..18c37df 100644
--- a/core/headscale/client.go
+++ b/core/headscale/client.go
@@ -1,9 +1,12 @@
 package main
 
 import (
+	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os/exec"
+	"strconv"
 	"strings"
 )
 
@@ -33,21 +36,81 @@
 	// TODO(giolekva): make expiration configurable, and auto-refresh
 	cmd := exec.Command("headscale", c.config, "--user", user, "preauthkeys", "create", "--reusable", "--expiration", "365d")
 	out, err := cmd.Output()
+	fmt.Println(string(out))
 	if err != nil {
 		return "", err
 	}
-	fmt.Println(string(out))
 	return extractLastLine(string(out))
 }
 
+func (c *client) expirePreAuthKey(user, authKey string) error {
+	cmd := exec.Command("headscale", c.config, "--user", user, "preauthkeys", "expire", authKey)
+	out, err := cmd.Output()
+	fmt.Println(string(out))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (c *client) expireUserNode(user, node string) error {
+	id, err := c.getNodeId(user, node)
+	if err != nil {
+		return err
+	}
+	cmd := exec.Command("headscale", c.config, "node", "expire", "--identifier", id)
+	out, err := cmd.Output()
+	fmt.Println(string(out))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (c *client) removeUserNode(user, node string) error {
+	id, err := c.getNodeId(user, node)
+	if err != nil {
+		return err
+	}
+	cmd := exec.Command("headscale", c.config, "node", "delete", "--identifier", id, "--force")
+	out, err := cmd.Output()
+	fmt.Println(string(out))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 func (c *client) enableRoute(id string) error {
-	// TODO(giolekva): make expiration configurable, and auto-refresh
 	cmd := exec.Command("headscale", c.config, "routes", "enable", "-r", id)
 	out, err := cmd.Output()
 	fmt.Println(string(out))
 	return err
 }
 
+type nodeInfo struct {
+	Id   int    `json:"id"`
+	Name string `json:"name"`
+}
+
+func (c *client) getNodeId(user, node string) (string, error) {
+	cmd := exec.Command("headscale", c.config, "--user", user, "node", "list", "-o", "json")
+	out, err := cmd.Output()
+	if err != nil {
+		return "", err
+	}
+	var nodes []nodeInfo
+	if err := json.NewDecoder(bytes.NewReader(out)).Decode(&nodes); err != nil {
+		return "", err
+	}
+	for _, n := range nodes {
+		if n.Name == node {
+			return strconv.Itoa(n.Id), nil
+		}
+	}
+	return "", fmt.Errorf("not found")
+}
+
 func extractLastLine(s string) (string, error) {
 	items := strings.Split(s, "\n")
 	for i := len(items) - 1; i >= 0; i-- {
diff --git a/core/headscale/main.go b/core/headscale/main.go
index da0ff6c..698d9d2 100644
--- a/core/headscale/main.go
+++ b/core/headscale/main.go
@@ -88,6 +88,9 @@
 	r := mux.NewRouter()
 	r.HandleFunc("/sync-users", s.handleSyncUsers).Methods(http.MethodGet)
 	r.HandleFunc("/user/{user}/preauthkey", s.createReusablePreAuthKey).Methods(http.MethodPost)
+	r.HandleFunc("/user/{user}/preauthkey", s.expireReusablePreAuthKey).Methods(http.MethodDelete)
+	r.HandleFunc("/user/{user}/node/{node}/expire", s.expireUserNode).Methods(http.MethodPost)
+	r.HandleFunc("/user/{user}/node/{node}", s.removeUserNode).Methods(http.MethodDelete)
 	r.HandleFunc("/user", s.createUser).Methods(http.MethodPost)
 	r.HandleFunc("/routes/{id}/enable", s.enableRoute).Methods(http.MethodPost)
 	go func() {
@@ -132,6 +135,63 @@
 	}
 }
 
+type expirePreAuthKeyReq struct {
+	AuthKey string `json:"authKey"`
+}
+
+func (s *server) expireReusablePreAuthKey(w http.ResponseWriter, r *http.Request) {
+	user, ok := mux.Vars(r)["user"]
+	if !ok {
+		http.Error(w, "no user", http.StatusBadRequest)
+		return
+	}
+	var req expirePreAuthKeyReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if err := s.client.expirePreAuthKey(user, req.AuthKey); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *server) expireUserNode(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("expire node")
+	user, ok := mux.Vars(r)["user"]
+	if !ok {
+		http.Error(w, "no user", http.StatusBadRequest)
+		return
+	}
+	node, ok := mux.Vars(r)["node"]
+	if !ok {
+		http.Error(w, "no user", http.StatusBadRequest)
+		return
+	}
+	if err := s.client.expireUserNode(user, node); err != nil {
+		fmt.Println(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *server) removeUserNode(w http.ResponseWriter, r *http.Request) {
+	user, ok := mux.Vars(r)["user"]
+	if !ok {
+		http.Error(w, "no user", http.StatusBadRequest)
+		return
+	}
+	node, ok := mux.Vars(r)["node"]
+	if !ok {
+		http.Error(w, "no user", http.StatusBadRequest)
+		return
+	}
+	if err := s.client.removeUserNode(user, node); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func (s *server) handleSyncUsers(_ http.ResponseWriter, _ *http.Request) {
 	go s.syncUsers()
 }
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,