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
85 changes: 83 additions & 2 deletions app.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
package tscaddy

// app.go contains TSApp and TSNode, which provide global configuration for registering Tailscale nodes.

import (
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"go.uber.org/zap"
)

func init() {
caddy.RegisterModule(TSApp{})
httpcaddyfile.RegisterGlobalOption("tailscale", parseTSApp)
}

// TSApp is the Tailscale Caddy app used to configure Tailscale nodes.
// Nodes can be used to serve sites privately on a Tailscale network,
// or to connect to other Tailnet nodes as upstream proxy backend.
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 specifies whether Tailscale nodes should be registered as ephemeral.
Ephemeral bool `json:"ephemeral,omitempty" caddy:"namespace=tailscale.ephemeral"`

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

logger *zap.Logger
}

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

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

name string
Expand All @@ -48,5 +64,70 @@ func (t *TSApp) Stop() error {
return nil
}

func parseTSApp(d *caddyfile.Dispenser, _ any) (any, error) {
app := &TSApp{
Nodes: make(map[string]TSNode),
}
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:
node, err := parseTSNode(d)
if app.Nodes == nil {
app.Nodes = map[string]TSNode{}
}
if err != nil {
return nil, err
}
app.Nodes[node.name] = node
}
}

return httpcaddyfile.App{
Name: "tailscale",
Value: caddyconfig.JSON(app, nil),
}, nil
}

func parseTSNode(d *caddyfile.Dispenser) (TSNode, error) {
name := d.Val()
segment := d.NewFromNextSegment()

if !segment.Next() {
return TSNode{}, d.ArgErr()
}

node := TSNode{name: name}
for nesting := segment.Nesting(); segment.NextBlock(nesting); {
val := segment.Val()
switch val {
case "auth_key":
if !segment.NextArg() {
return node, segment.ArgErr()
}
node.AuthKey = segment.Val()
case "ephemeral":
node.Ephemeral = true
default:
return node, segment.Errf("unrecognized subdirective: %s", segment.Val())
}
}

return node, nil
}

var _ caddy.App = (*TSApp)(nil)
var _ caddy.Provisioner = (*TSApp)(nil)
21 changes: 10 additions & 11 deletions caddyfile_test.go → app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ func Test_ParseApp(t *testing.T) {
name: "auth_key",
d: caddyfile.NewTestDispenser(`
tailscsale {
auth_key abcdefghijklmnopqrstuvwxyz
auth_key tskey-default
}`),
want: `{"auth_key":"abcdefghijklmnopqrstuvwxyz"}`,
authKey: "abcdefghijklmnopqrstuvwxyz",
want: `{"auth_key":"tskey-default"}`,
authKey: "tskey-default",
},
{
name: "ephemeral",
Expand All @@ -57,34 +57,33 @@ func Test_ParseApp(t *testing.T) {
tailscsale {
foo
}`),
want: `{"servers":{"foo":{}}}`,
want: `{"nodes":{"foo":{}}}`,
},
{
name: "tailscale with server",
d: caddyfile.NewTestDispenser(`
tailscsale {
auth_key 1234567890
auth_key tskey-default
foo {
auth_key abcdefghijklmnopqrstuvwxyz
auth_key tskey-node
}
}`),
want: `{"auth_key":"1234567890","servers":{"foo":{"auth_key":"abcdefghijklmnopqrstuvwxyz"}}}`,
want: `{"auth_key":"tskey-default","nodes":{"foo":{"auth_key":"tskey-node"}}}`,
wantErr: false,
authKey: "abcdefghijklmnopqrstuvwxyz",
authKey: "tskey-node",
},
}

for _, testcase := range tests {
t.Run(testcase.name, func(t *testing.T) {
got, err := parseApp(testcase.d, nil)
got, err := parseTSApp(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 {
} else if testcase.wantErr {
t.Errorf("parseApp() err = %v, wantErr %v", err, testcase.wantErr)
return
}
Expand Down
127 changes: 127 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package tscaddy

// auth.go contains the TailscaleAuth module and supporting logic.

import (
"fmt"
"net/http"
"strings"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)

func init() {
caddy.RegisterModule(TailscaleAuth{})
httpcaddyfile.RegisterHandlerDirective("tailscale_auth", parseAuthConfig)
}

// TailscaleAuth is an HTTP authentication provider that authenticates users based on their Tailscale identity.
// If configured on a caddy site that is listening on a tailscale node,
// that node will be used to identify the user information for inbound requests.
// Otherwise, it will attempt to find and use the local tailscaled daemon running on the system.
type TailscaleAuth struct {
localclient *tailscale.LocalClient
}

func (TailscaleAuth) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.authentication.providers.tailscale",
New: func() caddy.Module { return new(TailscaleAuth) },
}
}

// client returns the tailscale LocalClient for the TailscaleAuth module.
// If the LocalClient has not already been configured, the provided request will be used to
// lookup the tailscale node that serviced the request, and get the associated LocalClient.
func (ta *TailscaleAuth) client(r *http.Request) (*tailscale.LocalClient, error) {
if ta.localclient != nil {
return ta.localclient, nil
}

// if request was made through a tsnet listener, set up the client for the associated tsnet
// server.
server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
for _, listener := range server.Listeners() {
if tsl, ok := listener.(tsnetListener); ok {
var err error
ta.localclient, err = tsl.Server().LocalClient()
if err != nil {
return nil, err
}
}
}

if ta.localclient == nil {
// default to empty client that will talk to local tailscaled
ta.localclient = new(tailscale.LocalClient)
}

return ta.localclient, nil
}

// tsnetListener is an interface that is implemented by [tsnet.Listener].
type tsnetListener interface {
Server() *tsnet.Server
}

// Authenticate authenticates the request and sets Tailscale user data on the caddy User object.
//
// This method will set the following user metadata:
// - tailscale_login: the user's login name without the domain
// - tailscale_user: the user's full login name
// - tailscale_name: the user's display name
// - tailscale_profile_picture: the user's profile picture URL
// - tailscale_tailnet: the user's tailnet name (if the user is not connecting to a shared node)
func (ta TailscaleAuth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.User, bool, error) {
user := caddyauth.User{}

client, err := ta.client(r)
if err != nil {
return user, false, err
}

info, err := client.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
return user, false, err
}

if len(info.Node.Tags) != 0 {
return user, false, fmt.Errorf("node %s has tags", info.Node.Hostinfo.Hostname())
}

var tailnet string
if !info.Node.Hostinfo.ShareeNode() {
if s, found := strings.CutPrefix(info.Node.Name, info.Node.ComputedName+"."); found {
// TODO(will): Update this for current ts.net magicdns hostnames.
if s, found := strings.CutSuffix(s, ".beta.tailscale.net."); found {
tailnet = s
}
}
}

user.ID = info.UserProfile.LoginName
user.Metadata = map[string]string{
"tailscale_login": strings.Split(info.UserProfile.LoginName, "@")[0],
"tailscale_user": info.UserProfile.LoginName,
"tailscale_name": info.UserProfile.DisplayName,
"tailscale_profile_picture": info.UserProfile.ProfilePicURL,
"tailscale_tailnet": tailnet,
}
return user, true, nil
}

func parseAuthConfig(_ httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var ta TailscaleAuth

return caddyauth.Authentication{
ProvidersRaw: caddy.ModuleMap{
"tailscale": caddyconfig.JSON(ta, nil),
},
}, nil
}
77 changes: 0 additions & 77 deletions caddyfile.go

This file was deleted.

Loading