diff --git a/README.md b/README.md index b8a3bc6..0311d28 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,50 @@ As long as this is on a persistent volume, the auth key only needs to be provide [tag]: https://tailscale.com/kb/1068/acl-tags/ [os.UserConfigDir]: https://pkg.go.dev/os#UserConfigDir +## Registering as a Tailscale Service + +By default, golink registers as a regular tailnet node. However, you can register it as a [Tailscale Service], +which provides more stable identity and is especially useful for ephemeral infrastructure (like fly.io) +where storage may be lost. + +To register as a service: + +```bash +TS_AUTHKEY="tskey-auth-" go run ./cmd/golink -sqlitedb golink.db --register-as-service=svc:golink +``` + +Or using the environment variable: + +```bash +TS_SERVICE_NAME="svc:golink" TS_AUTHKEY="tskey-auth-" go run ./cmd/golink -sqlitedb golink.db +``` + +**Requirements:** +- The node must be tagged (e.g., `tag:golink`) +- Your ACL policy must define the service and include auto-approvers +- Services only support HTTPS on port 443 + +**Admin Capabilities in Service Mode:** + +Admin capability grants work in service mode by looking up the user's capabilities via the Tailscale daemon whois API. This means admin permissions are properly enforced based on your ACL policy, just like in regular mode. + +Example ACL configuration: + +```json +{ + "tagOwners": { + "tag:golink": ["autogroup:admin"] + }, + "autoApprovers": { + "services": { + "svc:golink": ["tag:golink"] + } + } +} +``` + +[Tailscale Service]: https://tailscale.com/kb/1534/services/ + ## Docker Compose To run golink via Docker Compose: diff --git a/golink.go b/golink.go index 284bc6f..a721d3d 100644 --- a/golink.go +++ b/golink.go @@ -34,6 +34,8 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/net/xsrftoken" "tailscale.com/client/local" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/envknob" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/tailcfg" @@ -69,6 +71,7 @@ var ( allowUnknownUsers = flag.Bool("allow-unknown-users", false, "allow unknown users to save links") readonly = flag.Bool("readonly", false, "start golink server in read-only mode") advertiseTags = flag.String("advertise-tags", os.Getenv("TS_ADVERTISE_TAGS"), "comma-separated list of ACL tags to advertise (e.g. tag:golink)") + serviceName = flag.String("register-as-service", envknob.String("TS_SERVICE_NAME"), "register as a Tailscale Service (e.g., svc:golink); requires tagged node") ) var stats struct { @@ -246,6 +249,31 @@ out: fqdn := strings.TrimSuffix(status.Self.DNSName, ".") httpHandler := serveHandler() + + // Service registration mode: use ListenService instead of standard listeners + if *serviceName != "" { + if !strings.HasPrefix(*serviceName, "svc:") { + return fmt.Errorf("service name must start with 'svc:' prefix, got: %q", *serviceName) + } + + log.Printf("Registering as Tailscale Service: %s", *serviceName) + serviceListener, err := srv.ListenService(*serviceName, tsnet.ServiceModeHTTP{ + HTTPS: true, + Port: 443, + }) + if err != nil { + if errors.Is(err, tsnet.ErrUntaggedServiceHost) { + return fmt.Errorf("service registration requires a tagged node; add a tag like 'tag:golink' to this node in the Tailscale admin console") + } + return fmt.Errorf("failed to register service: %w", err) + } + + httpsHandler := HSTS(httpHandler) + log.Printf("Serving https://%s/ as service %s ...", fqdn, *serviceName) + return http.Serve(serviceListener, httpsHandler) + } + + // Standard mode: use regular listeners if enableTLS { httpsHandler := HSTS(httpHandler) httpHandler = redirectHandler(fqdn) @@ -844,10 +872,27 @@ type user struct { // For tagged devices, the value "tagged-devices" is returned. // If the user can't be determined (such as requests coming through a subnet router), // an error is returned unless the -allow-unknown-users flag is set. +// +// When running as a Tailscale Service, authentication is handled via HTTP headers +// automatically injected by tsnet's internal proxy (Tailscale-User-Login, etc.). +// For regular mode, authentication uses WhoIs with the connection's RemoteAddr. var currentUser = func(r *http.Request) (user, error) { if devMode() { return user{login: "foo@example.com"}, nil } + + // When running as a Tailscale Service, identity headers are automatically injected + // by tsnet's internal proxy. Restrict this authentication check to cases + // when we are running in service mode, and the immediate client connection is + // on loopback. + if trustIdentityHeaders(r) { + headerUser := extractUserFromHeaders(r) + if headerUser.login != "" { + return headerUser, nil + } + } + + // Regular mode: use WhoIs with RemoteAddr whois, err := localClient.WhoIs(r.Context(), r.RemoteAddr) if err != nil { if *allowUnknownUsers { @@ -866,6 +911,63 @@ var currentUser = func(r *http.Request) (user, error) { return user{login: login}, nil } +// trustIdentityHeaders returns whether we should trust identity headers injected by tsnet's internal proxy. +var trustIdentityHeaders = func(r *http.Request) bool { + remoteHost := r.RemoteAddr + if host, _, err := net.SplitHostPort(remoteHost); err == nil { + remoteHost = host + } + remoteIP := net.ParseIP(remoteHost) + + return *serviceName != "" && remoteIP != nil && remoteIP.IsLoopback() +} + +// extractUserFromHeaders extracts the user from HTTP headers injected by tsnet's internal proxy. +var extractUserFromHeaders = func(r *http.Request) user { + if tsLogin := r.Header.Get("Tailscale-User-Login"); tsLogin != "" { + // Look for a peer from x-forwarded-for header. We'll use that for the + // whois/capmap lookup first. + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + // serve.go sets this to a single address, so we can just + // trim whitespace and use it. + ip := strings.TrimSpace(xff) + + // only accept well-formed IP addresses + if net.ParseIP(ip) == nil { + log.Printf("invalid IP in X-Forwarded-For header: %q", ip) + return user{login: tsLogin} + } + + whois, err := whoisFunc(r.Context(), ip) + + if err != nil { + log.Printf("WhoIs lookup for IP %q: %v", ip, err) + return user{login: tsLogin} + } + + caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName) + + for _, cap := range caps { + if cap.Admin { + return user{login: tsLogin, isAdmin: true} + } + } + + } + + // If we can't determine admin status, just return the user without admin privileges + // This allows the service to continue functioning even if the lookup fails + return user{login: tsLogin} + } + return user{} +} + +// whoisFunc is a variable so it can be overridden in tests. By default, it calls localClient.WhoIs. +var whoisFunc = func(ctx context.Context, ip string) (*apitype.WhoIsResponse, error) { + return localClient.WhoIs(ctx, ip) +} + // userExists returns whether a user exists with the specified login in the current tailnet. func userExists(ctx context.Context, login string) (bool, error) { const userTaggedDevices = "tagged-devices" // owner of tagged devices diff --git a/golink_test.go b/golink_test.go index e43d0c8..64d3b2b 100644 --- a/golink_test.go +++ b/golink_test.go @@ -4,6 +4,8 @@ package golink import ( + "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -15,6 +17,8 @@ import ( "github.com/google/go-cmp/cmp" "golang.org/x/net/xsrftoken" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/tailcfg" "tailscale.com/tstest" "tailscale.com/types/ptr" "tailscale.com/util/must" @@ -901,3 +905,143 @@ func TestParseAdvertiseTags(t *testing.T) { }) } } + +func TestTrustIdentityHeaders(t *testing.T) { + tests := []struct { + name string + serviceName string // value to set *serviceName to + remoteAddr string + want bool + }{ + { + name: "no service node, loopback", + serviceName: "", + remoteAddr: "127.0.0.1:1234", + want: false, + }, + { + name: "service mode, loopback", + serviceName: "svc:golink", + remoteAddr: "127.0.0.1:1234", + want: true, + }, + { + name: "service mode, non-loopback", + serviceName: "svc:golink", + remoteAddr: "100.64.1.1:1234", + want: false, + }, + { + name: "service mode, IPV6 loopback", + serviceName: "svc:golink", + remoteAddr: "[::1]:1234", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tstest.Replace(t, serviceName, tt.serviceName) + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = tt.remoteAddr + if got := trustIdentityHeaders(r); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractUserFromHeaders(t *testing.T) { + adminCapMap := tailcfg.PeerCapMap{ + peerCapName: []tailcfg.RawMessage{ + tailcfg.RawMessage(must.Get(json.Marshal(capabilities{Admin: true}))), + }, + } + noCapMap := tailcfg.PeerCapMap{} + + tests := []struct { + name string + headers map[string]string + whoisFunc func(context.Context, string) (*apitype.WhoIsResponse, error) // mock for localClient.WhoIs + wantLogin string + wantAdmin bool + }{ + { + name: "no headers", + headers: nil, + wantLogin: "", + }, + { + name: "login header only, no XFF", + headers: map[string]string{"Tailscale-User-Login": "alice@example.com"}, + wantLogin: "alice@example.com", + wantAdmin: false, + }, + { + name: "login header with XFF, peer has admin cap", + headers: map[string]string{ + "Tailscale-User-Login": "alice@example.com", + "X-Forwarded-For": "100.64.1.1", + }, + whoisFunc: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{CapMap: adminCapMap}, nil + }, + wantLogin: "alice@example.com", + wantAdmin: true, + }, + { + name: "login header with XFF, peer has no admin cap", + headers: map[string]string{ + "Tailscale-User-Login": "alice@example.com", + "X-Forwarded-For": "100.64.1.1", + }, + whoisFunc: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{CapMap: noCapMap}, nil + }, + wantLogin: "alice@example.com", + wantAdmin: false, + }, + { + name: "login header with XFF, WhoIs fails", + headers: map[string]string{ + "Tailscale-User-Login": "alice@example.com", + "X-Forwarded-For": "100.64.1.1", + }, + whoisFunc: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return nil, errors.New("peer not found") + }, + wantLogin: "alice@example.com", + wantAdmin: false, + }, + { + name: "Invalid X-Forwarded-For header", + headers: map[string]string{ + "Tailscale-User-Login": "alice@example.com", + "X-Forwarded-For": "invalid-ip", + }, + whoisFunc: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{CapMap: adminCapMap}, nil + }, + wantLogin: "alice@example.com", + wantAdmin: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.whoisFunc != nil { + tstest.Replace(t, &whoisFunc, tt.whoisFunc) + } + + r := httptest.NewRequest("GET", "/", nil) + for k, v := range tt.headers { + r.Header.Set(k, v) + } + got := extractUserFromHeaders(r) + if got.login != tt.wantLogin { + t.Errorf("login: got %q, want %q", got.login, tt.wantLogin) + } + if got.isAdmin != tt.wantAdmin { + t.Errorf("isAdmin: got %v, want %v", got.isAdmin, tt.wantAdmin) + } + }) + } +}