Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
{
order tailscale_auth after basicauth
tailscale {
auth_key "tskey-auth-"

caddy {
auth_key "tskey-auth-caddy"
}
}
}

:80 {
Expand Down
45 changes: 45 additions & 0 deletions app.go
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"`
Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

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.


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)
77 changes: 77 additions & 0 deletions caddyfile.go
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
}
114 changes: 114 additions & 0 deletions caddyfile_test.go
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)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.22.0

require (
github.com/caddyserver/caddy/v2 v2.7.3
github.com/google/go-cmp v0.6.0
go.uber.org/zap v1.26.0
tailscale.com v1.62.0
)
Expand Down Expand Up @@ -62,7 +63,6 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/cel-go v0.15.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect
github.com/google/pprof v0.0.0-20230808223545-4887780b67fb // indirect
github.com/google/uuid v1.5.0 // indirect
Expand Down
76 changes: 70 additions & 6 deletions module.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path"
"strings"
"sync/atomic"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
Expand All @@ -22,6 +23,7 @@ import (

var (
servers = caddy.NewUsagePool()
tsapp = atomic.Pointer[TSApp]{}
)

func init() {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
}
Loading