|
| 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 | +} |
0 commit comments