cmd/tsidp: add funnel support (#12591)

* cmd/tsidp: add funnel support

Updates #10263.

Signed-off-by: Naman Sood <mail@nsood.in>

* look past funnel-ingress-node to see who we're authenticating

Signed-off-by: Naman Sood <mail@nsood.in>

* fix comment typo

Signed-off-by: Naman Sood <mail@nsood.in>

* address review feedback, support Basic auth for /token

Turns out you need to support Basic auth if you do client ID/secret
according to OAuth.

Signed-off-by: Naman Sood <mail@nsood.in>

* fix typos

Signed-off-by: Naman Sood <mail@nsood.in>

* review fixes

Signed-off-by: Naman Sood <mail@nsood.in>

* remove debugging log

Signed-off-by: Naman Sood <mail@nsood.in>

* add comments, fix header

Signed-off-by: Naman Sood <mail@nsood.in>

---------

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood 2024-08-08 10:46:45 -04:00 committed by GitHub
parent 1ed958fe23
commit f79183dac7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 411 additions and 38 deletions

View File

@ -7,6 +7,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
crand "crypto/rand" crand "crypto/rand"
"crypto/rsa" "crypto/rsa"
@ -16,6 +17,7 @@
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -25,6 +27,7 @@
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
"os/signal"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -35,6 +38,7 @@
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype" "tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tsnet" "tailscale.com/tsnet"
@ -44,13 +48,22 @@
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/must" "tailscale.com/util/must"
"tailscale.com/util/rands" "tailscale.com/util/rands"
"tailscale.com/version"
) )
// ctxConn is a key to look up a net.Conn stored in an HTTP request's context.
type ctxConn struct{}
// funnelClientsFile is the file where client IDs and secrets for OIDC clients
// accessing the IDP over Funnel are persisted.
const funnelClientsFile = "oidc-funnel-clients.json"
var ( var (
flagVerbose = flag.Bool("verbose", false, "be verbose") flagVerbose = flag.Bool("verbose", false, "be verbose")
flagPort = flag.Int("port", 443, "port to listen on") flagPort = flag.Int("port", 443, "port to listen on")
flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost") flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost")
flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet") flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet")
flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
) )
func main() { func main() {
@ -61,9 +74,11 @@ func main() {
} }
var ( var (
lc *tailscale.LocalClient lc *tailscale.LocalClient
st *ipnstate.Status st *ipnstate.Status
err error err error
watcherChan chan error
cleanup func()
lns []net.Listener lns []net.Listener
) )
@ -90,6 +105,18 @@ func main() {
if !anySuccess { if !anySuccess {
log.Fatalf("failed to listen on any of %v", st.TailscaleIPs) log.Fatalf("failed to listen on any of %v", st.TailscaleIPs)
} }
// tailscaled needs to be setting an HTTP header for funneled requests
// that older versions don't provide.
// TODO(naman): is this the correct check?
if *flagFunnel && !version.AtLeast(st.Version, "1.71.0") {
log.Fatalf("Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode.")
}
cleanup, watcherChan, err = serveOnLocalTailscaled(ctx, lc, st, uint16(*flagPort), *flagFunnel)
if err != nil {
log.Fatalf("could not serve on local tailscaled: %v", err)
}
defer cleanup()
} else { } else {
ts := &tsnet.Server{ ts := &tsnet.Server{
Hostname: "idp", Hostname: "idp",
@ -105,7 +132,15 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("getting local client: %v", err) log.Fatalf("getting local client: %v", err)
} }
ln, err := ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort)) var ln net.Listener
if *flagFunnel {
if err := ipn.CheckFunnelAccess(uint16(*flagPort), st.Self); err != nil {
log.Fatalf("%v", err)
}
ln, err = ts.ListenFunnel("tcp", fmt.Sprintf(":%d", *flagPort))
} else {
ln, err = ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort))
}
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -113,13 +148,26 @@ func main() {
} }
srv := &idpServer{ srv := &idpServer{
lc: lc, lc: lc,
funnel: *flagFunnel,
localTSMode: *flagUseLocalTailscaled,
} }
if *flagPort != 443 { if *flagPort != 443 {
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort) srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
} else { } else {
srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, ".")) srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, "."))
} }
if *flagFunnel {
f, err := os.Open(funnelClientsFile)
if err == nil {
srv.funnelClients = make(map[string]*funnelClient)
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
log.Fatalf("could not parse %s: %v", funnelClientsFile, err)
}
} else if !errors.Is(err, os.ErrNotExist) {
log.Fatalf("could not open %s: %v", funnelClientsFile, err)
}
}
log.Printf("Running tsidp at %s ...", srv.serverURL) log.Printf("Running tsidp at %s ...", srv.serverURL)
@ -134,35 +182,129 @@ func main() {
} }
for _, ln := range lns { for _, ln := range lns {
go http.Serve(ln, srv) server := http.Server{
Handler: srv,
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, ctxConn{}, c)
},
}
go server.Serve(ln)
} }
select {} // need to catch os.Interrupt, otherwise deferred cleanup code doesn't run
exitChan := make(chan os.Signal, 1)
signal.Notify(exitChan, os.Interrupt)
select {
case <-exitChan:
log.Printf("interrupt, exiting")
return
case <-watcherChan:
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
log.Printf("watcher closed, exiting")
return
}
log.Fatalf("watcher error: %v", err)
return
}
}
// serveOnLocalTailscaled starts a serve session using an already-running
// tailscaled instead of starting a fresh tsnet server, making something
// listening on clientDNSName:dstPort accessible over serve/funnel.
func serveOnLocalTailscaled(ctx context.Context, lc *tailscale.LocalClient, st *ipnstate.Status, dstPort uint16, shouldFunnel bool) (cleanup func(), watcherChan chan error, err error) {
// In order to support funneling out in local tailscaled mode, we need
// to add a serve config to forward the listeners we bound above and
// allow those forwarders to be funneled out.
sc, err := lc.GetServeConfig(ctx)
if err != nil {
return nil, nil, fmt.Errorf("could not get serve config: %v", err)
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
// We watch the IPN bus just to get a session ID. The session expires
// when we stop watching the bus, and that auto-deletes the foreground
// serve/funnel configs we are creating below.
watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys)
if err != nil {
return nil, nil, fmt.Errorf("could not set up ipn bus watcher: %v", err)
}
defer func() {
if err != nil {
watcher.Close()
}
}()
n, err := watcher.Next()
if err != nil {
return nil, nil, fmt.Errorf("could not get initial state from ipn bus watcher: %v", err)
}
if n.SessionID == "" {
err = fmt.Errorf("missing sessionID in ipn.Notify")
return nil, nil, err
}
watcherChan = make(chan error)
go func() {
for {
_, err = watcher.Next()
if err != nil {
watcherChan <- err
return
}
}
}()
// Create a foreground serve config that gets cleaned up when tsidp
// exits and the session ID associated with this config is invalidated.
foregroundSc := new(ipn.ServeConfig)
mak.Set(&sc.Foreground, n.SessionID, foregroundSc)
serverURL := strings.TrimSuffix(st.Self.DNSName, ".")
fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort)
foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel)
foregroundSc.SetWebHandler(&ipn.HTTPHandler{
Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))),
}, serverURL, uint16(*flagPort), "/", true)
err = lc.SetServeConfig(ctx, sc)
if err != nil {
return nil, watcherChan, fmt.Errorf("could not set serve config: %v", err)
}
return func() { watcher.Close() }, watcherChan, nil
} }
type idpServer struct { type idpServer struct {
lc *tailscale.LocalClient lc *tailscale.LocalClient
loopbackURL string loopbackURL string
serverURL string // "https://foo.bar.ts.net" serverURL string // "https://foo.bar.ts.net"
funnel bool
localTSMode bool
lazyMux lazy.SyncValue[*http.ServeMux] lazyMux lazy.SyncValue[*http.ServeMux]
lazySigningKey lazy.SyncValue[*signingKey] lazySigningKey lazy.SyncValue[*signingKey]
lazySigner lazy.SyncValue[jose.Signer] lazySigner lazy.SyncValue[jose.Signer]
mu sync.Mutex // guards the fields below mu sync.Mutex // guards the fields below
code map[string]*authRequest // keyed by random hex code map[string]*authRequest // keyed by random hex
accessToken map[string]*authRequest // keyed by random hex accessToken map[string]*authRequest // keyed by random hex
funnelClients map[string]*funnelClient // keyed by client ID
} }
type authRequest struct { type authRequest struct {
// localRP is true if the request is from a relying party running on the // localRP is true if the request is from a relying party running on the
// same machine as the idp server. It is mutually exclusive with rpNodeID. // same machine as the idp server. It is mutually exclusive with rpNodeID
// and funnelRP.
localRP bool localRP bool
// rpNodeID is the NodeID of the relying party (who requested the auth, such // rpNodeID is the NodeID of the relying party (who requested the auth, such
// as Proxmox or Synology), not the user node who is being authenticated. It // as Proxmox or Synology), not the user node who is being authenticated. It
// is mutually exclusive with localRP. // is mutually exclusive with localRP and funnelRP.
rpNodeID tailcfg.NodeID rpNodeID tailcfg.NodeID
// funnelRP is non-nil if the request is from a relying party outside the
// tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID
// and localRP.
funnelRP *funnelClient
// clientID is the "client_id" sent in the authorized request. // clientID is the "client_id" sent in the authorized request.
clientID string clientID string
@ -181,9 +323,12 @@ type authRequest struct {
validTill time.Time validTill time.Time
} }
func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, lc *tailscale.LocalClient) error { // allowRelyingParty validates that a relying party identified either by a
// known remoteAddr or a valid client ID/secret pair is allowed to proceed
// with the authorization flow associated with this authRequest.
func (ar *authRequest) allowRelyingParty(r *http.Request, lc *tailscale.LocalClient) error {
if ar.localRP { if ar.localRP {
ra, err := netip.ParseAddrPort(remoteAddr) ra, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil { if err != nil {
return err return err
} }
@ -192,7 +337,18 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
} }
return nil return nil
} }
who, err := lc.WhoIs(ctx, remoteAddr) if ar.funnelRP != nil {
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret {
return fmt.Errorf("tsidp: invalid client credentials")
}
return nil
}
who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil { if err != nil {
return fmt.Errorf("tsidp: error getting WhoIs: %w", err) return fmt.Errorf("tsidp: error getting WhoIs: %w", err)
} }
@ -203,24 +359,60 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
} }
func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) { func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) // This URL is visited by the user who is being authenticated. If they are
// visiting the URL over Funnel, that means they are not part of the
// tailnet that they are trying to be authenticated for.
if isFunnelRequest(r) {
http.Error(w, "tsidp: unauthorized", http.StatusUnauthorized)
return
}
uq := r.URL.Query()
redirectURI := uq.Get("redirect_uri")
if redirectURI == "" {
http.Error(w, "tsidp: must specify redirect_uri", http.StatusBadRequest)
return
}
var remoteAddr string
if s.localTSMode {
// in local tailscaled mode, the local tailscaled is forwarding us
// HTTP requests, so reading r.RemoteAddr will just get us our own
// address.
remoteAddr = r.Header.Get("X-Forwarded-For")
} else {
remoteAddr = r.RemoteAddr
}
who, err := s.lc.WhoIs(r.Context(), remoteAddr)
if err != nil { if err != nil {
log.Printf("Error getting WhoIs: %v", err) log.Printf("Error getting WhoIs: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
uq := r.URL.Query()
code := rands.HexString(32) code := rands.HexString(32)
ar := &authRequest{ ar := &authRequest{
nonce: uq.Get("nonce"), nonce: uq.Get("nonce"),
remoteUser: who, remoteUser: who,
redirectURI: uq.Get("redirect_uri"), redirectURI: redirectURI,
clientID: uq.Get("client_id"), clientID: uq.Get("client_id"),
} }
if r.URL.Path == "/authorize/localhost" { if r.URL.Path == "/authorize/funnel" {
s.mu.Lock()
c, ok := s.funnelClients[ar.clientID]
s.mu.Unlock()
if !ok {
http.Error(w, "tsidp: invalid client ID", http.StatusBadRequest)
return
}
if ar.redirectURI != c.RedirectURI {
http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest)
return
}
ar.funnelRP = c
} else if r.URL.Path == "/authorize/localhost" {
ar.localRP = true ar.localRP = true
} else { } else {
var ok bool var ok bool
@ -237,8 +429,10 @@ func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
q := make(url.Values) q := make(url.Values)
q.Set("code", code) q.Set("code", code)
q.Set("state", uq.Get("state")) if state := uq.Get("state"); state != "" {
u := uq.Get("redirect_uri") + "?" + q.Encode() q.Set("state", state)
}
u := redirectURI + "?" + q.Encode()
log.Printf("Redirecting to %q", u) log.Printf("Redirecting to %q", u)
http.Redirect(w, r, u, http.StatusFound) http.Redirect(w, r, u, http.StatusFound)
@ -251,6 +445,7 @@ func (s *idpServer) newMux() *http.ServeMux {
mux.HandleFunc("/authorize/", s.authorize) mux.HandleFunc("/authorize/", s.authorize)
mux.HandleFunc("/userinfo", s.serveUserInfo) mux.HandleFunc("/userinfo", s.serveUserInfo)
mux.HandleFunc("/token", s.serveToken) mux.HandleFunc("/token", s.serveToken)
mux.HandleFunc("/clients/", s.serveClients)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>") io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>")
@ -284,11 +479,6 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tsidp: invalid token", http.StatusBadRequest) http.Error(w, "tsidp: invalid token", http.StatusBadRequest)
return return
} }
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil {
log.Printf("Error allowing relying party: %v", err)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ar.validTill.Before(time.Now()) { if ar.validTill.Before(time.Now()) {
http.Error(w, "tsidp: token expired", http.StatusBadRequest) http.Error(w, "tsidp: token expired", http.StatusBadRequest)
@ -348,7 +538,7 @@ func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tsidp: code not found", http.StatusBadRequest) http.Error(w, "tsidp: code not found", http.StatusBadRequest)
return return
} }
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil { if err := ar.allowRelyingParty(r, s.lc); err != nil {
log.Printf("Error allowing relying party: %v", err) log.Printf("Error allowing relying party: %v", err)
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
@ -581,7 +771,9 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
} }
var authorizeEndpoint string var authorizeEndpoint string
rpEndpoint := s.serverURL rpEndpoint := s.serverURL
if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil { if isFunnelRequest(r) {
authorizeEndpoint = fmt.Sprintf("%s/authorize/funnel", s.serverURL)
} else if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID) authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID)
} else if ap.Addr().IsLoopback() { } else if ap.Addr().IsLoopback() {
rpEndpoint = s.loopbackURL rpEndpoint = s.loopbackURL
@ -611,6 +803,148 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
} }
} }
// funnelClient represents an OIDC client/relying party that is accessing the
// IDP over Funnel.
type funnelClient struct {
ID string `json:"client_id"`
Secret string `json:"client_secret,omitempty"`
Name string `json:"name,omitempty"`
RedirectURI string `json:"redirect_uri"`
}
// /clients is a privileged endpoint that allows the visitor to create new
// Funnel-capable OIDC clients, so it is only accessible over the tailnet.
func (s *idpServer) serveClients(w http.ResponseWriter, r *http.Request) {
if isFunnelRequest(r) {
http.Error(w, "tsidp: not found", http.StatusNotFound)
return
}
path := strings.TrimPrefix(r.URL.Path, "/clients/")
if path == "new" {
s.serveNewClient(w, r)
return
}
if path == "" {
s.serveGetClientsList(w, r)
return
}
s.mu.Lock()
c, ok := s.funnelClients[path]
s.mu.Unlock()
if !ok {
http.Error(w, "tsidp: not found", http.StatusNotFound)
return
}
switch r.Method {
case "DELETE":
s.serveDeleteClient(w, r, path)
case "GET":
json.NewEncoder(w).Encode(&funnelClient{
ID: c.ID,
Name: c.Name,
Secret: "",
RedirectURI: c.RedirectURI,
})
default:
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *idpServer) serveNewClient(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
redirectURI := r.FormValue("redirect_uri")
if redirectURI == "" {
http.Error(w, "tsidp: must provide redirect_uri", http.StatusBadRequest)
return
}
clientID := rands.HexString(32)
clientSecret := rands.HexString(64)
newClient := funnelClient{
ID: clientID,
Secret: clientSecret,
Name: r.FormValue("name"),
RedirectURI: redirectURI,
}
s.mu.Lock()
defer s.mu.Unlock()
mak.Set(&s.funnelClients, clientID, &newClient)
if err := s.storeFunnelClientsLocked(); err != nil {
log.Printf("could not write funnel clients db: %v", err)
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
// delete the new client to avoid inconsistent state between memory
// and disk
delete(s.funnelClients, clientID)
return
}
json.NewEncoder(w).Encode(newClient)
}
func (s *idpServer) serveGetClientsList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.Lock()
redactedClients := make([]funnelClient, 0, len(s.funnelClients))
for _, c := range s.funnelClients {
redactedClients = append(redactedClients, funnelClient{
ID: c.ID,
Name: c.Name,
Secret: "",
RedirectURI: c.RedirectURI,
})
}
s.mu.Unlock()
json.NewEncoder(w).Encode(redactedClients)
}
func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, clientID string) {
if r.Method != "DELETE" {
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.funnelClients == nil {
http.Error(w, "tsidp: client not found", http.StatusNotFound)
return
}
if _, ok := s.funnelClients[clientID]; !ok {
http.Error(w, "tsidp: client not found", http.StatusNotFound)
return
}
deleted := s.funnelClients[clientID]
delete(s.funnelClients, clientID)
if err := s.storeFunnelClientsLocked(); err != nil {
log.Printf("could not write funnel clients db: %v", err)
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
// restore the deleted value to avoid inconsistent state between memory
// and disk
s.funnelClients[clientID] = deleted
return
}
w.WriteHeader(http.StatusNoContent)
}
// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret
// pairs for RPs that access the IDP over funnel. s.mu must be held while
// calling this.
func (s *idpServer) storeFunnelClientsLocked() error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil {
return err
}
return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600)
}
const ( const (
minimumRSAKeySize = 2048 minimumRSAKeySize = 2048
) )
@ -700,3 +1034,24 @@ func parseID[T ~int64](input string) (_ T, ok bool) {
} }
return T(i), true return T(i), true
} }
// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel.
func isFunnelRequest(r *http.Request) bool {
// If we're funneling through the local tailscaled, it will set this HTTP
// header.
if r.Header.Get("Tailscale-Funnel-Request") != "" {
return true
}
// If the funneled connection is from tsnet, then the net.Conn will be of
// type ipn.FunnelConn.
netConn := r.Context().Value(ctxConn{})
// if the conn is wrapped inside TLS, unwrap it
if tlsConn, ok := netConn.(*tls.Conn); ok {
netConn = tlsConn.NetConn()
}
if _, ok := netConn.(*ipn.FunnelConn); ok {
return true
}
return false
}

View File

@ -3781,7 +3781,7 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
return nil return nil
}, opts }, opts
} }
if handler := b.tcpHandlerForServe(dst.Port(), src); handler != nil { if handler := b.tcpHandlerForServe(dst.Port(), src, nil); handler != nil {
return handler, opts return handler, opts
} }
return nil, nil return nil, nil

View File

@ -56,6 +56,16 @@
type serveHTTPContext struct { type serveHTTPContext struct {
SrcAddr netip.AddrPort SrcAddr netip.AddrPort
DestPort uint16 DestPort uint16
// provides funnel-specific context, nil if not funneled
Funnel *funnelFlow
}
// funnelFlow represents a funneled connection initiated via IngressPeer
// to Host.
type funnelFlow struct {
Host string
IngressPeer tailcfg.NodeView
} }
// localListener is the state of host-level net.Listen for a specific (Tailscale IP, port) // localListener is the state of host-level net.Listen for a specific (Tailscale IP, port)
@ -91,7 +101,7 @@ func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort,
handler: func(conn net.Conn) error { handler: func(conn net.Conn) error {
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort() srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
handler := b.tcpHandlerForServe(ap.Port(), srcAddr) handler := b.tcpHandlerForServe(ap.Port(), srcAddr, nil)
if handler == nil { if handler == nil {
b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, ap.Port()) b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, ap.Port())
conn.Close() conn.Close()
@ -382,7 +392,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
return return
} }
_, port, err := net.SplitHostPort(string(target)) host, port, err := net.SplitHostPort(string(target))
if err != nil { if err != nil {
logf("got ingress conn for bad target %q; rejecting", target) logf("got ingress conn for bad target %q; rejecting", target)
sendRST() sendRST()
@ -407,9 +417,10 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
return return
} }
} }
// TODO(bradfitz): pass ingressPeer etc in context to tcpHandlerForServe, handler := b.tcpHandlerForServe(dport, srcAddr, &funnelFlow{
// extend serveHTTPContext or similar. Host: host,
handler := b.tcpHandlerForServe(dport, srcAddr) IngressPeer: ingressPeer,
})
if handler == nil { if handler == nil {
logf("[unexpected] no matching ingress serve handler for %v to port %v", srcAddr, dport) logf("[unexpected] no matching ingress serve handler for %v to port %v", srcAddr, dport)
sendRST() sendRST()
@ -424,8 +435,9 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
} }
// tcpHandlerForServe returns a handler for a TCP connection to be served via // tcpHandlerForServe returns a handler for a TCP connection to be served via
// the ipn.ServeConfig. // the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled
func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) (handler func(net.Conn) error) { // connection.
func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort, f *funnelFlow) (handler func(net.Conn) error) {
b.mu.Lock() b.mu.Lock()
sc := b.serveConfig sc := b.serveConfig
b.mu.Unlock() b.mu.Unlock()
@ -444,6 +456,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
Handler: http.HandlerFunc(b.serveWebHandler), Handler: http.HandlerFunc(b.serveWebHandler),
BaseContext: func(_ net.Listener) context.Context { BaseContext: func(_ net.Listener) context.Context {
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{ return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
Funnel: f,
SrcAddr: srcAddr, SrcAddr: srcAddr,
DestPort: dport, DestPort: dport,
}) })
@ -712,15 +725,20 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
r.Out.Header.Del("Tailscale-User-Login") r.Out.Header.Del("Tailscale-User-Login")
r.Out.Header.Del("Tailscale-User-Name") r.Out.Header.Del("Tailscale-User-Name")
r.Out.Header.Del("Tailscale-User-Profile-Pic") r.Out.Header.Del("Tailscale-User-Profile-Pic")
r.Out.Header.Del("Tailscale-Funnel-Request")
r.Out.Header.Del("Tailscale-Headers-Info") r.Out.Header.Del("Tailscale-Headers-Info")
c, ok := serveHTTPContextKey.ValueOk(r.Out.Context()) c, ok := serveHTTPContextKey.ValueOk(r.Out.Context())
if !ok { if !ok {
return return
} }
if c.Funnel != nil {
r.Out.Header.Set("Tailscale-Funnel-Request", "?1")
return
}
node, user, ok := b.WhoIs("tcp", c.SrcAddr) node, user, ok := b.WhoIs("tcp", c.SrcAddr)
if !ok { if !ok {
return // traffic from outside of Tailnet (funneled) return // traffic from outside of Tailnet (funneled or local machine)
} }
if node.IsTagged() { if node.IsTagged() {
// 2023-06-14: Not setting identity headers for tagged nodes. // 2023-06-14: Not setting identity headers for tagged nodes.