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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor wording/capitalization: “fly.io” is a proper name and is typically capitalized as “Fly.io”.

Suggested change
which provides more stable identity and is especially useful for ephemeral infrastructure (like fly.io)
which provides more stable identity and is especially useful for ephemeral infrastructure (like Fly.io)

Copilot uses AI. Check for mistakes.
where storage may be lost.

To register as a service:

```bash
TS_AUTHKEY="tskey-auth-<key>" 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-<key>" 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
Comment on lines +78 to +81
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service-mode prerequisites don’t mention how to actually apply the tag for a tsnet node (golink advertises tags via --advertise-tags / TS_ADVERTISE_TAGS). Consider adding that requirement here so readers can make the node “tagged” without hunting through flags/source.

Copilot uses AI. Check for mistakes.

**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.

Comment thread
offbyone marked this conversation as resolved.
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:
Expand Down
102 changes: 102 additions & 0 deletions golink.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Comment thread
mikeodr marked this conversation as resolved.
}
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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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}
}
Comment thread
offbyone marked this conversation as resolved.
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
Expand Down
144 changes: 144 additions & 0 deletions golink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package golink

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
},
Comment thread
mikeodr marked this conversation as resolved.
{
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,
},
Comment thread
mikeodr marked this conversation as resolved.
{
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)
}
})
}
}
Loading