2022-06-21 20:04:48 +00:00
// 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"
"log"
"net/http"
"os"
2022-07-15 14:20:54 +00:00
"regexp"
2022-06-21 20:04:48 +00:00
"strings"
"time"
2022-07-15 15:09:44 +00:00
2022-07-22 13:39:24 +00:00
"github.com/peterbourgon/ff/v3/ffcli"
2022-07-15 15:09:44 +00:00
"github.com/tailscale/hujson"
2022-06-21 20:04:48 +00:00
)
var (
2022-07-22 13:39:24 +00:00
rootFlagSet = flag . NewFlagSet ( "gitops-pusher" , flag . ExitOnError )
policyFname = rootFlagSet . String ( "policy-file" , "./policy.hujson" , "filename for policy file" )
2022-07-22 19:07:38 +00:00
cacheFname = rootFlagSet . String ( "cache-file" , "./version-cache.json" , "filename for the previous known version hash" )
2022-07-22 13:39:24 +00:00
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)" )
2022-06-21 20:04:48 +00:00
)
2022-07-22 19:07:38 +00:00
func modifiedExternallyError ( ) {
if * githubSyntax {
2022-08-30 13:41:25 +00:00
fmt . Printf ( "::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n" , * policyFname )
2022-07-22 19:07:38 +00:00
} else {
fmt . Printf ( "The policy file was modified externally in the admin console.\n" )
}
}
func apply ( cache * Cache , tailnet , apiKey string ) func ( context . Context , [ ] string ) error {
2022-07-22 13:39:24 +00:00
return func ( ctx context . Context , args [ ] string ) error {
2022-06-21 20:04:48 +00:00
controlEtag , err := getACLETag ( ctx , tailnet , apiKey )
if err != nil {
2022-07-22 13:39:24 +00:00
return err
2022-06-21 20:04:48 +00:00
}
localEtag , err := sumFile ( * policyFname )
if err != nil {
2022-07-22 13:39:24 +00:00
return err
2022-06-21 20:04:48 +00:00
}
2022-07-22 19:07:38 +00:00
if cache . PrevETag == "" {
log . Println ( "no previous etag found, assuming local file is correct and recording that" )
2022-07-25 13:09:32 +00:00
cache . PrevETag = localEtag
2022-07-22 19:07:38 +00:00
}
2022-06-21 20:04:48 +00:00
log . Printf ( "control: %s" , controlEtag )
log . Printf ( "local: %s" , localEtag )
2022-07-22 19:07:38 +00:00
log . Printf ( "cache: %s" , cache . PrevETag )
if cache . PrevETag != controlEtag {
modifiedExternallyError ( )
}
2022-06-21 20:04:48 +00:00
if controlEtag == localEtag {
2022-07-25 13:09:32 +00:00
cache . PrevETag = localEtag
2022-06-21 20:04:48 +00:00
log . Println ( "no update needed, doing nothing" )
2022-07-22 13:39:24 +00:00
return nil
2022-06-21 20:04:48 +00:00
}
if err := applyNewACL ( ctx , tailnet , apiKey , * policyFname , controlEtag ) ; err != nil {
2022-07-22 13:39:24 +00:00
return err
2022-06-21 20:04:48 +00:00
}
2022-07-25 13:09:32 +00:00
cache . PrevETag = localEtag
2022-07-22 19:07:38 +00:00
2022-07-22 13:39:24 +00:00
return nil
}
}
2022-07-22 19:07:38 +00:00
func test ( cache * Cache , tailnet , apiKey string ) func ( context . Context , [ ] string ) error {
2022-07-22 13:39:24 +00:00
return func ( ctx context . Context , args [ ] string ) error {
2022-06-21 20:04:48 +00:00
controlEtag , err := getACLETag ( ctx , tailnet , apiKey )
if err != nil {
2022-07-22 13:39:24 +00:00
return err
2022-06-21 20:04:48 +00:00
}
localEtag , err := sumFile ( * policyFname )
if err != nil {
2022-07-22 13:39:24 +00:00
return err
2022-06-21 20:04:48 +00:00
}
2022-07-22 19:07:38 +00:00
if cache . PrevETag == "" {
log . Println ( "no previous etag found, assuming local file is correct and recording that" )
2022-07-25 13:09:32 +00:00
cache . PrevETag = localEtag
2022-07-22 19:07:38 +00:00
}
2022-06-21 20:04:48 +00:00
log . Printf ( "control: %s" , controlEtag )
log . Printf ( "local: %s" , localEtag )
2022-07-22 19:07:38 +00:00
log . Printf ( "cache: %s" , cache . PrevETag )
if cache . PrevETag != controlEtag {
modifiedExternallyError ( )
}
2022-06-21 20:04:48 +00:00
if controlEtag == localEtag {
log . Println ( "no updates found, doing nothing" )
2022-07-22 13:39:24 +00:00
return nil
2022-06-21 20:04:48 +00:00
}
if err := testNewACLs ( ctx , tailnet , apiKey , * policyFname ) ; err != nil {
2022-07-22 13:39:24 +00:00
return err
}
return nil
}
}
2022-07-22 19:07:38 +00:00
func getChecksums ( cache * Cache , tailnet , apiKey string ) func ( context . Context , [ ] string ) error {
2022-07-22 13:39:24 +00:00
return func ( ctx context . Context , args [ ] string ) error {
controlEtag , err := getACLETag ( ctx , tailnet , apiKey )
if err != nil {
return err
2022-06-21 20:04:48 +00:00
}
2022-07-22 13:39:24 +00:00
localEtag , err := sumFile ( * policyFname )
if err != nil {
return err
}
2022-07-22 19:07:38 +00:00
if cache . PrevETag == "" {
log . Println ( "no previous etag found, assuming local file is correct and recording that" )
cache . PrevETag = Shuck ( localEtag )
}
2022-07-22 13:39:24 +00:00
log . Printf ( "control: %s" , controlEtag )
log . Printf ( "local: %s" , localEtag )
2022-07-22 19:07:38 +00:00
log . Printf ( "cache: %s" , cache . PrevETag )
2022-07-22 13:39:24 +00:00
return nil
}
}
func main ( ) {
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" )
}
2022-07-22 19:07:38 +00:00
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 )
2022-07-22 13:39:24 +00:00
applyCmd := & ffcli . Command {
Name : "apply" ,
ShortUsage : "gitops-pusher [options] apply" ,
ShortHelp : "Pushes changes to CONTROL" ,
LongHelp : ` Pushes changes to CONTROL ` ,
2022-07-22 19:07:38 +00:00
Exec : apply ( cache , tailnet , apiKey ) ,
2022-07-22 13:39:24 +00:00
}
testCmd := & ffcli . Command {
Name : "test" ,
ShortUsage : "gitops-pusher [options] test" ,
ShortHelp : "Tests ACL changes" ,
LongHelp : "Tests ACL changes" ,
2022-07-22 19:07:38 +00:00
Exec : test ( cache , tailnet , apiKey ) ,
2022-07-22 13:39:24 +00:00
}
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" ,
2022-07-22 19:07:38 +00:00
Exec : getChecksums ( cache , tailnet , apiKey ) ,
2022-07-22 13:39:24 +00:00
}
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 )
2022-06-21 20:04:48 +00:00
}
}
func sumFile ( fname string ) ( string , error ) {
2022-07-15 15:09:44 +00:00
data , err := os . ReadFile ( fname )
if err != nil {
return "" , err
}
formatted , err := hujson . Format ( data )
2022-06-21 20:04:48 +00:00
if err != nil {
return "" , err
}
h := sha256 . New ( )
2022-07-15 15:09:44 +00:00
_ , err = h . Write ( formatted )
2022-06-21 20:04:48 +00:00
if err != nil {
return "" , err
}
2022-07-22 19:07:38 +00:00
return fmt . Sprintf ( "%x" , h . Sum ( nil ) ) , nil
2022-06-21 20:04:48 +00:00
}
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" )
2022-07-25 13:09:32 +00:00
req . Header . Set ( "If-Match" , ` " ` + oldEtag + ` " ` )
2022-06-21 20:04:48 +00:00
resp , err := http . DefaultClient . Do ( req )
if err != nil {
return err
}
defer resp . Body . Close ( )
got := resp . StatusCode
want := http . StatusOK
if got != want {
2022-07-08 14:53:50 +00:00
var ate ACLTestError
err := json . NewDecoder ( resp . Body ) . Decode ( & ate )
if err != nil {
return err
}
return ate
2022-06-21 20:04:48 +00:00
}
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 ( )
2022-07-08 14:53:50 +00:00
var ate ACLTestError
err = json . NewDecoder ( resp . Body ) . Decode ( & ate )
if err != nil {
return err
}
2022-06-21 20:04:48 +00:00
2022-07-15 14:20:54 +00:00
if len ( ate . Message ) != 0 || len ( ate . Data ) != 0 {
2022-06-21 20:04:48 +00:00
return ate
}
2022-07-22 17:53:42 +00:00
got := resp . StatusCode
want := http . StatusOK
if got != want {
return fmt . Errorf ( "wanted HTTP status code %d but got %d" , want , got )
}
2022-06-21 20:04:48 +00:00
return nil
}
2022-07-21 17:02:14 +00:00
var lineColMessageSplit = regexp . MustCompile ( ` line ([0-9]+), column ([0-9]+): (.*)$ ` )
2022-07-15 14:20:54 +00:00
2022-06-21 20:04:48 +00:00
type ACLTestError struct {
Message string ` json:"message" `
Data [ ] ACLTestErrorDetail ` json:"data" `
}
func ( ate ACLTestError ) Error ( ) string {
var sb strings . Builder
2022-07-15 14:20:54 +00:00
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 )
}
2022-06-21 20:04:48 +00:00
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 )
}
2022-07-22 19:07:38 +00:00
return Shuck ( resp . Header . Get ( "ETag" ) ) , nil
2022-06-21 20:04:48 +00:00
}