Skip to content

Commit dee95d0

Browse files
author
Xe Iaso
authored
cmd/gitops-pusher: add new GitOps assistant (tailscale#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>
1 parent 1007983 commit dee95d0

File tree

2 files changed

+271
-0
lines changed

2 files changed

+271
-0
lines changed

cmd/gitops-pusher/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# gitops-pusher
2+
3+
This is a small tool to help people achieve a
4+
[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL
5+
changes. This tool is intended to be used in a CI flow that looks like this:
6+
7+
```yaml
8+
name: Tailscale ACL syncing
9+
10+
on:
11+
push:
12+
branches: [ "main" ]
13+
pull_request:
14+
branches: [ "main" ]
15+
16+
jobs:
17+
acls:
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- uses: actions/checkout@v3
22+
23+
- name: Setup Go environment
24+
uses: actions/setup-go@v3.2.0
25+
26+
- name: Install gitops-pusher
27+
run: go install tailscale.com/cmd/gitops-pusher@latest
28+
29+
- name: Deploy ACL
30+
if: github.event_name == 'push'
31+
env:
32+
TS_API_KEY: ${{ secrets.TS_API_KEY }}
33+
TS_TAILNET: ${{ secrets.TS_TAILNET }}
34+
run: |
35+
~/go/bin/gitops-pusher --policy-file ./policy.hujson apply
36+
37+
- name: ACL tests
38+
if: github.event_name == 'pull_request'
39+
env:
40+
TS_API_KEY: ${{ secrets.TS_API_KEY }}
41+
TS_TAILNET: ${{ secrets.TS_TAILNET }}
42+
run: |
43+
~/go/bin/gitops-pusher --policy-file ./policy.hujson test
44+
```
45+
46+
Change the value of the `--policy-file` flag to point to the policy file on
47+
disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson)
48+
format.

cmd/gitops-pusher/gitops-pusher.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.
6+
//
7+
// See README.md for more details.
8+
package main
9+
10+
import (
11+
"context"
12+
"crypto/sha256"
13+
"encoding/json"
14+
"flag"
15+
"fmt"
16+
"io"
17+
"log"
18+
"net/http"
19+
"os"
20+
"strings"
21+
"time"
22+
)
23+
24+
var (
25+
policyFname = flag.String("policy-file", "./policy.hujson", "filename for policy file")
26+
timeout = flag.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
27+
)
28+
29+
func main() {
30+
flag.Parse()
31+
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
32+
defer cancel()
33+
34+
tailnet, ok := os.LookupEnv("TS_TAILNET")
35+
if !ok {
36+
log.Fatal("set envvar TS_TAILNET to your tailnet's name")
37+
}
38+
apiKey, ok := os.LookupEnv("TS_API_KEY")
39+
if !ok {
40+
log.Fatal("set envvar TS_API_KEY to your Tailscale API key")
41+
}
42+
43+
switch flag.Arg(0) {
44+
case "apply":
45+
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
46+
if err != nil {
47+
log.Fatal(err)
48+
}
49+
50+
localEtag, err := sumFile(*policyFname)
51+
if err != nil {
52+
log.Fatal(err)
53+
}
54+
55+
log.Printf("control: %s", controlEtag)
56+
log.Printf("local: %s", localEtag)
57+
58+
if controlEtag == localEtag {
59+
log.Println("no update needed, doing nothing")
60+
os.Exit(0)
61+
}
62+
63+
if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil {
64+
log.Fatal(err)
65+
}
66+
67+
case "test":
68+
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
69+
if err != nil {
70+
log.Fatal(err)
71+
}
72+
73+
localEtag, err := sumFile(*policyFname)
74+
if err != nil {
75+
log.Fatal(err)
76+
}
77+
78+
log.Printf("control: %s", controlEtag)
79+
log.Printf("local: %s", localEtag)
80+
81+
if controlEtag == localEtag {
82+
log.Println("no updates found, doing nothing")
83+
os.Exit(0)
84+
}
85+
86+
if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil {
87+
log.Fatal(err)
88+
}
89+
default:
90+
log.Fatalf("usage: %s [options] <test|apply>", os.Args[0])
91+
}
92+
}
93+
94+
func sumFile(fname string) (string, error) {
95+
fin, err := os.Open(fname)
96+
if err != nil {
97+
return "", err
98+
}
99+
defer fin.Close()
100+
101+
h := sha256.New()
102+
_, err = io.Copy(h, fin)
103+
if err != nil {
104+
return "", err
105+
}
106+
107+
return fmt.Sprintf("\"%x\"", h.Sum(nil)), nil
108+
}
109+
110+
func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error {
111+
fin, err := os.Open(policyFname)
112+
if err != nil {
113+
return err
114+
}
115+
defer fin.Close()
116+
117+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), fin)
118+
if err != nil {
119+
return err
120+
}
121+
122+
req.SetBasicAuth(apiKey, "")
123+
req.Header.Set("Content-Type", "application/hujson")
124+
req.Header.Set("If-Match", oldEtag)
125+
126+
resp, err := http.DefaultClient.Do(req)
127+
if err != nil {
128+
return err
129+
}
130+
defer resp.Body.Close()
131+
132+
got := resp.StatusCode
133+
want := http.StatusOK
134+
if got != want {
135+
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
136+
}
137+
138+
return nil
139+
}
140+
141+
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
142+
fin, err := os.Open(policyFname)
143+
if err != nil {
144+
return err
145+
}
146+
defer fin.Close()
147+
148+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), fin)
149+
if err != nil {
150+
return err
151+
}
152+
153+
req.SetBasicAuth(apiKey, "")
154+
req.Header.Set("Content-Type", "application/hujson")
155+
156+
resp, err := http.DefaultClient.Do(req)
157+
if err != nil {
158+
return err
159+
}
160+
defer resp.Body.Close()
161+
162+
if resp.StatusCode != http.StatusOK {
163+
var ate ACLTestError
164+
err := json.NewDecoder(resp.Body).Decode(&ate)
165+
if err != nil {
166+
return err
167+
}
168+
169+
return ate
170+
}
171+
172+
return nil
173+
}
174+
175+
type ACLTestError struct {
176+
Message string `json:"message"`
177+
Data []ACLTestErrorDetail `json:"data"`
178+
}
179+
180+
func (ate ACLTestError) Error() string {
181+
var sb strings.Builder
182+
183+
fmt.Fprintln(&sb, ate.Message)
184+
fmt.Fprintln(&sb)
185+
186+
for _, data := range ate.Data {
187+
fmt.Fprintf(&sb, "For user %s:\n", data.User)
188+
for _, err := range data.Errors {
189+
fmt.Fprintf(&sb, "- %s\n", err)
190+
}
191+
}
192+
193+
return sb.String()
194+
}
195+
196+
type ACLTestErrorDetail struct {
197+
User string `json:"user"`
198+
Errors []string `json:"errors"`
199+
}
200+
201+
func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) {
202+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), nil)
203+
if err != nil {
204+
return "", err
205+
}
206+
207+
req.SetBasicAuth(apiKey, "")
208+
req.Header.Set("Accept", "application/hujson")
209+
210+
resp, err := http.DefaultClient.Do(req)
211+
if err != nil {
212+
return "", err
213+
}
214+
defer resp.Body.Close()
215+
216+
got := resp.StatusCode
217+
want := http.StatusOK
218+
if got != want {
219+
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
220+
}
221+
222+
return resp.Header.Get("ETag"), nil
223+
}

0 commit comments

Comments
 (0)