Skip to content

Commit 25b816a

Browse files
authored
Support tailscale configuration via Caddy configuration
Register a parseApp function to parse the caddy configuration file. The parse function creates a new TSApp which will hold our configuration and create the CaddyModule. Initially see if the provisioned server or global key exist in the app usage pool. If not then fallback to the already existing environment variables Signed-off-by: Connor Kelly <connor.r.kelly@gmail.com>
1 parent 7b5a952 commit 25b816a

File tree

7 files changed

+400
-7
lines changed

7 files changed

+400
-7
lines changed

Caddyfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
{
22
order tailscale_auth after basicauth
3+
tailscale {
4+
auth_key "tskey-auth-"
5+
6+
caddy {
7+
auth_key "tskey-auth-caddy"
8+
}
9+
}
310
}
411

512
:80 {

app.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package tscaddy
2+
3+
import (
4+
"github.com/caddyserver/caddy/v2"
5+
)
6+
7+
func init() {
8+
caddy.RegisterModule(TSApp{})
9+
}
10+
11+
type TSApp struct {
12+
// DefaultAuthKey is the default auth key to use for Tailscale if no other auth key is specified.
13+
DefaultAuthKey string `json:"auth_key,omitempty" caddy:"namespace=tailscale.auth_key"`
14+
15+
Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"`
16+
17+
Servers map[string]TSServer `json:"servers,omitempty" caddy:"namespace=tailscale"`
18+
}
19+
20+
type TSServer struct {
21+
AuthKey string `json:"auth_key,omitempty" caddy:"namespace=auth_key"`
22+
23+
Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"`
24+
25+
name string
26+
}
27+
28+
func (TSApp) CaddyModule() caddy.ModuleInfo {
29+
return caddy.ModuleInfo{
30+
ID: "tailscale",
31+
New: func() caddy.Module { return new(TSApp) },
32+
}
33+
}
34+
35+
func (t *TSApp) Start() error {
36+
tsapp.Store(t)
37+
return nil
38+
}
39+
40+
func (t *TSApp) Stop() error {
41+
tsapp.CompareAndSwap(t, nil)
42+
return nil
43+
}
44+
45+
var _ caddy.App = (*TSApp)(nil)

caddyfile.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package tscaddy
2+
3+
import (
4+
"github.com/caddyserver/caddy/v2/caddyconfig"
5+
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
6+
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
7+
)
8+
9+
func init() {
10+
httpcaddyfile.RegisterGlobalOption("tailscale", parseApp)
11+
}
12+
13+
func parseApp(d *caddyfile.Dispenser, _ any) (any, error) {
14+
app := &TSApp{
15+
Servers: make(map[string]TSServer),
16+
}
17+
if !d.Next() {
18+
return app, d.ArgErr()
19+
20+
}
21+
22+
for d.NextBlock(0) {
23+
val := d.Val()
24+
25+
switch val {
26+
case "auth_key":
27+
if !d.NextArg() {
28+
return nil, d.ArgErr()
29+
}
30+
app.DefaultAuthKey = d.Val()
31+
case "ephemeral":
32+
app.Ephemeral = true
33+
default:
34+
svr, err := parseServer(d)
35+
if app.Servers == nil {
36+
app.Servers = map[string]TSServer{}
37+
}
38+
if err != nil {
39+
return nil, err
40+
}
41+
app.Servers[svr.name] = svr
42+
}
43+
}
44+
45+
return httpcaddyfile.App{
46+
Name: "tailscale",
47+
Value: caddyconfig.JSON(app, nil),
48+
}, nil
49+
}
50+
51+
func parseServer(d *caddyfile.Dispenser) (TSServer, error) {
52+
name := d.Val()
53+
segment := d.NewFromNextSegment()
54+
55+
if !segment.Next() {
56+
return TSServer{}, d.ArgErr()
57+
}
58+
59+
svr := TSServer{}
60+
svr.name = name
61+
for nesting := segment.Nesting(); segment.NextBlock(nesting); {
62+
val := segment.Val()
63+
switch val {
64+
case "auth_key":
65+
if !segment.NextArg() {
66+
return svr, segment.ArgErr()
67+
}
68+
svr.AuthKey = segment.Val()
69+
case "ephemeral":
70+
svr.Ephemeral = true
71+
default:
72+
return svr, segment.Errf("unrecognized subdirective: %s", segment.Val())
73+
}
74+
}
75+
76+
return svr, nil
77+
}

caddyfile_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package tscaddy
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
8+
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
9+
"github.com/google/go-cmp/cmp"
10+
)
11+
12+
func Test_ParseApp(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
d *caddyfile.Dispenser
16+
want string
17+
authKey string
18+
wantErr bool
19+
}{
20+
{
21+
22+
name: "empty",
23+
d: caddyfile.NewTestDispenser(`
24+
tailscsale {}
25+
`),
26+
want: `{}`,
27+
},
28+
{
29+
name: "auth_key",
30+
d: caddyfile.NewTestDispenser(`
31+
tailscsale {
32+
auth_key abcdefghijklmnopqrstuvwxyz
33+
}`),
34+
want: `{"auth_key":"abcdefghijklmnopqrstuvwxyz"}`,
35+
authKey: "abcdefghijklmnopqrstuvwxyz",
36+
},
37+
{
38+
name: "ephemeral",
39+
d: caddyfile.NewTestDispenser(`
40+
tailscsale {
41+
ephemeral
42+
}`),
43+
want: `{"ephemeral":true}`,
44+
authKey: "",
45+
},
46+
{
47+
name: "missing auth key",
48+
d: caddyfile.NewTestDispenser(`
49+
tailscsale {
50+
auth_key
51+
}`),
52+
wantErr: true,
53+
},
54+
{
55+
name: "empty server",
56+
d: caddyfile.NewTestDispenser(`
57+
tailscsale {
58+
foo
59+
}`),
60+
want: `{"servers":{"foo":{}}}`,
61+
},
62+
{
63+
name: "tailscale with server",
64+
d: caddyfile.NewTestDispenser(`
65+
tailscsale {
66+
auth_key 1234567890
67+
foo {
68+
auth_key abcdefghijklmnopqrstuvwxyz
69+
}
70+
}`),
71+
want: `{"auth_key":"1234567890","servers":{"foo":{"auth_key":"abcdefghijklmnopqrstuvwxyz"}}}`,
72+
wantErr: false,
73+
authKey: "abcdefghijklmnopqrstuvwxyz",
74+
},
75+
}
76+
77+
for _, testcase := range tests {
78+
t.Run(testcase.name, func(t *testing.T) {
79+
got, err := parseApp(testcase.d, nil)
80+
if err != nil {
81+
if !testcase.wantErr {
82+
t.Errorf("parseApp() error = %v, wantErr %v", err, testcase.wantErr)
83+
return
84+
}
85+
return
86+
}
87+
if testcase.wantErr && err == nil {
88+
t.Errorf("parseApp() err = %v, wantErr %v", err, testcase.wantErr)
89+
return
90+
}
91+
gotJSON := string(got.(httpcaddyfile.App).Value)
92+
if diff := compareJSON(gotJSON, testcase.want, t); diff != "" {
93+
t.Errorf("parseApp() diff(-got +want):\n%s", diff)
94+
}
95+
app := new(TSApp)
96+
if err := json.Unmarshal([]byte(gotJSON), &app); err != nil {
97+
t.Error("failed to unmarshal json into TSApp")
98+
}
99+
})
100+
}
101+
102+
}
103+
104+
func compareJSON(s1, s2 string, t *testing.T) string {
105+
var v1, v2 map[string]any
106+
if err := json.Unmarshal([]byte(s1), &v1); err != nil {
107+
t.Error(err)
108+
}
109+
if err := json.Unmarshal([]byte(s2), &v2); err != nil {
110+
t.Error(err)
111+
}
112+
113+
return cmp.Diff(v1, v2)
114+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.22.0
44

55
require (
66
github.com/caddyserver/caddy/v2 v2.7.3
7+
github.com/google/go-cmp v0.6.0
78
go.uber.org/zap v1.26.0
89
tailscale.com v1.62.0
910
)
@@ -62,7 +63,6 @@ require (
6263
github.com/golang/snappy v0.0.4 // indirect
6364
github.com/google/btree v1.1.2 // indirect
6465
github.com/google/cel-go v0.15.1 // indirect
65-
github.com/google/go-cmp v0.6.0 // indirect
6666
github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect
6767
github.com/google/pprof v0.0.0-20230808223545-4887780b67fb // indirect
6868
github.com/google/uuid v1.5.0 // indirect

module.go

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"path"
1212
"strings"
13+
"sync/atomic"
1314

1415
"github.com/caddyserver/caddy/v2"
1516
"github.com/caddyserver/caddy/v2/caddyconfig"
@@ -22,6 +23,7 @@ import (
2223

2324
var (
2425
servers = caddy.NewUsagePool()
26+
tsapp = atomic.Pointer[TSApp]{}
2527
)
2628

2729
func init() {
@@ -47,7 +49,10 @@ func getPlainListener(_ context.Context, _ string, addr string, _ net.ListenConf
4749
network = "tcp"
4850
}
4951

50-
return s.Listen(network, ":"+port)
52+
ln := &tsnetServerDestructor{
53+
Server: s.Server,
54+
}
55+
return ln.Listen(network, ":"+port)
5156
}
5257

5358
func getTLSListener(_ context.Context, _ string, addr string, _ net.ListenConfig) (any, error) {
@@ -105,11 +110,9 @@ func getServer(_, addr string) (*tsnetServerDestructor, error) {
105110
}
106111

107112
if host != "" {
108-
// Set authkey to "TS_AUTHKEY_<HOST>". If empty,
109-
// fall back to "TS_AUTHKEY".
110-
s.AuthKey = os.Getenv("TS_AUTHKEY_" + strings.ToUpper(host))
111-
if s.AuthKey == "" {
112-
s.AuthKey = os.Getenv("TS_AUTHKEY")
113+
if app := tsapp.Load(); app != nil {
114+
s.AuthKey = getAuthKey(host, app)
115+
s.Ephemeral = getEphemeral(host, app)
113116
}
114117

115118
// Set config directory for tsnet. By default, tsnet will use the name of the
@@ -136,6 +139,39 @@ func getServer(_, addr string) (*tsnetServerDestructor, error) {
136139
return s.(*tsnetServerDestructor), nil
137140
}
138141

142+
func getAuthKey(host string, app *TSApp) string {
143+
if app == nil {
144+
return ""
145+
}
146+
svr := app.Servers[host]
147+
if svr.AuthKey != "" {
148+
return svr.AuthKey
149+
}
150+
151+
if app.DefaultAuthKey != "" {
152+
return app.DefaultAuthKey
153+
}
154+
155+
// Set authkey to "TS_AUTHKEY_<HOST>". If empty,
156+
// fall back to "TS_AUTHKEY".
157+
authKey := os.Getenv("TS_AUTHKEY_" + strings.ToUpper(host))
158+
if authKey == "" {
159+
authKey = os.Getenv("TS_AUTHKEY")
160+
}
161+
return authKey
162+
}
163+
164+
func getEphemeral(host string, app *TSApp) bool {
165+
if app == nil {
166+
return false
167+
}
168+
if svr, ok := app.Servers[host]; ok {
169+
return svr.Ephemeral
170+
}
171+
172+
return app.Ephemeral
173+
}
174+
139175
type TailscaleAuth struct {
140176
localclient *tailscale.LocalClient
141177
}
@@ -234,3 +270,31 @@ type tsnetServerDestructor struct {
234270
func (t tsnetServerDestructor) Destruct() error {
235271
return t.Close()
236272
}
273+
274+
func (t *tsnetServerDestructor) Listen(network string, addr string) (net.Listener, error) {
275+
ln, err := t.Server.Listen(network, addr)
276+
if err != nil {
277+
return nil, err
278+
}
279+
serverListener := &tsnetServerListener{
280+
hostname: t.Hostname,
281+
Listener: ln,
282+
}
283+
return serverListener, nil
284+
}
285+
286+
type tsnetServerListener struct {
287+
hostname string
288+
net.Listener
289+
}
290+
291+
func (t *tsnetServerListener) Close() error {
292+
if err := t.Listener.Close(); err != nil {
293+
return err
294+
}
295+
296+
// Decrement usage count of server for this hostname.
297+
// If usage reaches zero, then the server is actually shutdown.
298+
_, err := servers.Delete(t.hostname)
299+
return err
300+
}

0 commit comments

Comments
 (0)