mirror of https://github.com/tailscale/tailscale/
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 <xe@tailscale.com>pull/4863/head
parent
1007983159
commit
dee95d0894
@ -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.
|
@ -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] <test|apply>", 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
|
||||||
|
}
|
Loading…
Reference in New Issue