-
Notifications
You must be signed in to change notification settings - Fork 80
Support tailscale configuration via Caddy configuration #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ded0324
00def93
31abd2b
733f7ea
4bb0252
17f85e5
ffef646
9fc8b14
f6a9281
d74f857
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package tscaddy | ||
|
|
||
| import ( | ||
| "github.com/caddyserver/caddy/v2" | ||
| ) | ||
|
|
||
| func init() { | ||
| caddy.RegisterModule(TSApp{}) | ||
| } | ||
|
|
||
| type TSApp struct { | ||
| // DefaultAuthKey is the default auth key to use for Tailscale if no other auth key is specified. | ||
| DefaultAuthKey string `json:"auth_key,omitempty" caddy:"namespace=tailscale.auth_key"` | ||
|
|
||
| Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"` | ||
|
|
||
| Servers map[string]TSServer `json:"servers,omitempty" caddy:"namespace=tailscale"` | ||
| } | ||
|
|
||
| type TSServer struct { | ||
| AuthKey string `json:"auth_key,omitempty" caddy:"namespace=auth_key"` | ||
|
|
||
| Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"` | ||
|
|
||
| name string | ||
| } | ||
|
|
||
| func (TSApp) CaddyModule() caddy.ModuleInfo { | ||
| return caddy.ModuleInfo{ | ||
| ID: "tailscale", | ||
| New: func() caddy.Module { return new(TSApp) }, | ||
| } | ||
| } | ||
|
|
||
| func (t *TSApp) Start() error { | ||
| tsapp.Store(t) | ||
| return nil | ||
| } | ||
|
|
||
| func (t *TSApp) Stop() error { | ||
| tsapp.CompareAndSwap(t, nil) | ||
| return nil | ||
| } | ||
|
|
||
| var _ caddy.App = (*TSApp)(nil) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package tscaddy | ||
|
|
||
| import ( | ||
| "github.com/caddyserver/caddy/v2/caddyconfig" | ||
| "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" | ||
| "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" | ||
| ) | ||
|
|
||
| func init() { | ||
| httpcaddyfile.RegisterGlobalOption("tailscale", parseApp) | ||
| } | ||
|
|
||
| func parseApp(d *caddyfile.Dispenser, _ any) (any, error) { | ||
| app := &TSApp{ | ||
| Servers: make(map[string]TSServer), | ||
| } | ||
| if !d.Next() { | ||
| return app, d.ArgErr() | ||
|
|
||
| } | ||
|
|
||
| for d.NextBlock(0) { | ||
| val := d.Val() | ||
|
|
||
| switch val { | ||
| case "auth_key": | ||
| if !d.NextArg() { | ||
| return nil, d.ArgErr() | ||
| } | ||
| app.DefaultAuthKey = d.Val() | ||
| case "ephemeral": | ||
| app.Ephemeral = true | ||
| default: | ||
| svr, err := parseServer(d) | ||
| if app.Servers == nil { | ||
| app.Servers = map[string]TSServer{} | ||
| } | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| app.Servers[svr.name] = svr | ||
| } | ||
| } | ||
|
|
||
| return httpcaddyfile.App{ | ||
| Name: "tailscale", | ||
| Value: caddyconfig.JSON(app, nil), | ||
| }, nil | ||
| } | ||
|
|
||
| func parseServer(d *caddyfile.Dispenser) (TSServer, error) { | ||
| name := d.Val() | ||
| segment := d.NewFromNextSegment() | ||
|
|
||
| if !segment.Next() { | ||
| return TSServer{}, d.ArgErr() | ||
| } | ||
|
|
||
| svr := TSServer{} | ||
| svr.name = name | ||
| for nesting := segment.Nesting(); segment.NextBlock(nesting); { | ||
| val := segment.Val() | ||
| switch val { | ||
| case "auth_key": | ||
| if !segment.NextArg() { | ||
| return svr, segment.ArgErr() | ||
| } | ||
| svr.AuthKey = segment.Val() | ||
| case "ephemeral": | ||
| svr.Ephemeral = true | ||
| default: | ||
| return svr, segment.Errf("unrecognized subdirective: %s", segment.Val()) | ||
| } | ||
| } | ||
|
|
||
| return svr, nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package tscaddy | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" | ||
| "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" | ||
| "github.com/google/go-cmp/cmp" | ||
| ) | ||
|
|
||
| func Test_ParseApp(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| d *caddyfile.Dispenser | ||
| want string | ||
| authKey string | ||
| wantErr bool | ||
| }{ | ||
| { | ||
|
|
||
| name: "empty", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale {} | ||
| `), | ||
| want: `{}`, | ||
| }, | ||
| { | ||
| name: "auth_key", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale { | ||
| auth_key abcdefghijklmnopqrstuvwxyz | ||
| }`), | ||
| want: `{"auth_key":"abcdefghijklmnopqrstuvwxyz"}`, | ||
| authKey: "abcdefghijklmnopqrstuvwxyz", | ||
| }, | ||
| { | ||
| name: "ephemeral", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale { | ||
| ephemeral | ||
| }`), | ||
| want: `{"ephemeral":true}`, | ||
| authKey: "", | ||
| }, | ||
| { | ||
| name: "missing auth key", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale { | ||
| auth_key | ||
| }`), | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "empty server", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale { | ||
| foo | ||
| }`), | ||
| want: `{"servers":{"foo":{}}}`, | ||
| }, | ||
| { | ||
| name: "tailscale with server", | ||
| d: caddyfile.NewTestDispenser(` | ||
| tailscsale { | ||
| auth_key 1234567890 | ||
| foo { | ||
| auth_key abcdefghijklmnopqrstuvwxyz | ||
| } | ||
| }`), | ||
| want: `{"auth_key":"1234567890","servers":{"foo":{"auth_key":"abcdefghijklmnopqrstuvwxyz"}}}`, | ||
| wantErr: false, | ||
| authKey: "abcdefghijklmnopqrstuvwxyz", | ||
| }, | ||
| } | ||
|
|
||
| for _, testcase := range tests { | ||
| t.Run(testcase.name, func(t *testing.T) { | ||
| got, err := parseApp(testcase.d, nil) | ||
| if err != nil { | ||
| if !testcase.wantErr { | ||
| t.Errorf("parseApp() error = %v, wantErr %v", err, testcase.wantErr) | ||
| return | ||
| } | ||
| return | ||
| } | ||
| if testcase.wantErr && err == nil { | ||
| t.Errorf("parseApp() err = %v, wantErr %v", err, testcase.wantErr) | ||
| return | ||
| } | ||
| gotJSON := string(got.(httpcaddyfile.App).Value) | ||
| if diff := compareJSON(gotJSON, testcase.want, t); diff != "" { | ||
| t.Errorf("parseApp() diff(-got +want):\n%s", diff) | ||
| } | ||
| app := new(TSApp) | ||
| if err := json.Unmarshal([]byte(gotJSON), &app); err != nil { | ||
| t.Error("failed to unmarshal json into TSApp") | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| } | ||
|
|
||
| func compareJSON(s1, s2 string, t *testing.T) string { | ||
| var v1, v2 map[string]any | ||
| if err := json.Unmarshal([]byte(s1), &v1); err != nil { | ||
| t.Error(err) | ||
| } | ||
| if err := json.Unmarshal([]byte(s2), &v2); err != nil { | ||
| t.Error(err) | ||
| } | ||
|
|
||
| return cmp.Diff(v1, v2) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import ( | |
| "os" | ||
| "path" | ||
| "strings" | ||
| "sync/atomic" | ||
|
|
||
| "github.com/caddyserver/caddy/v2" | ||
| "github.com/caddyserver/caddy/v2/caddyconfig" | ||
|
|
@@ -22,6 +23,7 @@ import ( | |
|
|
||
| var ( | ||
| servers = caddy.NewUsagePool() | ||
| tsapp = atomic.Pointer[TSApp]{} | ||
| ) | ||
|
|
||
| func init() { | ||
|
|
@@ -47,7 +49,10 @@ func getPlainListener(_ context.Context, _ string, addr string, _ net.ListenConf | |
| network = "tcp" | ||
| } | ||
|
|
||
| return s.Listen(network, ":"+port) | ||
| ln := &tsnetServerDestructor{ | ||
| Server: s.Server, | ||
| } | ||
| return ln.Listen(network, ":"+port) | ||
| } | ||
|
|
||
| func getTLSListener(_ context.Context, _ string, addr string, _ net.ListenConfig) (any, error) { | ||
|
|
@@ -105,11 +110,9 @@ func getServer(_, addr string) (*tsnetServerDestructor, error) { | |
| } | ||
|
|
||
| if host != "" { | ||
| // Set authkey to "TS_AUTHKEY_<HOST>". If empty, | ||
| // fall back to "TS_AUTHKEY". | ||
| s.AuthKey = os.Getenv("TS_AUTHKEY_" + strings.ToUpper(host)) | ||
| if s.AuthKey == "" { | ||
| s.AuthKey = os.Getenv("TS_AUTHKEY") | ||
| if app := tsapp.Load(); app != nil { | ||
| s.AuthKey = getAuthKey(host, app) | ||
| s.Ephemeral = getEphemeral(host, app) | ||
| } | ||
|
|
||
| // Set config directory for tsnet. By default, tsnet will use the name of the | ||
|
|
@@ -136,6 +139,39 @@ func getServer(_, addr string) (*tsnetServerDestructor, error) { | |
| return s.(*tsnetServerDestructor), nil | ||
| } | ||
|
|
||
| func getAuthKey(host string, app *TSApp) string { | ||
| if app == nil { | ||
| return "" | ||
| } | ||
| svr := app.Servers[host] | ||
| if svr.AuthKey != "" { | ||
| return svr.AuthKey | ||
| } | ||
|
|
||
| if app.DefaultAuthKey != "" { | ||
| return app.DefaultAuthKey | ||
| } | ||
|
|
||
| // Set authkey to "TS_AUTHKEY_<HOST>". If empty, | ||
| // fall back to "TS_AUTHKEY". | ||
| authKey := os.Getenv("TS_AUTHKEY_" + strings.ToUpper(host)) | ||
| if authKey == "" { | ||
| authKey = os.Getenv("TS_AUTHKEY") | ||
| } | ||
| return authKey | ||
| } | ||
|
|
||
| func getEphemeral(host string, app *TSApp) bool { | ||
| if app == nil { | ||
| return false | ||
| } | ||
| if svr, ok := app.Servers[host]; ok { | ||
| return svr.Ephemeral | ||
| } | ||
|
|
||
| return app.Ephemeral | ||
| } | ||
|
|
||
| type TailscaleAuth struct { | ||
| localclient *tailscale.LocalClient | ||
| } | ||
|
|
@@ -234,3 +270,31 @@ type tsnetServerDestructor struct { | |
| func (t tsnetServerDestructor) Destruct() error { | ||
| return t.Close() | ||
| } | ||
|
|
||
| func (t *tsnetServerDestructor) Listen(network string, addr string) (net.Listener, error) { | ||
| ln, err := t.Server.Listen(network, addr) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| serverListener := &tsnetServerListener{ | ||
| hostname: t.Hostname, | ||
| Listener: ln, | ||
| } | ||
| return serverListener, nil | ||
| } | ||
|
|
||
| type tsnetServerListener struct { | ||
| hostname string | ||
| net.Listener | ||
| } | ||
|
|
||
| func (t *tsnetServerListener) Close() error { | ||
| if err := t.Listener.Close(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // Decrement usage count of server for this hostname. | ||
| // If usage reaches zero, then the server is actually shutdown. | ||
| _, err := servers.Delete(t.hostname) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This definitely confused me at first. It looks like it will destruct the server on the first listener to close. I didn't realize this is really just a counter decrement on the usage pool. Maybe add a comment here that this decrements the usage pool for the server, and will close it only when it reaches zero? |
||
| return err | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've honestly never used these caddy struct tags before. I guess they're used in doc generation? This seems to be the only one that doesn't have "tailscale." in the namespace. Is that intentional?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was, I think this is the translation for marshaling the caddy file format or going to/from JSON. It was modeled on the dynamicdns caddy app.