Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.//// See README.md for more details.package main
import ( "bytes" "context" "crypto/sha256" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "regexp" "strings" "sync" "time"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/tailscale/hujson" "golang.org/x/oauth2/clientcredentials" tsclient "tailscale.com/client/tailscale" _ "tailscale.com/feature/identityfederation" "tailscale.com/internal/client/tailscale" "tailscale.com/util/httpm")
var ( rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError) policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file") cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash") timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run") githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)") apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact") failOnManualEdits = rootFlagSet.Bool("fail-on-manual-edits", false, "fail if manual edits to the ACLs in the admin panel are detected; when set to false (the default) only a warning is printed"))
var ( getCredentialsOnce sync.Once client *http.Client apiKey string)
func modifiedExternallyError() error { if *githubSyntax { return fmt.Errorf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.", *policyFname) } else { return fmt.Errorf("The policy file was modified externally in the admin console.") }}
func apply(cache *Cache, tailnet string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { controlEtag, err := getACLETag(ctx, tailnet) if err != nil { return err }
localEtag, err := sumFile(*policyFname) if err != nil { return err }
if cache.PrevETag == "" { log.Println("no previous etag found, assuming the latest control etag") cache.PrevETag = controlEtag }
log.Printf("control: %s", controlEtag) log.Printf("local: %s", localEtag) log.Printf("cache: %s", cache.PrevETag)
if controlEtag == localEtag { cache.PrevETag = localEtag log.Println("no update needed, doing nothing") return nil }
if cache.PrevETag != controlEtag { err := modifiedExternallyError() if *failOnManualEdits { return err } fmt.Println(err) }
if err := applyNewACL(ctx, tailnet, *policyFname, controlEtag); err != nil { return err }
cache.PrevETag = localEtag
return nil }}
func test(cache *Cache, tailnet string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { controlEtag, err := getACLETag(ctx, tailnet) if err != nil { return err }
localEtag, err := sumFile(*policyFname) if err != nil { return err }
if cache.PrevETag == "" { log.Println("no previous etag found, assuming the latest control etag") cache.PrevETag = controlEtag }
log.Printf("control: %s", controlEtag) log.Printf("local: %s", localEtag) log.Printf("cache: %s", cache.PrevETag)
if controlEtag == localEtag { log.Println("no updates found, doing nothing") return nil }
if cache.PrevETag != controlEtag { err := modifiedExternallyError() if *failOnManualEdits { return err } fmt.Println(err) }
if err := testNewACLs(ctx, tailnet, *policyFname); err != nil { return err } return nil }}
func getChecksums(cache *Cache, tailnet string) func(context.Context, []string) error { return func(ctx context.Context, args []string) error { controlEtag, err := getACLETag(ctx, tailnet) if err != nil { return err }
localEtag, err := sumFile(*policyFname) if err != nil { return err }
if cache.PrevETag == "" { log.Println("no previous etag found, assuming control etag") cache.PrevETag = Shuck(controlEtag) }
log.Printf("control: %s", controlEtag) log.Printf("local: %s", localEtag) log.Printf("cache: %s", cache.PrevETag)
return nil }}
func main() { tailnet, ok := os.LookupEnv("TS_TAILNET") if !ok { log.Fatal("set envvar TS_TAILNET to your tailnet's name") }
cache, err := LoadCache(*cacheFname) if err != nil { if os.IsNotExist(err) { cache = &Cache{} } else { log.Fatalf("error loading cache: %v", err) } } defer cache.Save(*cacheFname)
applyCmd := &ffcli.Command{ Name: "apply", ShortUsage: "gitops-pusher [options] apply", ShortHelp: "Pushes changes to CONTROL", LongHelp: `Pushes changes to CONTROL`, Exec: apply(cache, tailnet), }
testCmd := &ffcli.Command{ Name: "test", ShortUsage: "gitops-pusher [options] test", ShortHelp: "Tests ACL changes", LongHelp: "Tests ACL changes", Exec: test(cache, tailnet), }
cksumCmd := &ffcli.Command{ Name: "checksum", ShortUsage: "Shows checksums of ACL files", ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison", LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison", Exec: getChecksums(cache, tailnet), }
root := &ffcli.Command{ ShortUsage: "gitops-pusher [options] <command>", ShortHelp: "Push Tailscale ACLs to CONTROL using a GitOps workflow", Subcommands: []*ffcli.Command{applyCmd, cksumCmd, testCmd}, FlagSet: rootFlagSet, }
if err := root.Parse(os.Args[1:]); err != nil { log.Fatal(err) }
ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel()
if err := root.Run(ctx); err != nil { fmt.Println(err) os.Exit(1) }}
func getCredentials() (*http.Client, string) { getCredentialsOnce.Do(func() { apiKeyEnv, ok := os.LookupEnv("TS_API_KEY") oauthId, oiok := os.LookupEnv("TS_OAUTH_ID") oauthSecret, osok := os.LookupEnv("TS_OAUTH_SECRET") idToken, idok := os.LookupEnv("TS_ID_TOKEN")
if !ok && (!oiok || (!osok && !idok)) { log.Fatal("set envvar TS_API_KEY to your Tailscale API key, TS_OAUTH_ID and TS_OAUTH_SECRET to a Tailscale OAuth ID and Secret, or TS_OAUTH_ID and TS_ID_TOKEN to a Tailscale federated identity Client ID and OIDC identity token") } if apiKeyEnv != "" && (oauthId != "" || (oauthSecret != "" && idToken != "")) { log.Fatal("set either the envvar TS_API_KEY, TS_OAUTH_ID and TS_OAUTH_SECRET, or TS_OAUTH_ID and TS_ID_TOKEN") } if oiok && ((oauthId != "" && !idok) || oauthSecret != "") { // Both should ideally be set, but if either are non-empty it means the user had an intent // to set _something_, so they should receive the oauth error flow. oauthConfig := &clientcredentials.Config{ ClientID: oauthId, ClientSecret: oauthSecret, TokenURL: fmt.Sprintf("https://%s/api/v2/oauth/token", *apiServer), } client = oauthConfig.Client(context.Background()) } else if idok && idToken != "" && oiok && oauthId != "" { if exchangeJWTForToken, ok := tailscale.HookExchangeJWTForTokenViaWIF.GetOk(); ok { var err error apiKeyEnv, err = exchangeJWTForToken(context.Background(), tailscale.ExchangeJWTForTokenWIFArgs{ BaseURL: fmt.Sprintf("https://%s", *apiServer), ClientID: oauthId, IDToken: idToken, }) if err != nil { log.Fatal(err) } } client = http.DefaultClient } else { client = http.DefaultClient }
apiKey = apiKeyEnv })
return client, apiKey}
func sumFile(fname string) (string, error) { data, err := os.ReadFile(fname) if err != nil { return "", err }
formatted, err := hujson.Format(data) if err != nil { return "", err }
h := sha256.New() _, err = h.Write(formatted) if err != nil { return "", err }
return fmt.Sprintf("%x", h.Sum(nil)), nil}
func applyNewACL(ctx context.Context, tailnet, policyFname, oldEtag string) error { client, apiKey := getCredentials()
fin, err := os.Open(policyFname) if err != nil { return err } defer fin.Close()
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, 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 := client.Do(req) if err != nil { return err } defer resp.Body.Close()
got := resp.StatusCode want := http.StatusOK if got != want { var ate ACLGitopsTestError err := json.NewDecoder(resp.Body).Decode(&ate) if err != nil { return err }
return ate }
return nil}
func testNewACLs(ctx context.Context, tailnet, policyFname string) error { client, apiKey := getCredentials()
data, err := os.ReadFile(policyFname) if err != nil { return err } data, err = hujson.Standardize(data) if err != nil { return err }
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl/validate", *apiServer, tailnet), bytes.NewBuffer(data)) if err != nil { return err }
req.SetBasicAuth(apiKey, "") req.Header.Set("Content-Type", "application/hujson")
resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close()
var ate ACLGitopsTestError err = json.NewDecoder(resp.Body).Decode(&ate) if err != nil { return err }
if len(ate.Message) != 0 || len(ate.Data) != 0 { return ate }
got := resp.StatusCode want := http.StatusOK if got != want { return fmt.Errorf("wanted HTTP status code %d but got %d", want, got) }
return nil}
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
// ACLGitopsTestError is redefined here so we can add a custom .Error() responsetype ACLGitopsTestError struct { tsclient.ACLTestError}
func (ate ACLGitopsTestError) Error() string { var sb strings.Builder
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) { sp := lineColMessageSplit.FindStringSubmatch(ate.Message)
line := sp[1] col := sp[2] msg := sp[3]
fmt.Fprintf(&sb, "::error file=%s,line=%s,col=%s::%s", *policyFname, line, col, msg) } else { fmt.Fprintln(&sb, ate.Message) } fmt.Fprintln(&sb)
for _, data := range ate.Data { if data.User != "" { fmt.Fprintf(&sb, "For user %s:\n", data.User) }
if len(data.Errors) > 0 { fmt.Fprint(&sb, "Errors found:\n") for _, err := range data.Errors { fmt.Fprintf(&sb, "- %s\n", err) } }
if len(data.Warnings) > 0 { fmt.Fprint(&sb, "Warnings found:\n") for _, err := range data.Warnings { fmt.Fprintf(&sb, "- %s\n", err) } } }
return sb.String()}
func getACLETag(ctx context.Context, tailnet string) (string, error) { client, apiKey := getCredentials()
req, err := http.NewRequestWithContext(ctx, httpm.GET, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil) if err != nil { return "", err }
req.SetBasicAuth(apiKey, "") req.Header.Set("Accept", "application/hujson")
resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close()
got := resp.StatusCode want := http.StatusOK if got != want { errorDetails, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("wanted HTTP status code %d but got %d: %#q", want, got, string(errorDetails)) }
return Shuck(resp.Header.Get("ETag")), nil}