Skip to content

Commit 5fa047d

Browse files
committed
refactor caddy-tailscale logic
This includes: - Move TailscaleAuth logic into auth.go - Move all TSApp logic into app.go (including caddyfile parsing) - Rename "server" to "node" throughout. This aligns better with Tailscale terminology, and is reflective of the fact that nodes can also just be used as proxy transports, in which case they are not acting as servers at all. - Generally prefer referring to a node's "name" than "host". While this name is still used as the default hostname for the node, I would expect that to change with a future iteration of #18. - add godocs throughout
1 parent af99185 commit 5fa047d

File tree

7 files changed

+282
-241
lines changed

7 files changed

+282
-241
lines changed

app.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,45 @@
11
package tscaddy
22

3+
// app.go contains logic for the Tailscale Caddy app,
4+
// which provides global configuration for registering Tailscale nodes.
5+
36
import (
47
"github.com/caddyserver/caddy/v2"
8+
"github.com/caddyserver/caddy/v2/caddyconfig"
9+
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
10+
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
511
"go.uber.org/zap"
612
)
713

814
func init() {
915
caddy.RegisterModule(TSApp{})
16+
httpcaddyfile.RegisterGlobalOption("tailscale", parseTSApp)
1017
}
1118

19+
// TSApp is the Tailscale Caddy app used to configure Tailscale nodes.
20+
// Nodes can be used to serve sites privately on a Tailscale network,
21+
// or to connect to other Tailnet nodes as upstream proxy backend.
1222
type TSApp struct {
1323
// DefaultAuthKey is the default auth key to use for Tailscale if no other auth key is specified.
1424
DefaultAuthKey string `json:"auth_key,omitempty" caddy:"namespace=tailscale.auth_key"`
1525

26+
// Ephemeral specifies whether Tailscale nodes should be registered as ephemeral.
1627
Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"`
1728

18-
Servers map[string]TSServer `json:"servers,omitempty" caddy:"namespace=tailscale"`
29+
// Nodes is a map of per-node configuration which overrides global options.
30+
Nodes map[string]TSNode `json:"servers,omitempty" caddy:"namespace=tailscale"`
1931

2032
logger *zap.Logger
2133
}
2234

23-
type TSServer struct {
35+
// TSNode is a Tailscale node configuration.
36+
// A single node can be used to serve multiple sites on different domains or ports,
37+
// and/or to connect to other Tailscale nodes.
38+
type TSNode struct {
39+
// AuthKey is the Tailscale auth key used to register the node.
2440
AuthKey string `json:"auth_key,omitempty" caddy:"namespace=auth_key"`
2541

42+
// Ephemeral specifies whether the node should be registered as ephemeral.
2643
Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"`
2744

2845
name string
@@ -48,5 +65,70 @@ func (t *TSApp) Stop() error {
4865
return nil
4966
}
5067

68+
func parseTSApp(d *caddyfile.Dispenser, _ any) (any, error) {
69+
app := &TSApp{
70+
Nodes: make(map[string]TSNode),
71+
}
72+
if !d.Next() {
73+
return app, d.ArgErr()
74+
75+
}
76+
77+
for d.NextBlock(0) {
78+
val := d.Val()
79+
80+
switch val {
81+
case "auth_key":
82+
if !d.NextArg() {
83+
return nil, d.ArgErr()
84+
}
85+
app.DefaultAuthKey = d.Val()
86+
case "ephemeral":
87+
app.Ephemeral = true
88+
default:
89+
node, err := parseTSNode(d)
90+
if app.Nodes == nil {
91+
app.Nodes = map[string]TSNode{}
92+
}
93+
if err != nil {
94+
return nil, err
95+
}
96+
app.Nodes[node.name] = node
97+
}
98+
}
99+
100+
return httpcaddyfile.App{
101+
Name: "tailscale",
102+
Value: caddyconfig.JSON(app, nil),
103+
}, nil
104+
}
105+
106+
func parseTSNode(d *caddyfile.Dispenser) (TSNode, error) {
107+
name := d.Val()
108+
segment := d.NewFromNextSegment()
109+
110+
if !segment.Next() {
111+
return TSNode{}, d.ArgErr()
112+
}
113+
114+
node := TSNode{name: name}
115+
for nesting := segment.Nesting(); segment.NextBlock(nesting); {
116+
val := segment.Val()
117+
switch val {
118+
case "auth_key":
119+
if !segment.NextArg() {
120+
return node, segment.ArgErr()
121+
}
122+
node.AuthKey = segment.Val()
123+
case "ephemeral":
124+
node.Ephemeral = true
125+
default:
126+
return node, segment.Errf("unrecognized subdirective: %s", segment.Val())
127+
}
128+
}
129+
130+
return node, nil
131+
}
132+
51133
var _ caddy.App = (*TSApp)(nil)
52134
var _ caddy.Provisioner = (*TSApp)(nil)

caddyfile_test.go renamed to app_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func Test_ParseApp(t *testing.T) {
7676

7777
for _, testcase := range tests {
7878
t.Run(testcase.name, func(t *testing.T) {
79-
got, err := parseApp(testcase.d, nil)
79+
got, err := parseTSApp(testcase.d, nil)
8080
if err != nil {
8181
if !testcase.wantErr {
8282
t.Errorf("parseApp() error = %v, wantErr %v", err, testcase.wantErr)

auth.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tscaddy
2+
3+
// auth.go contains the TailscaleAuth module and supporting logic.
4+
5+
import (
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/caddyserver/caddy/v2"
11+
"github.com/caddyserver/caddy/v2/caddyconfig"
12+
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
13+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
14+
"github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth"
15+
"tailscale.com/client/tailscale"
16+
"tailscale.com/tsnet"
17+
)
18+
19+
func init() {
20+
caddy.RegisterModule(TailscaleAuth{})
21+
httpcaddyfile.RegisterHandlerDirective("tailscale_auth", parseAuthConfig)
22+
}
23+
24+
// TailscaleAuth is an HTTP authentication provider that authenticates users based on their Tailscale identity.
25+
// If configured on a caddy site that is listening on a tailscale node,
26+
// that node will be used to identify the user information for inbound requests.
27+
// Otherwise, it will attempt to find and use the local tailscaled daemon running on the system.
28+
type TailscaleAuth struct {
29+
localclient *tailscale.LocalClient
30+
}
31+
32+
func (TailscaleAuth) CaddyModule() caddy.ModuleInfo {
33+
return caddy.ModuleInfo{
34+
ID: "http.authentication.providers.tailscale",
35+
New: func() caddy.Module { return new(TailscaleAuth) },
36+
}
37+
}
38+
39+
// client returns the tailscale LocalClient for the TailscaleAuth module.
40+
// If the LocalClient has not already been configured, the provided request will be used to
41+
// lookup the tailscale node that serviced the request, and get the associated LocalClient.
42+
func (ta *TailscaleAuth) client(r *http.Request) (*tailscale.LocalClient, error) {
43+
if ta.localclient != nil {
44+
return ta.localclient, nil
45+
}
46+
47+
// if request was made through a tsnet listener, set up the client for the associated tsnet
48+
// server.
49+
server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
50+
for _, listener := range server.Listeners() {
51+
if tsl, ok := listener.(tsnetListener); ok {
52+
var err error
53+
ta.localclient, err = tsl.Server().LocalClient()
54+
if err != nil {
55+
return nil, err
56+
}
57+
}
58+
}
59+
60+
if ta.localclient == nil {
61+
// default to empty client that will talk to local tailscaled
62+
ta.localclient = new(tailscale.LocalClient)
63+
}
64+
65+
return ta.localclient, nil
66+
}
67+
68+
// tsnetListener is an interface that is implemented by [tsnet.Listener].
69+
type tsnetListener interface {
70+
Server() *tsnet.Server
71+
}
72+
73+
// Authenticate authenticates the request and sets Tailscale user data on the caddy User object.
74+
//
75+
// This method will set the following user metadata:
76+
// - tailscale_login: the user's login name without the domain
77+
// - tailscale_user: the user's full login name
78+
// - tailscale_name: the user's display name
79+
// - tailscale_profile_picture: the user's profile picture URL
80+
// - tailscale_tailnet: the user's tailnet name (if the user is not connecting to a shared node)
81+
func (ta TailscaleAuth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.User, bool, error) {
82+
user := caddyauth.User{}
83+
84+
client, err := ta.client(r)
85+
if err != nil {
86+
return user, false, err
87+
}
88+
89+
info, err := client.WhoIs(r.Context(), r.RemoteAddr)
90+
if err != nil {
91+
return user, false, err
92+
}
93+
94+
if len(info.Node.Tags) != 0 {
95+
return user, false, fmt.Errorf("node %s has tags", info.Node.Hostinfo.Hostname())
96+
}
97+
98+
var tailnet string
99+
if !info.Node.Hostinfo.ShareeNode() {
100+
if s, found := strings.CutPrefix(info.Node.Name, info.Node.ComputedName+"."); found {
101+
// TODO(will): Update this for current ts.net magicdns hostnames.
102+
if s, found := strings.CutSuffix(s, ".beta.tailscale.net."); found {
103+
tailnet = s
104+
}
105+
}
106+
}
107+
108+
user.ID = info.UserProfile.LoginName
109+
user.Metadata = map[string]string{
110+
"tailscale_login": strings.Split(info.UserProfile.LoginName, "@")[0],
111+
"tailscale_user": info.UserProfile.LoginName,
112+
"tailscale_name": info.UserProfile.DisplayName,
113+
"tailscale_profile_picture": info.UserProfile.ProfilePicURL,
114+
"tailscale_tailnet": tailnet,
115+
}
116+
return user, true, nil
117+
}
118+
119+
func parseAuthConfig(_ httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
120+
var ta TailscaleAuth
121+
122+
return caddyauth.Authentication{
123+
ProvidersRaw: caddy.ModuleMap{
124+
"tailscale": caddyconfig.JSON(ta, nil),
125+
},
126+
}, nil
127+
}

caddyfile.go

Lines changed: 0 additions & 77 deletions
This file was deleted.

0 commit comments

Comments
 (0)