Skip to content

Commit

Permalink
refactor caddy-tailscale logic
Browse files Browse the repository at this point in the history
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

Signed-off-by: Will Norris <will@tailscale.com>
  • Loading branch information
willnorris committed May 8, 2024
1 parent af99185 commit 37f4301
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 241 deletions.
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)
2 changes: 1 addition & 1 deletion caddyfile_test.go → app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func Test_ParseApp(t *testing.T) {

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)
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

0 comments on commit 37f4301

Please sign in to comment.