From 4d24fc9774c8a6c335325d5fb9676b8dbafcbee7 Mon Sep 17 00:00:00 2001 From: Simon Rozet Date: Tue, 22 Apr 2025 20:24:53 +0200 Subject: [PATCH] implement oidc funnel handler --- go.mod | 3 ++ go.sum | 6 ++++ main.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index dee251c..9137df0 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect @@ -75,6 +76,7 @@ require ( github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect + github.com/tink-crypto/tink-go/v2 v2.2.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -84,6 +86,7 @@ require ( golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/net v0.36.0 // indirect + golang.org/x/oauth2 v0.26.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect diff --git a/go.sum b/go.sum index 459ec75..a6e1074 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe h1:QBlUtM+Rv9P+W3k9C6+xLgpssfxcKd8Ir+pvNM7E23Y= +github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe/go.mod h1:H1Y2Ektfl9aWzSHYT1qf6lXpE9mdil6ZavkI/5+N5Qg= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -183,6 +185,8 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tink-crypto/tink-go/v2 v2.2.0 h1:L2Da0F2Udh2agtKztdr69mV/KpnY3/lGTkMgLTVIXlA= +github.com/tink-crypto/tink-go/v2 v2.2.0/go.mod h1:JJ6PomeNPF3cJpfWC0lgyTES6zpJILkAX0cJNwlS3xU= github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= @@ -208,6 +212,8 @@ golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/main.go b/main.go index 0542c64..ec99788 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,8 @@ import ( "strings" "syscall" + "github.com/lstoll/oidc" + "github.com/lstoll/oidc/middleware" "github.com/oklog/run" "github.com/prometheus/client_golang/prometheus" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" @@ -67,7 +69,14 @@ type upstream struct { Name string Backend string Prometheus bool - Funnel bool + Funnel *funnelConfig `json:"funnel,omitempty"` +} + +type funnelConfig struct { + Insecure bool + Issuer string + ClientID string + ClientSecret string } type target struct { @@ -301,7 +310,10 @@ func tsproxy(ctx context.Context) error { cancel() }) } - if upstream.Funnel { + if funnel := upstream.Funnel; funnel != nil { + if !funnel.Insecure && funnel.Issuer == "" { + return fmt.Errorf("upstream %s: funnel must set issuer or insecure", upstream.Name) + } { var srv *http.Server g.Add(func() error { @@ -309,9 +321,20 @@ func tsproxy(ctx context.Context) error { if err != nil { return fmt.Errorf("tailscale: wait for tsnet %s to be ready: %w", upstream.Name, err) } - srv = &http.Server{ - Handler: instrument(insecureFunnelHandler(log, lc, proxy)), + + srv = &http.Server{} + if funnel.Issuer != "" { + handler, err := oidcFunnelHandler(ctx, log, lc, funnel, proxy) + if err != nil { + return fmt.Errorf("oidc: %w", err) + } + srv.Handler = handler + } else if funnel.Insecure { + srv.Handler = insecureFunnelHandler(log, lc, proxy) + } else { + panic("funnel misconfigured") } + srv.Handler = instrument(srv.Handler) ln, err := ts.ListenFunnel("tcp", ":443", tsnet.FunnelOnly()) if err != nil { @@ -339,7 +362,7 @@ func localTailnetHandler(logger *slog.Logger, lc tailscaleLocalClient, next http whois, err := tsWhoIs(lc, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.Error("tailscale whois", lerr(err)) + logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) return } @@ -364,14 +387,14 @@ func localTailnetTLSHandler(logger *slog.Logger, lc tailscaleLocalClient, next h ) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS == nil { - panic("TLS handler wants TLS") + panic("tailnet handler wants tls") } if dnsName == "" { st, err := lc.StatusWithoutPeers(r.Context()) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.Error("tailscale status", slog.Any("err", err)) + logger.ErrorContext(r.Context(), "tailscale status", slog.Any("err", err)) return } dnsName = strings.TrimSuffix(st.Self.DNSName, ".") @@ -390,7 +413,62 @@ func localTailnetTLSHandler(logger *slog.Logger, lc tailscaleLocalClient, next h // This is marked insecure because the upstream is exposed to the public Internet. // The upstream is responsible for implementing authentication. func insecureFunnelHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { - return localTailnetHandler(logger, lc, next) + return localTailnetTLSHandler(logger, lc, next) +} + +// oidcFunnelHandlers serves Funnel requests, requiring authentication via the configured OIDC issuer. +func oidcFunnelHandler(ctx context.Context, logger *slog.Logger, lc tailscaleLocalClient, cfg *funnelConfig, next http.Handler) (http.Handler, error) { + st, err := lc.StatusWithoutPeers(ctx) + if err != nil { + return nil, fmt.Errorf("tailscale status: %w", err) + } + + redir := &url.URL{Scheme: "https", Path: ".oidc-callback"} + redir.Host = strings.TrimSuffix(st.Self.DNSName, ".") + + wrapper, err := middleware.NewFromDiscovery(ctx, nil, cfg.Issuer, cfg.ClientID, cfg.ClientSecret, redir.String()) + if err != nil { + return nil, fmt.Errorf("oidc middleware: %w", err) + } + wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) + + return wrapper.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + panic("oidc handler wants tls") + } + + _, err := tsWhoIs(lc, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) + return + } + + tok := middleware.IDJWTFromContext(r.Context()) + if tok == nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.ErrorContext(r.Context(), "jwt token missing") + return + } + email, err := tok.StringClaim("email") + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.ErrorContext(r.Context(), "claim missing", slog.String("claim", "email")) + return + } + name, err := tok.StringClaim("name") + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.ErrorContext(r.Context(), "claim missing", slog.String("claim", "name")) + return + } + + req := r.Clone(r.Context()) + req.Header.Set("X-Webauth-User", email) + req.Header.Set("X-Webauth-Name", name) + + next.ServeHTTP(w, r) + })), nil } type tailscaleLocalClient interface { -- 2.51.2