Skip to content

feat: add support for registering as a tailscale service host#215

Merged
mikeodr merged 1 commit into
tailscale:mainfrom
offbyone:push-ykquzkqqwwnn
May 5, 2026
Merged

feat: add support for registering as a tailscale service host#215
mikeodr merged 1 commit into
tailscale:mainfrom
offbyone:push-ykquzkqqwwnn

Conversation

@offbyone
Copy link
Copy Markdown
Contributor

Optionally register as a tailcale service host, instead of as an HTTP tsnet host.

Notes:

  • uses x-forwarded-for for the remote IP, since we don't have the peer IP from the request (there's an internal proxy in place)

Fixes: #214

@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch 2 times, most recently from 375184f to 00cf167 Compare February 22, 2026 15:17
@offbyone
Copy link
Copy Markdown
Contributor Author

If I try downgrading to 1.25.4 as specified here, I get this:

$ go mod tidy
go: tailscale.com@v1.94.2 requires go >= 1.25.5; switching to go1.25.7
go: downloading go1.25.7 (darwin/arm64)

So, I think there is something to be said for upgrading the go version here too.

@mikeodr
Copy link
Copy Markdown
Contributor

mikeodr commented Mar 13, 2026

Once #221 is merged feel free to rebase.

@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch from 0cf0659 to 7902f92 Compare April 16, 2026 15:47
@offbyone
Copy link
Copy Markdown
Contributor Author

Once #221 is merged feel free to rebase.

Done! Sorry, I didn't see this til just now.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional mode for running golink as a Tailscale Service (svc:*) rather than a regular tsnet node, to provide a more stable identity in ephemeral environments.

Changes:

  • Introduces -register-as-service / TS_SERVICE_NAME to register via tsnet.Server.ListenService and serve HTTPS on :443.
  • Adds service-mode request identity handling based on tsnet-injected HTTP headers (Tailscale-User-Login, X-Forwarded-For).
  • Documents setup and ACL requirements for service registration in the README.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
golink.go Adds service registration flow and service-mode user/admin identity detection logic.
README.md Documents how to run golink as a Tailscale Service and provides an ACL example.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread golink.go Outdated
Comment on lines +943 to +969
// by tsnet's internal proxy. Check for these headers first.
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 != "" {
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := localClient.WhoIs(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}

caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}, nil
}
}
}

}

// 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}, nil
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

currentUser trusts the Tailscale-User-Login (and X-Forwarded-For) headers whenever they’re present. In non-service mode, any client can supply these headers and bypass the normal WhoIs(RemoteAddr) identity lookup, potentially impersonating another user.

Tangible fix: only honor these identity headers when actually running in service mode (e.g., gate this block on *serviceName != "" or a dedicated runningAsService boolean set when ListenService is used), and ideally also verify the request originates from the local tsnet proxy (e.g., RemoteAddr is loopback) before trusting the headers.

Suggested change
// by tsnet's internal proxy. Check for these headers first.
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 != "" {
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := localClient.WhoIs(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}
caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}, nil
}
}
}
}
// 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}, nil
// by tsnet's internal proxy. Only trust those headers when we are actually
// running in service mode and the immediate client connection is loopback.
remoteHost := r.RemoteAddr
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteHost = host
}
remoteIP := net.ParseIP(remoteHost)
trustIdentityHeaders := *serviceName != "" && remoteIP != nil && remoteIP.IsLoopback()
if trustIdentityHeaders {
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 != "" {
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := localClient.WhoIs(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}
caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}, nil
}
}
}
}
// If we can't determine admin status, just return the user without admin privileges.
// This path is only trusted for requests from the local tsnet service proxy.
return user{login: tsLogin}, nil
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue(blocking): yeah concur with the bot on this one.

Comment thread golink.go Outdated
Comment thread README.md
Comment thread golink.go
Copy link
Copy Markdown
Contributor

@mikeodr mikeodr left a comment

Choose a reason for hiding this comment

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

Thanks for the rebase and the reminder.

Can some tests also be added to help cover the new code if at all possible?

Thanks.

Comment thread golink.go Outdated
Comment thread golink.go Outdated
Comment thread golink.go Outdated
Comment thread golink.go Outdated
Comment on lines +943 to +969
// by tsnet's internal proxy. Check for these headers first.
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 != "" {
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := localClient.WhoIs(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}

caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}, nil
}
}
}

}

// 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}, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue(blocking): yeah concur with the bot on this one.

@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch from 7902f92 to 3a22ee6 Compare April 16, 2026 22:37
@offbyone
Copy link
Copy Markdown
Contributor Author

It took a nontrivial bit of testability refactoring, but here you go.

Comment thread golink.go Outdated
httpsHandler := HSTS(httpHandler)
log.Printf("Serving https://%s/ as service %s ...", fqdn, *serviceName)
if err := http.Serve(serviceListener, httpsHandler); err != nil {
log.Fatal(err)
Copy link
Copy Markdown
Contributor

@mikeodr mikeodr Apr 20, 2026

Choose a reason for hiding this comment

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

issue: TLS http.Serve below returns the error instead of fatal. Can this be made consistent?

Suggested change
log.Fatal(err)
return err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still needs a fix here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I completely missed this comment. My bad.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread golink.go
Comment thread golink.go Outdated
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
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.

When parsing X-Forwarded-For, ip can end up empty (e.g. trailing comma) or non-IP tokens (some proxies emit unknown). Today those values get passed to WhoIs, causing avoidable lookups/errors and potentially missing a later valid IP. Skip empty/invalid entries before calling whoisFunc.

Suggested change
ip = strings.TrimSpace(ip)
ip = strings.TrimSpace(ip)
if ip == "" || net.ParseIP(ip) == nil {
continue
}

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +78 to +81
**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
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.
Comment thread README.md
## 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.
Comment thread golink.go Outdated
Comment on lines +935 to +950
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := whoisFunc(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}

caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
for ip := range strings.SplitSeq(xff, ",") {
ip = strings.TrimSpace(ip)
whois, err := whoisFunc(r.Context(), ip)
if err != nil {
// if this lookup fails, try the next x-forwarded-for IP
continue
}
caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}
}
}
}
ip = strings.TrimSpace(xff)
whois, err := whoisFunc(r.Context(), ip)
caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName)
for _, cap := range caps {
if cap.Admin {
return user{login: tsLogin, isAdmin: true}
}
}

change: this doesn't need to be a loop. serve.go just does an explicit set overwriting anything so it should only contain one item:

  // serve.go:1042
  func addProxyForwardedHeaders(r *httputil.ProxyRequest) {
      r.Out.Header.Set("X-Forwarded-For", c.SrcAddr.Addr().String())
  }

While good defensively I don't think the extra loop is needed.

Comment thread golink_test.go
Comment thread golink_test.go Outdated
name: "multiple XFF IPs, first fails, second has admin",
headers: map[string]string{
"Tailscale-User-Login": "alice@example.com",
"X-Forwarded-For": "192.168.1.1, 100.64.1.1",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

per other comment above regarding the header set logic, I don't think this is necessary.

Copy link
Copy Markdown
Contributor

@mikeodr mikeodr left a comment

Choose a reason for hiding this comment

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

Thanks ❤️, some small updates left

@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch from fa4cc3a to 9e35af4 Compare April 30, 2026 05:41
@offbyone offbyone requested a review from mikeodr April 30, 2026 05:41
Copy link
Copy Markdown
Contributor

@mikeodr mikeodr left a comment

Choose a reason for hiding this comment

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

One minor remainder.

Comment thread golink.go Outdated
httpsHandler := HSTS(httpHandler)
log.Printf("Serving https://%s/ as service %s ...", fqdn, *serviceName)
if err := http.Serve(serviceListener, httpsHandler); err != nil {
log.Fatal(err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still needs a fix here.

@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch from 9e35af4 to c88e4cd Compare May 2, 2026 16:58
@offbyone offbyone requested a review from mikeodr May 2, 2026 16:58
Copy link
Copy Markdown
Contributor

@mikeodr mikeodr left a comment

Choose a reason for hiding this comment

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

Thanks, just two more things after re-review.

Appreciate your patience and updates.

Comment thread golink.go Outdated
Comment thread golink_test.go
Optionally register as a tailcale service host, instead of as an HTTP tsnet host.

Notes:

- uses x-forwarded-for for the remote IP, since we don't have the peer IP from the request (there's an internal proxy in place)

Fixes: tailscale#214
Signed-off-by: Chris Rose <offline@offby1.net>
@offbyone offbyone force-pushed the push-ykquzkqqwwnn branch from c88e4cd to e40074f Compare May 4, 2026 21:34
@offbyone
Copy link
Copy Markdown
Contributor Author

offbyone commented May 4, 2026

Done; I added an extra IP check as well, since it turns out that the test mocking in the test table actually masked the expected failure when the IP address was invalid.

@offbyone offbyone requested a review from mikeodr May 4, 2026 21:45
@mikeodr mikeodr merged commit b85eec3 into tailscale:main May 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support registering golink as a tsnet service

3 participants