From dee95d08943f03e7d4127f4cb0103435e846b57f Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Tue, 21 Jun 2022 16:04:48 -0400 Subject: [PATCH] cmd/gitops-pusher: add new GitOps assistant (#4893) This is for an upcoming blogpost on how to manage Tailscale ACLs using a GitOps flow. This tool is intended to be used in CI and will allow users to have a git repository be the ultimate source of truth for their ACL file. This enables ACL changes to be proposed, approved and discussed before they are applied. Signed-off-by: Xe --- cmd/gitops-pusher/README.md | 48 +++++++ cmd/gitops-pusher/gitops-pusher.go | 223 +++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 cmd/gitops-pusher/README.md create mode 100644 cmd/gitops-pusher/gitops-pusher.go diff --git a/cmd/gitops-pusher/README.md b/cmd/gitops-pusher/README.md new file mode 100644 index 000000000..d569f8b5f --- /dev/null +++ b/cmd/gitops-pusher/README.md @@ -0,0 +1,48 @@ +# gitops-pusher + +This is a small tool to help people achieve a +[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL +changes. This tool is intended to be used in a CI flow that looks like this: + +```yaml +name: Tailscale ACL syncing + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + acls: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Go environment + uses: actions/setup-go@v3.2.0 + + - name: Install gitops-pusher + run: go install tailscale.com/cmd/gitops-pusher@latest + + - name: Deploy ACL + if: github.event_name == 'push' + env: + TS_API_KEY: ${{ secrets.TS_API_KEY }} + TS_TAILNET: ${{ secrets.TS_TAILNET }} + run: | + ~/go/bin/gitops-pusher --policy-file ./policy.hujson apply + + - name: ACL tests + if: github.event_name == 'pull_request' + env: + TS_API_KEY: ${{ secrets.TS_API_KEY }} + TS_TAILNET: ${{ secrets.TS_TAILNET }} + run: | + ~/go/bin/gitops-pusher --policy-file ./policy.hujson test +``` + +Change the value of the `--policy-file` flag to point to the policy file on +disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson) +format. diff --git a/cmd/gitops-pusher/gitops-pusher.go b/cmd/gitops-pusher/gitops-pusher.go new file mode 100644 index 000000000..d27565816 --- /dev/null +++ b/cmd/gitops-pusher/gitops-pusher.go @@ -0,0 +1,223 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs. +// +// See README.md for more details. +package main + +import ( + "context" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +var ( + policyFname = flag.String("policy-file", "./policy.hujson", "filename for policy file") + timeout = flag.Duration("timeout", 5*time.Minute, "timeout for the entire CI run") +) + +func main() { + flag.Parse() + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + tailnet, ok := os.LookupEnv("TS_TAILNET") + if !ok { + log.Fatal("set envvar TS_TAILNET to your tailnet's name") + } + apiKey, ok := os.LookupEnv("TS_API_KEY") + if !ok { + log.Fatal("set envvar TS_API_KEY to your Tailscale API key") + } + + switch flag.Arg(0) { + case "apply": + controlEtag, err := getACLETag(ctx, tailnet, apiKey) + if err != nil { + log.Fatal(err) + } + + localEtag, err := sumFile(*policyFname) + if err != nil { + log.Fatal(err) + } + + log.Printf("control: %s", controlEtag) + log.Printf("local: %s", localEtag) + + if controlEtag == localEtag { + log.Println("no update needed, doing nothing") + os.Exit(0) + } + + if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil { + log.Fatal(err) + } + + case "test": + controlEtag, err := getACLETag(ctx, tailnet, apiKey) + if err != nil { + log.Fatal(err) + } + + localEtag, err := sumFile(*policyFname) + if err != nil { + log.Fatal(err) + } + + log.Printf("control: %s", controlEtag) + log.Printf("local: %s", localEtag) + + if controlEtag == localEtag { + log.Println("no updates found, doing nothing") + os.Exit(0) + } + + if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil { + log.Fatal(err) + } + default: + log.Fatalf("usage: %s [options] ", os.Args[0]) + } +} + +func sumFile(fname string) (string, error) { + fin, err := os.Open(fname) + if err != nil { + return "", err + } + defer fin.Close() + + h := sha256.New() + _, err = io.Copy(h, fin) + if err != nil { + return "", err + } + + return fmt.Sprintf("\"%x\"", h.Sum(nil)), nil +} + +func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error { + fin, err := os.Open(policyFname) + if err != nil { + return err + } + defer fin.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), fin) + if err != nil { + return err + } + + req.SetBasicAuth(apiKey, "") + req.Header.Set("Content-Type", "application/hujson") + req.Header.Set("If-Match", oldEtag) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + got := resp.StatusCode + want := http.StatusOK + if got != want { + return fmt.Errorf("wanted HTTP status code %d but got %d", want, got) + } + + return nil +} + +func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error { + fin, err := os.Open(policyFname) + if err != nil { + return err + } + defer fin.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), fin) + if err != nil { + return err + } + + req.SetBasicAuth(apiKey, "") + req.Header.Set("Content-Type", "application/hujson") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var ate ACLTestError + err := json.NewDecoder(resp.Body).Decode(&ate) + if err != nil { + return err + } + + return ate + } + + return nil +} + +type ACLTestError struct { + Message string `json:"message"` + Data []ACLTestErrorDetail `json:"data"` +} + +func (ate ACLTestError) Error() string { + var sb strings.Builder + + fmt.Fprintln(&sb, ate.Message) + fmt.Fprintln(&sb) + + for _, data := range ate.Data { + fmt.Fprintf(&sb, "For user %s:\n", data.User) + for _, err := range data.Errors { + fmt.Fprintf(&sb, "- %s\n", err) + } + } + + return sb.String() +} + +type ACLTestErrorDetail struct { + User string `json:"user"` + Errors []string `json:"errors"` +} + +func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), nil) + if err != nil { + return "", err + } + + req.SetBasicAuth(apiKey, "") + req.Header.Set("Accept", "application/hujson") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + got := resp.StatusCode + want := http.StatusOK + if got != want { + return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got) + } + + return resp.Header.Get("ETag"), nil +}