HTTP reverse proxy for Tailscale

implement oidc funnel handler

+95 -8
+3
go.mod
··· 56 56 github.com/klauspost/compress v1.17.11 // indirect 57 57 github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 58 58 github.com/kylelemons/godebug v1.1.0 // indirect 59 + github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe // indirect 59 60 github.com/mdlayher/genetlink v1.3.2 // indirect 60 61 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 61 62 github.com/mdlayher/sdnotify v1.0.0 // indirect ··· 75 76 github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 76 77 github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 77 78 github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect 79 + github.com/tink-crypto/tink-go/v2 v2.2.0 // indirect 78 80 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 79 81 github.com/vishvananda/netns v0.0.4 // indirect 80 82 github.com/x448/float16 v0.8.4 // indirect ··· 84 86 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect 85 87 golang.org/x/mod v0.23.0 // indirect 86 88 golang.org/x/net v0.36.0 // indirect 89 + golang.org/x/oauth2 v0.26.0 // indirect 87 90 golang.org/x/sync v0.11.0 // indirect 88 91 golang.org/x/sys v0.30.0 // indirect 89 92 golang.org/x/term v0.29.0 // indirect
+6
go.sum
··· 117 117 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 118 118 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 119 119 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 120 + github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe h1:QBlUtM+Rv9P+W3k9C6+xLgpssfxcKd8Ir+pvNM7E23Y= 121 + github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe/go.mod h1:H1Y2Ektfl9aWzSHYT1qf6lXpE9mdil6ZavkI/5+N5Qg= 120 122 github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 121 123 github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 122 124 github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= ··· 183 185 github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 184 186 github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 185 187 github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 188 + github.com/tink-crypto/tink-go/v2 v2.2.0 h1:L2Da0F2Udh2agtKztdr69mV/KpnY3/lGTkMgLTVIXlA= 189 + github.com/tink-crypto/tink-go/v2 v2.2.0/go.mod h1:JJ6PomeNPF3cJpfWC0lgyTES6zpJILkAX0cJNwlS3xU= 186 190 github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= 187 191 github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= 188 192 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= ··· 208 212 golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 209 213 golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 210 214 golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 215 + golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= 216 + golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 211 217 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 218 golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 213 219 golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+86 -8
main.go
··· 19 19 "strings" 20 20 "syscall" 21 21 22 + "github.com/lstoll/oidc" 23 + "github.com/lstoll/oidc/middleware" 22 24 "github.com/oklog/run" 23 25 "github.com/prometheus/client_golang/prometheus" 24 26 versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" ··· 67 69 Name string 68 70 Backend string 69 71 Prometheus bool 70 - Funnel bool 72 + Funnel *funnelConfig `json:"funnel,omitempty"` 73 + } 74 + 75 + type funnelConfig struct { 76 + Insecure bool 77 + Issuer string 78 + ClientID string 79 + ClientSecret string 71 80 } 72 81 73 82 type target struct { ··· 301 310 cancel() 302 311 }) 303 312 } 304 - if upstream.Funnel { 313 + if funnel := upstream.Funnel; funnel != nil { 314 + if !funnel.Insecure && funnel.Issuer == "" { 315 + return fmt.Errorf("upstream %s: funnel must set issuer or insecure", upstream.Name) 316 + } 305 317 { 306 318 var srv *http.Server 307 319 g.Add(func() error { ··· 309 321 if err != nil { 310 322 return fmt.Errorf("tailscale: wait for tsnet %s to be ready: %w", upstream.Name, err) 311 323 } 312 - srv = &http.Server{ 313 - Handler: instrument(insecureFunnelHandler(log, lc, proxy)), 324 + 325 + srv = &http.Server{} 326 + if funnel.Issuer != "" { 327 + handler, err := oidcFunnelHandler(ctx, log, lc, funnel, proxy) 328 + if err != nil { 329 + return fmt.Errorf("oidc: %w", err) 330 + } 331 + srv.Handler = handler 332 + } else if funnel.Insecure { 333 + srv.Handler = insecureFunnelHandler(log, lc, proxy) 334 + } else { 335 + panic("funnel misconfigured") 314 336 } 337 + srv.Handler = instrument(srv.Handler) 315 338 316 339 ln, err := ts.ListenFunnel("tcp", ":443", tsnet.FunnelOnly()) 317 340 if err != nil { ··· 339 362 whois, err := tsWhoIs(lc, r) 340 363 if err != nil { 341 364 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 342 - logger.Error("tailscale whois", lerr(err)) 365 + logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) 343 366 return 344 367 } 345 368 ··· 364 387 ) 365 388 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 366 389 if r.TLS == nil { 367 - panic("TLS handler wants TLS") 390 + panic("tailnet handler wants tls") 368 391 } 369 392 370 393 if dnsName == "" { 371 394 st, err := lc.StatusWithoutPeers(r.Context()) 372 395 if err != nil { 373 396 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 374 - logger.Error("tailscale status", slog.Any("err", err)) 397 + logger.ErrorContext(r.Context(), "tailscale status", slog.Any("err", err)) 375 398 return 376 399 } 377 400 dnsName = strings.TrimSuffix(st.Self.DNSName, ".") ··· 390 413 // This is marked insecure because the upstream is exposed to the public Internet. 391 414 // The upstream is responsible for implementing authentication. 392 415 func insecureFunnelHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { 393 - return localTailnetHandler(logger, lc, next) 416 + return localTailnetTLSHandler(logger, lc, next) 417 + } 418 + 419 + // oidcFunnelHandlers serves Funnel requests, requiring authentication via the configured OIDC issuer. 420 + func oidcFunnelHandler(ctx context.Context, logger *slog.Logger, lc tailscaleLocalClient, cfg *funnelConfig, next http.Handler) (http.Handler, error) { 421 + st, err := lc.StatusWithoutPeers(ctx) 422 + if err != nil { 423 + return nil, fmt.Errorf("tailscale status: %w", err) 424 + } 425 + 426 + redir := &url.URL{Scheme: "https", Path: ".oidc-callback"} 427 + redir.Host = strings.TrimSuffix(st.Self.DNSName, ".") 428 + 429 + wrapper, err := middleware.NewFromDiscovery(ctx, nil, cfg.Issuer, cfg.ClientID, cfg.ClientSecret, redir.String()) 430 + if err != nil { 431 + return nil, fmt.Errorf("oidc middleware: %w", err) 432 + } 433 + wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) 434 + 435 + return wrapper.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 436 + if r.TLS == nil { 437 + panic("oidc handler wants tls") 438 + } 439 + 440 + _, err := tsWhoIs(lc, r) 441 + if err != nil { 442 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 443 + logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) 444 + return 445 + } 446 + 447 + tok := middleware.IDJWTFromContext(r.Context()) 448 + if tok == nil { 449 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 450 + logger.ErrorContext(r.Context(), "jwt token missing") 451 + return 452 + } 453 + email, err := tok.StringClaim("email") 454 + if err != nil { 455 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 456 + logger.ErrorContext(r.Context(), "claim missing", slog.String("claim", "email")) 457 + return 458 + } 459 + name, err := tok.StringClaim("name") 460 + if err != nil { 461 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 462 + logger.ErrorContext(r.Context(), "claim missing", slog.String("claim", "name")) 463 + return 464 + } 465 + 466 + req := r.Clone(r.Context()) 467 + req.Header.Set("X-Webauth-User", email) 468 + req.Header.Set("X-Webauth-Name", name) 469 + 470 + next.ServeHTTP(w, r) 471 + })), nil 394 472 } 395 473 396 474 type tailscaleLocalClient interface {