Skip to content

Commit a61e32a

Browse files
authored
feat: add workload identity federation and dual-port tsnet serve (#161)
1 parent 19c8668 commit a61e32a

4 files changed

Lines changed: 105 additions & 20 deletions

File tree

README.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,42 @@ TSFlow can embed a Tailscale node and serve itself directly on your tailnet, eli
9494
| `TSFLOW_FUNNEL` | Expose via Tailscale Funnel | `false` |
9595
| `TSFLOW_STATE_DIR` | tsnet state persistence directory | `./data/tsnet-state` |
9696

97+
##### Workload Identity Federation
98+
99+
tsnet mode supports [workload identity federation](https://tailscale.com/kb/1236/workload-identity) as an alternative to OAuth secrets. This lets tsflow authenticate using platform identity (AWS, GCP, GitHub Actions, Azure) without managing secrets.
100+
101+
| Variable | Description | Default |
102+
|----------|-------------|---------|
103+
| `TS_CLIENT_ID` | Federated client ID | - |
104+
| `TS_ID_TOKEN` | ID token from identity provider | - |
105+
| `TS_AUDIENCE` | Audience for requesting platform tokens | - |
106+
107+
When `TS_CLIENT_ID` is set, tsflow uses WIF instead of OAuth `ClientSecret` for the tsnet node. The platform token is auto-detected from the runtime environment. Set either `TS_ID_TOKEN` or `TS_AUDIENCE`, not both. You must also set `TSFLOW_TAGS`.
108+
109+
> **Note:** OAuth credentials (`TAILSCALE_OAUTH_CLIENT_ID` and `TAILSCALE_OAUTH_CLIENT_SECRET`) are still required for Tailscale API access (fetching devices, network logs). WIF only replaces the tsnet node authentication secret.
110+
97111
**Requirements:**
98-
- OAuth credentials (API keys are not supported in tsnet mode)
99-
- ACL tags must be allowed for the OAuth client to register nodes
112+
- OAuth credentials or workload identity federation (API keys are not supported in tsnet mode)
113+
- ACL tags must be allowed for the OAuth client or federated identity to register nodes
100114
- For Funnel, the ACL must grant funnel access to the tag
101115

102-
**Example:**
116+
**tsnet mode serves on both port 80 (HTTP) and port 443 (HTTPS).**
117+
118+
**Example with OAuth:**
119+
120+
```bash
121+
docker run -d \
122+
--name tsflow \
123+
-v tsflow_data:/app/data \
124+
-e TAILSCALE_OAUTH_CLIENT_ID=your-client-id \
125+
-e TAILSCALE_OAUTH_CLIENT_SECRET=your-client-secret \
126+
-e TSFLOW_SERVE=true \
127+
-e TSFLOW_HOSTNAME=tsflow \
128+
-e TSFLOW_TAGS=tag:tsflow \
129+
ghcr.io/rajsinghtech/tsflow:latest
130+
```
131+
132+
**Example with Workload Identity (GCP):**
103133

104134
```bash
105135
docker run -d \
@@ -110,10 +140,12 @@ docker run -d \
110140
-e TSFLOW_SERVE=true \
111141
-e TSFLOW_HOSTNAME=tsflow \
112142
-e TSFLOW_TAGS=tag:tsflow \
143+
-e TS_CLIENT_ID=your-federated-client-id \
144+
-e TS_AUDIENCE=your-tailnet.org \
113145
ghcr.io/rajsinghtech/tsflow:latest
114146
```
115147

116-
TSFlow will be accessible at `https://tsflow.<your-tailnet>.ts.net` with automatic HTTPS certificates.
148+
TSFlow will be accessible at both `https://tsflow.<your-tailnet>.ts.net` and `http://tsflow.<your-tailnet>.ts.net`.
117149

118150
#### Data Storage & Polling
119151

backend/internal/config/config.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type Config struct {
2525
TsnetTags []string
2626
TsnetFunnel bool
2727
TsnetStateDir string
28+
// tsnet workload identity federation
29+
TsnetClientID string
30+
TsnetIDToken string
31+
TsnetAudience string
2832
}
2933

3034
// Load loads configuration from environment variables
@@ -45,13 +49,17 @@ func Load() *Config {
4549
TsnetTags: parseTags(os.Getenv("TSFLOW_TAGS")),
4650
TsnetFunnel: os.Getenv("TSFLOW_FUNNEL") == "true",
4751
TsnetStateDir: getEnvWithDefault("TSFLOW_STATE_DIR", filepath.Join(".", "data", "tsnet-state")),
52+
TsnetClientID: os.Getenv("TS_CLIENT_ID"),
53+
TsnetIDToken: os.Getenv("TS_ID_TOKEN"),
54+
TsnetAudience: os.Getenv("TS_AUDIENCE"),
4855
}
4956
}
5057

5158
// Validate validates the configuration
5259
func (c *Config) Validate() error {
5360
hasAPIKey := c.TailscaleAPIKey != ""
5461
hasOAuth := c.TailscaleOAuthClientID != "" && c.TailscaleOAuthClientSecret != ""
62+
hasWIF := c.TsnetClientID != ""
5563

5664
if !hasAPIKey && !hasOAuth {
5765
return errors.New("either TAILSCALE_API_KEY or both TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET must be provided")
@@ -61,8 +69,21 @@ func (c *Config) Validate() error {
6169
log.Println("Both API key and OAuth credentials provided. OAuth will take precedence.")
6270
}
6371

64-
if c.TsnetServe && !hasOAuth {
65-
return errors.New("TSFLOW_SERVE=true requires OAuth credentials (TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET)")
72+
if c.TsnetServe {
73+
if !hasOAuth && !hasWIF {
74+
return errors.New("TSFLOW_SERVE=true requires either OAuth credentials or workload identity federation (TS_CLIENT_ID)")
75+
}
76+
if hasWIF {
77+
if c.TsnetIDToken == "" && c.TsnetAudience == "" {
78+
return errors.New("workload identity federation requires TS_ID_TOKEN or TS_AUDIENCE")
79+
}
80+
if c.TsnetIDToken != "" && c.TsnetAudience != "" {
81+
return errors.New("only one of TS_ID_TOKEN or TS_AUDIENCE should be set for workload identity federation")
82+
}
83+
if len(c.TsnetTags) == 0 {
84+
return errors.New("workload identity federation requires TSFLOW_TAGS to be set")
85+
}
86+
}
6687
}
6788

6889
return nil

backend/internal/tsnetserve/tsnetserve.go

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import (
1212
)
1313

1414
type Server struct {
15-
tsServer *tsnet.Server
16-
listener net.Listener
15+
tsServer *tsnet.Server
16+
tlsListener net.Listener
17+
httpListener net.Listener
1718
}
1819

1920
func New(ctx context.Context, cfg *config.Config) (*Server, error) {
@@ -24,43 +25,62 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) {
2425
srv := &tsnet.Server{
2526
Dir: cfg.TsnetStateDir,
2627
Hostname: cfg.TsnetHostname,
27-
ClientSecret: cfg.TailscaleOAuthClientSecret,
2828
Ephemeral: true,
2929
AdvertiseTags: cfg.TsnetTags,
3030
}
3131

32+
if cfg.TsnetClientID != "" {
33+
srv.ClientID = cfg.TsnetClientID
34+
srv.IDToken = cfg.TsnetIDToken
35+
srv.Audience = cfg.TsnetAudience
36+
} else {
37+
srv.ClientSecret = cfg.TailscaleOAuthClientSecret
38+
}
39+
3240
if _, err := srv.Up(ctx); err != nil {
3341
return nil, fmt.Errorf("tsnet up: %w", err)
3442
}
3543

36-
var ln net.Listener
44+
var tlsLn, httpLn net.Listener
3745
var err error
46+
3847
if cfg.TsnetFunnel {
39-
ln, err = srv.ListenFunnel("tcp", ":443")
48+
tlsLn, err = srv.ListenFunnel("tcp", ":443")
4049
} else {
41-
ln, err = srv.ListenTLS("tcp", ":443")
50+
tlsLn, err = srv.ListenTLS("tcp", ":443")
4251
}
4352
if err != nil {
4453
srv.Close()
45-
return nil, fmt.Errorf("tsnet listen: %w", err)
54+
return nil, fmt.Errorf("tsnet listen TLS: %w", err)
55+
}
56+
57+
httpLn, err = srv.Listen("tcp", ":80")
58+
if err != nil {
59+
tlsLn.Close()
60+
srv.Close()
61+
return nil, fmt.Errorf("tsnet listen HTTP: %w", err)
4662
}
4763

4864
mode := "tailnet"
4965
if cfg.TsnetFunnel {
5066
mode = "funnel"
5167
}
5268
if domains := srv.CertDomains(); len(domains) > 0 {
53-
log.Printf("tsnet: serving via %s at https://%s", mode, domains[0])
69+
log.Printf("tsnet: serving via %s at https://%s (443) and http://%s (80)", mode, domains[0], domains[0])
5470
}
5571

56-
return &Server{tsServer: srv, listener: ln}, nil
72+
return &Server{tsServer: srv, tlsListener: tlsLn, httpListener: httpLn}, nil
5773
}
5874

59-
func (s *Server) Listener() net.Listener { return s.listener }
75+
func (s *Server) TLSListener() net.Listener { return s.tlsListener }
76+
func (s *Server) HTTPListener() net.Listener { return s.httpListener }
6077

6178
func (s *Server) Close() error {
62-
if s.listener != nil {
63-
s.listener.Close()
79+
if s.httpListener != nil {
80+
s.httpListener.Close()
81+
}
82+
if s.tlsListener != nil {
83+
s.tlsListener.Close()
6484
}
6585
return s.tsServer.Close()
6686
}

backend/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ func main() {
248248
if len(cfg.TsnetTags) > 0 {
249249
log.Printf("Tags: %v", cfg.TsnetTags)
250250
}
251+
if cfg.TsnetClientID != "" {
252+
log.Printf("tsnet auth: workload identity federation (TS_CLIENT_ID)")
253+
} else {
254+
log.Printf("tsnet auth: OAuth client secret")
255+
}
251256
log.Printf("Funnel: %v", cfg.TsnetFunnel)
252257
} else {
253258
log.Printf("Server ready at http://0.0.0.0:%s", port)
@@ -266,10 +271,16 @@ func main() {
266271
log.Fatalf("Failed to start tsnet server: %v", err)
267272
}
268273

274+
tlsSrv := &http.Server{Handler: router}
269275
httpSrv := &http.Server{Handler: router}
270276
go func() {
271-
if err := httpSrv.Serve(tsnetSrv.Listener()); err != nil && err != http.ErrServerClosed {
272-
log.Fatalf("FATAL tsnet serve failed: %v", err)
277+
if err := tlsSrv.Serve(tsnetSrv.TLSListener()); err != nil && err != http.ErrServerClosed {
278+
log.Fatalf("FATAL tsnet TLS serve failed: %v", err)
279+
}
280+
}()
281+
go func() {
282+
if err := httpSrv.Serve(tsnetSrv.HTTPListener()); err != nil && err != http.ErrServerClosed {
283+
log.Fatalf("FATAL tsnet HTTP serve failed: %v", err)
273284
}
274285
}()
275286

@@ -278,6 +289,7 @@ func main() {
278289

279290
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
280291
defer shutdownCancel()
292+
tlsSrv.Shutdown(shutdownCtx)
281293
httpSrv.Shutdown(shutdownCtx)
282294
tsnetSrv.Close()
283295
} else {

0 commit comments

Comments
 (0)