Headscale: Sync users and update ACLs

Change-Id: Ie3488f6296567f5e2301476912d79de845299708
diff --git a/charts/headscale/Chart.yaml b/charts/headscale/Chart.yaml
index f1688de..316a1f8 100644
--- a/charts/headscale/Chart.yaml
+++ b/charts/headscale/Chart.yaml
@@ -2,5 +2,5 @@
 name: headscale
 description: A Helm chart to run Headscale on PCloud
 type: application
-version: 0.0.1
-appVersion: "0.0.1"
+version: 0.0.4
+appVersion: "0.0.4"
diff --git a/charts/headscale/templates/headscale.yaml b/charts/headscale/templates/headscale.yaml
index d483854..8f1af28 100644
--- a/charts/headscale/templates/headscale.yaml
+++ b/charts/headscale/templates/headscale.yaml
@@ -166,6 +166,13 @@
           readOnly: true
         - mountPath: /headscale-api
           name: api-socket
+        livenessProbe:
+          exec:
+            command:
+              - cat
+              - /headscale/acls/config.hujson-reload
+            initialDelaySeconds: 60
+            periodSeconds: 5
       - name: headscale-api
         image: {{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}
         imagePullPolicy: {{ .Values.api.image.pullPolicy }}
@@ -179,6 +186,15 @@
         - --config=/headscale/config/config.yaml
         - --ip-subnet={{ .Values.api.ipSubnet }}
         - --acls=/headscale/acls/config.hujson
+        - --self={{ .Values.api.self }}
+        - --fetch-users-addr={{ .Values.api.fetchUsersAddr }}
+        livenessProbe:
+          exec:
+            command:
+              - cat
+              - /headscale/acls/config.hujson-reload
+            initialDelaySeconds: 60
+            periodSeconds: 5
         volumeMounts:
         - name: data
           mountPath: /headscale/data
diff --git a/charts/headscale/values.yaml b/charts/headscale/values.yaml
index 18d7b57..0237ba5 100644
--- a/charts/headscale/values.yaml
+++ b/charts/headscale/values.yaml
@@ -19,6 +19,8 @@
     repository: giolekva/headscale-api
     tag: latest
     pullPolicy: Always
+  self: ""
+  fetchUsersAddr: ""
 ui:
   enabled: false
   image:
diff --git a/core/headscale/client.go b/core/headscale/client.go
index edc36ff..ce82291 100644
--- a/core/headscale/client.go
+++ b/core/headscale/client.go
@@ -1,11 +1,14 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"os/exec"
 	"strings"
 )
 
+var ErrorAlreadyExists = errors.New("already exists")
+
 type client struct {
 	config string
 }
@@ -19,7 +22,10 @@
 func (c *client) createUser(name string) error {
 	cmd := exec.Command("headscale", c.config, "users", "create", name)
 	out, err := cmd.Output()
-	fmt.Println(string(out))
+	outStr := string(out)
+	if err != nil && strings.Contains(outStr, "User already exists") {
+		return ErrorAlreadyExists
+	}
 	return err
 }
 
diff --git a/core/headscale/go.mod b/core/headscale/go.mod
index c2c1616..a262c18 100644
--- a/core/headscale/go.mod
+++ b/core/headscale/go.mod
@@ -2,4 +2,7 @@
 
 go 1.18
 
-require github.com/gorilla/mux v1.8.1
+require (
+	github.com/gorilla/mux v1.8.1
+	golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
+)
diff --git a/core/headscale/go.sum b/core/headscale/go.sum
index 7128337..3cafaa0 100644
--- a/core/headscale/go.sum
+++ b/core/headscale/go.sum
@@ -1,2 +1,4 @@
 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
+golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
diff --git a/core/headscale/main.go b/core/headscale/main.go
index de0c2ff..da0ff6c 100644
--- a/core/headscale/main.go
+++ b/core/headscale/main.go
@@ -1,15 +1,22 @@
 package main
 
 import (
+	"bytes"
 	"encoding/json"
+	"errors"
 	"flag"
 	"fmt"
+	"io"
+	"io/ioutil"
 	"log"
 	"net"
 	"net/http"
 	"os"
 	"strings"
 	"text/template"
+	"time"
+
+	"golang.org/x/exp/rand"
 
 	"github.com/gorilla/mux"
 )
@@ -18,6 +25,8 @@
 var config = flag.String("config", "", "Path to headscale config")
 var acls = flag.String("acls", "", "Path to the headscale acls file")
 var ipSubnet = flag.String("ip-subnet", "10.1.0.0/24", "IP subnet of the private network")
+var fetchUsersAddr = flag.String("fetch-users-addr", "", "API endpoint to fetch user data")
+var self = flag.String("self", "", "Self address")
 
 // TODO(gio): make internal network cidr and proxy user configurable
 const defaultACLs = `
@@ -37,27 +46,59 @@
       "dst": ["{{ . }}:*", "private-network-proxy:0"],
     },
     {{- end }}
+    {{- range .users }}
+    { // Everyone has passthough access to private-network-proxy node
+      "action": "accept",
+      "src": ["{{ . }}"],
+      "dst": ["{{ . }}:*"],
+    },
+    {{- end }}
   ],
 }
 `
 
 type server struct {
-	port   int
-	client *client
+	port           int
+	client         *client
+	fetchUsersAddr string
+	self           string
+	aclsPath       string
+	aclsReloadPath string
+	cidrs          []string
 }
 
-func newServer(port int, client *client) *server {
+func newServer(port int, client *client, fetchUsersAddr, self, aclsPath string, cidrs []string) *server {
 	return &server{
 		port,
 		client,
+		fetchUsersAddr,
+		self,
+		aclsPath,
+		fmt.Sprintf("%s-reload", aclsPath), // TODO(gio): take from the flag
+		cidrs,
 	}
 }
 
 func (s *server) start() error {
+	f, err := os.Create(s.aclsReloadPath)
+	if err != nil {
+		return err
+	}
+	f.Close()
 	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", s.createUser).Methods(http.MethodPost)
 	r.HandleFunc("/routes/{id}/enable", s.enableRoute).Methods(http.MethodPost)
+	go func() {
+		rand.Seed(uint64(time.Now().UnixNano()))
+		s.syncUsers()
+		for {
+			delay := time.Duration(rand.Intn(60)+60) * time.Second
+			time.Sleep(delay)
+			s.syncUsers()
+		}
+	}()
 	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 }
 
@@ -91,6 +132,49 @@
 	}
 }
 
+func (s *server) handleSyncUsers(_ http.ResponseWriter, _ *http.Request) {
+	go s.syncUsers()
+}
+
+type user struct {
+	Username string `json:"username"`
+}
+
+func (s *server) syncUsers() {
+	resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/sync-users", s.fetchUsersAddr, s.self))
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	users := []user{}
+	if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
+		fmt.Println(err)
+		return
+	}
+	var usernames []string
+	for _, u := range users {
+		usernames = append(usernames, u.Username)
+		if err := s.client.createUser(u.Username); err != nil && !errors.Is(err, ErrorAlreadyExists) {
+			fmt.Println(err)
+			continue
+		}
+	}
+	currentACLs, err := ioutil.ReadFile(s.aclsPath)
+	if err != nil {
+		fmt.Println(err)
+	}
+	newACLs, err := updateACLs(s.aclsPath, s.cidrs, usernames)
+	if err != nil {
+		fmt.Println(err)
+		panic(err)
+	}
+	if !bytes.Equal(currentACLs, newACLs) {
+		if err := os.Remove(s.aclsReloadPath); err != nil {
+			fmt.Println(err)
+		}
+	}
+}
+
 func (s *server) enableRoute(w http.ResponseWriter, r *http.Request) {
 	id, ok := mux.Vars(r)["id"]
 	if !ok {
@@ -103,22 +187,24 @@
 	}
 }
 
-func updateACLs(cidrs []string, aclsPath string) error {
+func updateACLs(aclsPath string, cidrs []string, users []string) ([]byte, error) {
 	tmpl, err := template.New("acls").Parse(defaultACLs)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	out, err := os.Create(aclsPath)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	defer out.Close()
-	tmpl.Execute(os.Stdout, map[string]any{
+	var ret bytes.Buffer
+	if err := tmpl.Execute(io.MultiWriter(out, &ret), map[string]any{
 		"cidrs": cidrs,
-	})
-	return tmpl.Execute(out, map[string]any{
-		"cidrs": cidrs,
-	})
+		"users": users,
+	}); err != nil {
+		return nil, err
+	}
+	return ret.Bytes(), nil
 }
 
 func main() {
@@ -131,8 +217,7 @@
 		}
 		cidrs = append(cidrs, cidr.String())
 	}
-	updateACLs(cidrs, *acls)
 	c := newClient(*config)
-	s := newServer(*port, c)
+	s := newServer(*port, c, *fetchUsersAddr, *self, *acls, cidrs)
 	log.Fatal(s.start())
 }
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index a19fecf..3f40e9f 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -223,13 +223,6 @@
 		go func() {
 			rand.Seed(uint64(time.Now().UnixNano()))
 			s.syncUsers()
-			// TODO(dtabidze): every sync delay should be randomized to avoid all client
-			// applications hitting memberships service at the same time.
-			// For every next sync new delay should be randomly generated from scratch.
-			// We can choose random delay from 1 to 2 minutes.
-			// for range time.Tick(1 * time.Minute) {
-			// 	s.syncUsers()
-			// }
 			for {
 				delay := time.Duration(rand.Intn(60)+60) * time.Second
 				time.Sleep(delay)