|
|
@@ -7,6 +7,7 @@
|
|
|
package main
|
|
|
|
|
|
import (
|
|
|
+ "bytes"
|
|
|
"context"
|
|
|
crand "crypto/rand"
|
|
|
"crypto/rsa"
|
|
|
@@ -16,6 +17,7 @@ import (
|
|
|
"encoding/binary"
|
|
|
"encoding/json"
|
|
|
"encoding/pem"
|
|
|
+ "errors"
|
|
|
"flag"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
@@ -25,6 +27,7 @@ import (
|
|
|
"net/netip"
|
|
|
"net/url"
|
|
|
"os"
|
|
|
+ "os/signal"
|
|
|
"strconv"
|
|
|
"strings"
|
|
|
"sync"
|
|
|
@@ -35,6 +38,7 @@ import (
|
|
|
"tailscale.com/client/tailscale"
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
|
"tailscale.com/envknob"
|
|
|
+ "tailscale.com/ipn"
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
|
"tailscale.com/tailcfg"
|
|
|
"tailscale.com/tsnet"
|
|
|
@@ -44,13 +48,22 @@ import (
|
|
|
"tailscale.com/util/mak"
|
|
|
"tailscale.com/util/must"
|
|
|
"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 (
|
|
|
flagVerbose = flag.Bool("verbose", false, "be verbose")
|
|
|
flagPort = flag.Int("port", 443, "port to listen on")
|
|
|
flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost")
|
|
|
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() {
|
|
|
@@ -61,9 +74,11 @@ func main() {
|
|
|
}
|
|
|
|
|
|
var (
|
|
|
- lc *tailscale.LocalClient
|
|
|
- st *ipnstate.Status
|
|
|
- err error
|
|
|
+ lc *tailscale.LocalClient
|
|
|
+ st *ipnstate.Status
|
|
|
+ err error
|
|
|
+ watcherChan chan error
|
|
|
+ cleanup func()
|
|
|
|
|
|
lns []net.Listener
|
|
|
)
|
|
|
@@ -90,6 +105,18 @@ func main() {
|
|
|
if !anySuccess {
|
|
|
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 {
|
|
|
ts := &tsnet.Server{
|
|
|
Hostname: "idp",
|
|
|
@@ -105,7 +132,15 @@ func main() {
|
|
|
if err != nil {
|
|
|
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 {
|
|
|
log.Fatal(err)
|
|
|
}
|
|
|
@@ -113,13 +148,26 @@ func main() {
|
|
|
}
|
|
|
|
|
|
srv := &idpServer{
|
|
|
- lc: lc,
|
|
|
+ lc: lc,
|
|
|
+ funnel: *flagFunnel,
|
|
|
+ localTSMode: *flagUseLocalTailscaled,
|
|
|
}
|
|
|
if *flagPort != 443 {
|
|
|
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
|
|
|
} else {
|
|
|
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)
|
|
|
|
|
|
@@ -134,35 +182,129 @@ func main() {
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
+ }
|
|
|
+ // 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)
|
|
|
}
|
|
|
- select {}
|
|
|
+
|
|
|
+ return func() { watcher.Close() }, watcherChan, nil
|
|
|
}
|
|
|
|
|
|
type idpServer struct {
|
|
|
lc *tailscale.LocalClient
|
|
|
loopbackURL string
|
|
|
serverURL string // "https://foo.bar.ts.net"
|
|
|
+ funnel bool
|
|
|
+ localTSMode bool
|
|
|
|
|
|
lazyMux lazy.SyncValue[*http.ServeMux]
|
|
|
lazySigningKey lazy.SyncValue[*signingKey]
|
|
|
lazySigner lazy.SyncValue[jose.Signer]
|
|
|
|
|
|
- mu sync.Mutex // guards the fields below
|
|
|
- code map[string]*authRequest // keyed by random hex
|
|
|
- accessToken map[string]*authRequest // keyed by random hex
|
|
|
+ mu sync.Mutex // guards the fields below
|
|
|
+ code 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 {
|
|
|
// 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
|
|
|
|
|
|
// 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
|
|
|
- // is mutually exclusive with localRP.
|
|
|
+ // is mutually exclusive with localRP and funnelRP.
|
|
|
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 string
|
|
|
|
|
|
@@ -181,9 +323,12 @@ type authRequest struct {
|
|
|
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 {
|
|
|
- ra, err := netip.ParseAddrPort(remoteAddr)
|
|
|
+ ra, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
@@ -192,7 +337,18 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
|
|
|
}
|
|
|
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 {
|
|
|
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) {
|
|
|
- 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 {
|
|
|
log.Printf("Error getting WhoIs: %v", err)
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- uq := r.URL.Query()
|
|
|
-
|
|
|
code := rands.HexString(32)
|
|
|
ar := &authRequest{
|
|
|
nonce: uq.Get("nonce"),
|
|
|
remoteUser: who,
|
|
|
- redirectURI: uq.Get("redirect_uri"),
|
|
|
+ redirectURI: redirectURI,
|
|
|
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
|
|
|
} else {
|
|
|
var ok bool
|
|
|
@@ -237,8 +429,10 @@ func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
q := make(url.Values)
|
|
|
q.Set("code", code)
|
|
|
- q.Set("state", uq.Get("state"))
|
|
|
- u := uq.Get("redirect_uri") + "?" + q.Encode()
|
|
|
+ if state := uq.Get("state"); state != "" {
|
|
|
+ q.Set("state", state)
|
|
|
+ }
|
|
|
+ u := redirectURI + "?" + q.Encode()
|
|
|
log.Printf("Redirecting to %q", u)
|
|
|
|
|
|
http.Redirect(w, r, u, http.StatusFound)
|
|
|
@@ -251,6 +445,7 @@ func (s *idpServer) newMux() *http.ServeMux {
|
|
|
mux.HandleFunc("/authorize/", s.authorize)
|
|
|
mux.HandleFunc("/userinfo", s.serveUserInfo)
|
|
|
mux.HandleFunc("/token", s.serveToken)
|
|
|
+ mux.HandleFunc("/clients/", s.serveClients)
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
|
if r.URL.Path == "/" {
|
|
|
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)
|
|
|
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()) {
|
|
|
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)
|
|
|
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)
|
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
|
return
|
|
|
@@ -581,7 +771,9 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
|
var authorizeEndpoint string
|
|
|
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)
|
|
|
} else if ap.Addr().IsLoopback() {
|
|
|
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 (
|
|
|
minimumRSAKeySize = 2048
|
|
|
)
|
|
|
@@ -700,3 +1034,24 @@ func parseID[T ~int64](input string) (_ T, ok bool) {
|
|
|
}
|
|
|
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
|
|
|
+}
|