@ -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 . R emoteAddr)
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
}