Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598// Copyright (c) Tailscale Inc & contributors// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
// Package tailssh is an SSH server integrated into Tailscale.package tailssh
import ( "bytes" "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "maps" "net" "net/http" "net/netip" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "sync/atomic" "syscall" "time"
gossh "golang.org/x/crypto/ssh" "tailscale.com/envknob" "tailscale.com/feature" "tailscale.com/ipn/ipnlocal" "tailscale.com/net/tsaddr" "tailscale.com/net/tsdial" "tailscale.com/sessionrecording" "tailscale.com/tailcfg" "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/util/backoff" "tailscale.com/util/clientmetric" "tailscale.com/util/httpm" "tailscale.com/util/mak")
var ( sshVerboseLogging = envknob.RegisterBool("TS_DEBUG_SSH_VLOG") sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP") sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING") sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY")
// errTerminal is an empty gossh.PartialSuccessError (with no 'Next' // authentication methods that may proceed), which results in the SSH // server immediately disconnecting the client. errTerminal = &gossh.PartialSuccessError{}
// hookSSHLoginSuccess is called after successful SSH authentication. // It is set by platform-specific code (e.g., auditd_linux.go). hookSSHLoginSuccess feature.Hook[func(logf logger.Logf, c *conn)])
const ( // forcePasswordSuffix is the suffix at the end of a username that forces // Tailscale SSH into password authentication mode to work around buggy SSH // clients that get confused by successful replies to auth type "none". forcePasswordSuffix = "+password")
// ipnLocalBackend is the subset of ipnlocal.LocalBackend that we use.// It is used for testing.type ipnLocalBackend interface { ShouldRunSSH() bool NetMap() *netmap.NetworkMap WhoIs(proto string, ipp netip.AddrPort) (n tailcfg.NodeView, u tailcfg.UserProfile, ok bool) DoNoiseRequest(req *http.Request) (*http.Response, error) Dialer() *tsdial.Dialer TailscaleVarRoot() string NodeKey() key.NodePublic}
type server struct { lb ipnLocalBackend logf logger.Logf tailscaledPath string
timeNow func() time.Time // or nil for time.Now
sessionWaitGroup sync.WaitGroup
// mu protects the following mu sync.Mutex activeConns map[*conn]bool // set; value is always true shutdownCalled bool}
func (srv *server) now() time.Time { if srv != nil && srv.timeNow != nil { return srv.timeNow() } return time.Now()}
func init() { feature.HookGetSSHHostKeyPublicStrings.Set(getHostKeyPublicStrings) ipnlocal.RegisterC2N("/ssh/usernames", handleC2NSSHUsernames) ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) { tsd, err := os.Executable() if err != nil { return nil, err } srv := &server{ lb: lb, logf: logf, tailscaledPath: tsd, timeNow: func() time.Time { return lb.ControlNow(time.Now()) }, }
return srv, nil })}
// attachSessionToConnIfNotShutdown ensures that srv is not shutdown before// attaching the session to the conn. This ensures that once Shutdown is called,// new sessions are not allowed and existing ones are cleaned up.// It reports whether ss was attached to the conn.func (srv *server) attachSessionToConnIfNotShutdown(ss *sshSession) bool { srv.mu.Lock() defer srv.mu.Unlock() if srv.shutdownCalled { // Do not start any new sessions. return false } ss.conn.attachSession(ss) return true}
func (srv *server) trackActiveConn(c *conn, add bool) { srv.mu.Lock() defer srv.mu.Unlock() if add { mak.Set(&srv.activeConns, c, true) return } delete(srv.activeConns, c)}
// NumActiveConns returns the number of active SSH connections.func (srv *server) NumActiveConns() int { srv.mu.Lock() defer srv.mu.Unlock() return len(srv.activeConns)}
// HandleSSHConn handles a Tailscale SSH connection from c.// This is the entry point for all SSH connections.// When this returns, the connection is closed.func (srv *server) HandleSSHConn(nc net.Conn) error { metricIncomingConnections.Add(1) c, err := srv.newConn() if err != nil { return err } srv.trackActiveConn(c, true) // add defer srv.trackActiveConn(c, false) // remove c.HandleConn(nc)
// Return nil to signal to netstack's interception that it doesn't need to // log. If ss.HandleConn had problems, it can log itself (ideally on an // sshSession.logf). return nil}
// Shutdown terminates all active sessions.func (srv *server) Shutdown() { srv.mu.Lock() srv.shutdownCalled = true for c := range srv.activeConns { c.Close() } srv.mu.Unlock() srv.sessionWaitGroup.Wait()}
// OnPolicyChange terminates any active sessions that no longer match// the SSH access policy.func (srv *server) OnPolicyChange() { srv.mu.Lock() defer srv.mu.Unlock() for c := range srv.activeConns { if !c.authCompleted.Load() { // The connection hasn't completed authentication yet. // In that case, the connection will be terminated when it does. continue } go c.checkStillValid() }}
// conn represents a single SSH connection and its associated// ssh.Server.//// During the lifecycle of a connection, the following are called in order:// Setup and discover server info// - ServerConfigCallback//// Get access to a ServerPreAuthConn (useful for sending banners)//// Do the user auth with a NoClientAuthCallback. If user specified// a username ending in "+password", follow this with password auth// (to work around buggy SSH clients that don't work with noauth).//// Once auth is done, the conn can be multiplexed with multiple sessions and// channels concurrently. At which point any of the following can be called// in any order.// - c.handleSessionPostSSHAuth// - c.mayForwardLocalPortTo followed by ssh.DirectTCPIPHandlertype conn struct { *ssh.Server srv *server
insecureSkipTailscaleAuth bool // used by tests.
// idH is the RFC4253 sec8 hash H. It is used to identify the connection, // and is shared among all sessions. It should not be shared outside // process. It is confusingly referred to as SessionID by the gliderlabs/ssh // library. idH string connID string // ID that's shared with control
// spac is a [gossh.ServerPreAuthConn] used for sending auth banners. // Banners cannot be sent after auth completes. spac gossh.ServerPreAuthConn
// The following fields are set during clientAuth and are used for policy // evaluation and session management. They are immutable after clientAuth // completes. They must not be read from other goroutines until // authCompleted is set to true.
action0 *tailcfg.SSHAction // set by clientAuth finalAction *tailcfg.SSHAction // set by clientAuth
info *sshConnInfo // set by setInfo (during clientAuth) localUser *userMeta // set by clientAuth userGroupIDs []string // set by clientAuth acceptEnv []string
// authCompleted is set to true after clientAuth has finished writing // all authentication state fields (info, localUser, action0, // finalAction, userGroupIDs, acceptEnv). It provides a memory // barrier so that concurrent readers (e.g. OnPolicyChange) see // fully-initialized values. authCompleted atomic.Bool
// mu protects the following fields. // // srv.mu should be acquired prior to mu. // It is safe to just acquire mu, but unsafe to // acquire mu and then srv.mu. mu sync.Mutex // protects the following sessions []*sshSession}
func (c *conn) logf(format string, args ...any) { format = fmt.Sprintf("%v: %v", c.connID, format) c.srv.logf(format, args...)}
func (c *conn) vlogf(format string, args ...any) { if sshVerboseLogging() { c.logf(format, args...) }}
// errDenied is returned by auth callbacks when a connection is denied by the// policy. It writes the message to an auth banner and then returns an empty// gossh.PartialSuccessError in order to stop processing authentication// attempts and immediately disconnect the client.func (c *conn) errDenied(message string) error { if message == "" { message = "tailscale: access denied" } if err := c.spac.SendAuthBanner(message); err != nil { c.logf("failed to send auth banner: %s", err) } return errTerminal}
// errBanner writes the given message to an auth banner and then returns an// empty gossh.PartialSuccessError in order to stop processing authentication// attempts and immediately disconnect the client. The contents of err is not// leaked in the auth banner, but it is logged to the server's log.func (c *conn) errBanner(message string, err error) error { if err != nil { c.logf("%s: %s", message, err) } if err := c.spac.SendAuthBanner("tailscale: " + message + "\n"); err != nil { c.logf("failed to send auth banner: %s", err) } return errTerminal}
// errUnexpected is returned by auth callbacks that encounter an unexpected// error, such as being unable to send an auth banner. It sends an empty// gossh.PartialSuccessError to tell gossh.Server to stop processing// authentication attempts and instead disconnect immediately.func (c *conn) errUnexpected(err error) error { c.logf("terminal error: %s", err) return errTerminal}
// clientAuth is responsible for performing client authentication.//// If policy evaluation fails, it returns an error.// If access is denied, it returns an error. This must always be an empty// gossh.PartialSuccessError to prevent further authentication methods from// being tried.func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retErr error) { defer func() { if pse, ok := retErr.(*gossh.PartialSuccessError); ok { if pse.Next.GSSAPIWithMICConfig != nil || pse.Next.KeyboardInteractiveCallback != nil || pse.Next.PasswordCallback != nil || pse.Next.PublicKeyCallback != nil { panic("clientAuth attempted to return a non-empty PartialSuccessError") } } else if retErr != nil { panic(fmt.Sprintf("clientAuth attempted to return a non-PartialSuccessError error of type: %t", retErr)) } }()
if c.insecureSkipTailscaleAuth { return &gossh.Permissions{}, nil }
if err := c.setInfo(cm); err != nil { return nil, c.errBanner("failed to get connection info", err) }
action, localUser, acceptEnv, result := c.evaluatePolicy() switch result { case accepted: // do nothing case rejectedUser: return nil, c.errBanner(fmt.Sprintf("tailnet policy does not permit you to SSH as user %q", c.info.sshUser), nil) case rejected, noPolicy: return nil, c.errBanner("tailnet policy does not permit you to SSH to this node", fmt.Errorf("failed to evaluate policy, result: %s", result)) default: return nil, c.errBanner("failed to evaluate tailnet policy", fmt.Errorf("failed to evaluate policy, result: %s", result)) }
c.action0 = action
if action.Accept || action.HoldAndDelegate != "" { // Immediately look up user information for purposes of generating // hold and delegate URL (if necessary). lu, err := userLookup(localUser) if err != nil { return nil, c.errBanner(fmt.Sprintf("failed to look up local user %q ", localUser), err) } gids, err := lu.GroupIds() if err != nil { return nil, c.errBanner("failed to look up local user's group IDs", err) } c.userGroupIDs = gids c.localUser = lu c.acceptEnv = acceptEnv }
for { switch { case action.Accept: metricTerminalAccept.Add(1) if action.Message != "" { if err := c.spac.SendAuthBanner(action.Message); err != nil { return nil, c.errUnexpected(fmt.Errorf("error sending auth welcome message: %w", err)) } } c.finalAction = action c.authCompleted.Store(true) return &gossh.Permissions{}, nil case action.Reject: metricTerminalReject.Add(1) c.finalAction = action return nil, c.errDenied(action.Message) case action.HoldAndDelegate != "": if action.Message != "" { if err := c.spac.SendAuthBanner(action.Message); err != nil { return nil, c.errUnexpected(fmt.Errorf("error sending hold and delegate message: %w", err)) } }
url := action.HoldAndDelegate
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel()
metricHolds.Add(1) url = c.expandDelegateURLLocked(url)
var err error action, err = c.fetchSSHAction(ctx, url) if err != nil { metricTerminalFetchError.Add(1) return nil, c.errBanner("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err)) } default: metricTerminalMalformed.Add(1) return nil, c.errBanner("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil) } }}
// ServerConfig implements ssh.ServerConfigCallback.func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { return &gossh.ServerConfig{ PreAuthConnCallback: func(spac gossh.ServerPreAuthConn) { c.spac = spac }, NoClientAuth: true, // required for the NoClientAuthCallback to run NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) { // First perform client authentication, which can potentially // involve multiple steps (for example prompting user to log in to // Tailscale admin panel to confirm identity). perms, err := c.clientAuth(cm) if err != nil { return nil, err }
// Authentication succeeded. Buggy SSH clients get confused by // success from the "none" auth method. As a workaround, let users // specify a username ending in "+password" to force password auth. // The actual value of the password doesn't matter. if strings.HasSuffix(cm.User(), forcePasswordSuffix) { return nil, &gossh.PartialSuccessError{ Next: gossh.ServerAuthCallbacks{ PasswordCallback: func(_ gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) { return &gossh.Permissions{}, nil }, }, } }
return perms, nil }, PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) { // Some clients don't request 'none' authentication. Instead, they // immediately supply a password. We humor them by accepting the // password, but authenticate as usual, ignoring the actual value of // the password. return c.clientAuth(cm) }, PublicKeyCallback: func(cm gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { // Some clients don't request 'none' authentication. Instead, they // immediately supply a public key. We humor them by accepting the // key, but authenticate as usual, ignoring the actual content of // the key. return c.clientAuth(cm) }, }}
func (srv *server) newConn() (*conn, error) { srv.mu.Lock() if srv.shutdownCalled { srv.mu.Unlock() // Stop accepting new connections. // Connections in the auth phase are handled in handleConnPostSSHAuth. // Existing sessions are terminated by Shutdown. return nil, errors.New("server is shutting down") } srv.mu.Unlock() c := &conn{srv: srv} now := srv.now() c.connID = fmt.Sprintf("ssh-conn-%s-%02x", now.UTC().Format("20060102T150405"), randBytes(5)) fwdHandler := &ssh.ForwardedTCPHandler{} c.Server = &ssh.Server{ Version: "Tailscale", ServerConfigCallback: c.ServerConfig,
Handler: c.handleSessionPostSSHAuth, LocalPortForwardingCallback: c.mayForwardLocalPortTo, ReversePortForwardingCallback: c.mayReversePortForwardTo, SubsystemHandlers: map[string]ssh.SubsystemHandler{ "sftp": c.handleSessionPostSSHAuth, }, // Note: the direct-tcpip channel handler and LocalPortForwardingCallback // only adds support for forwarding ports from the local machine. // TODO(maisem/bradfitz): add remote port forwarding support. ChannelHandlers: map[string]ssh.ChannelHandler{ "direct-tcpip": ssh.DirectTCPIPHandler, }, RequestHandlers: map[string]ssh.RequestHandler{ "tcpip-forward": fwdHandler.HandleSSHRequest, "cancel-tcpip-forward": fwdHandler.HandleSSHRequest, }, } ss := c.Server maps.Copy(ss.RequestHandlers, ssh.DefaultRequestHandlers) maps.Copy(ss.ChannelHandlers, ssh.DefaultChannelHandlers) maps.Copy(ss.SubsystemHandlers, ssh.DefaultSubsystemHandlers) keys, err := getHostKeys(srv.lb.TailscaleVarRoot(), srv.logf) if err != nil { return nil, err } for _, signer := range keys { ss.AddHostKey(signer) } return c, nil}
// mayReversePortPortForwardTo reports whether the ctx should be allowed to port forward// to the specified host and port.// TODO(bradfitz/maisem): should we have more checks on host/port?func (c *conn) mayReversePortForwardTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { if sshDisableForwarding() { return false } if c.finalAction != nil && c.finalAction.AllowRemotePortForwarding { metricRemotePortForward.Add(1) return true } return false}
// mayForwardLocalPortTo reports whether the ctx should be allowed to port forward// to the specified host and port.// TODO(bradfitz/maisem): should we have more checks on host/port?func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, destinationPort uint32) bool { if sshDisableForwarding() { return false } if c.finalAction != nil && c.finalAction.AllowLocalPortForwarding { metricLocalPortForward.Add(1) return true } return false}
// sshPolicy returns the SSHPolicy for current node.// If there is no SSHPolicy in the netmap, it returns a debugPolicy// if one is defined.func (c *conn) sshPolicy() (_ *tailcfg.SSHPolicy, ok bool) { lb := c.srv.lb if !lb.ShouldRunSSH() { return nil, false } nm := lb.NetMap() if nm == nil { return nil, false } if pol := nm.SSHPolicy; pol != nil && !envknob.SSHIgnoreTailnetPolicy() { return pol, true } debugPolicyFile := envknob.SSHPolicyFile() if debugPolicyFile != "" { c.logf("reading debug SSH policy file: %v", debugPolicyFile) f, err := os.ReadFile(debugPolicyFile) if err != nil { c.logf("error reading debug SSH policy file: %v", err) return nil, false } p := new(tailcfg.SSHPolicy) if err := json.Unmarshal(f, p); err != nil { c.logf("invalid JSON in %v: %v", debugPolicyFile, err) return nil, false } return p, true } return nil, false}
func toIPPort(a net.Addr) (ipp netip.AddrPort) { ta, ok := a.(*net.TCPAddr) if !ok { return } tanetaddr, ok := netip.AddrFromSlice(ta.IP) if !ok { return } return netip.AddrPortFrom(tanetaddr.Unmap(), uint16(ta.Port))}
// connInfo populates the sshConnInfo from the provided arguments,// validating only that they represent a known Tailscale identity.func (c *conn) setInfo(cm gossh.ConnMetadata) error { if c.info != nil { return nil } ci := &sshConnInfo{ sshUser: strings.TrimSuffix(cm.User(), forcePasswordSuffix), src: toIPPort(cm.RemoteAddr()), dst: toIPPort(cm.LocalAddr()), } if !tsaddr.IsTailscaleIP(ci.dst.Addr()) { return fmt.Errorf("tailssh: rejecting non-Tailscale local address %v", ci.dst) } if !tsaddr.IsTailscaleIP(ci.src.Addr()) { return fmt.Errorf("tailssh: rejecting non-Tailscale remote address %v", ci.src) } node, uprof, ok := c.srv.lb.WhoIs("tcp", ci.src) if !ok { return fmt.Errorf("unknown Tailscale identity from src %v", ci.src) } ci.node = node ci.uprof = uprof
c.idH = string(cm.SessionID()) c.info = ci c.logf("handling conn: %v", ci.String()) return nil}
type evalResult string
const ( noPolicy evalResult = "no policy" rejected evalResult = "rejected" rejectedUser evalResult = "rejected user" accepted evalResult = "accept")
// evaluatePolicy returns the SSHAction and localUser after evaluating// the SSHPolicy for this conn.func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, result evalResult) { pol, ok := c.sshPolicy() if !ok { return nil, "", nil, noPolicy } return c.evalSSHPolicy(pol)}
// handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication,// but not necessarily before all the Tailscale-level extra verification has// completed. It also handles SFTP requests.func (c *conn) handleSessionPostSSHAuth(s ssh.Session) { // Do this check after auth, but before starting the session. switch s.Subsystem() { case "sftp": if sshDisableSFTP() { fmt.Fprintf(s.Stderr(), "sftp disabled\r\n") s.Exit(1) return } metricSFTP.Add(1) case "": // Regular SSH session. default: fmt.Fprintf(s.Stderr(), "Unsupported subsystem %q\r\n", s.Subsystem()) s.Exit(1) return }
ss := c.newSSHSession(s) ss.logf("handling new SSH connection from %v (%v) to ssh-user %q", c.info.uprof.LoginName, c.info.src.Addr(), c.localUser.Username) ss.logf("access granted to %v as ssh-user %q", c.info.uprof.LoginName, c.localUser.Username)
if f, ok := hookSSHLoginSuccess.GetOk(); ok { f(c.srv.logf, c) }
ss.run()}
func (c *conn) expandDelegateURLLocked(actionURL string) string { nm := c.srv.lb.NetMap() ci := c.info lu := c.localUser var dstNodeID string if nm != nil { dstNodeID = fmt.Sprint(int64(nm.SelfNode.ID())) } return strings.NewReplacer( "$SRC_NODE_IP", url.QueryEscape(ci.src.Addr().String()), "$SRC_NODE_ID", fmt.Sprint(int64(ci.node.ID())), "$DST_NODE_IP", url.QueryEscape(ci.dst.Addr().String()), "$DST_NODE_ID", dstNodeID, "$SSH_USER", url.QueryEscape(ci.sshUser), "$LOCAL_USER", url.QueryEscape(lu.Username), ).Replace(actionURL)}
// sshSession is an accepted Tailscale SSH session.type sshSession struct { ssh.Session sharedID string // ID that's shared with control logf logger.Logf
ctx context.Context cancelCtx context.CancelCauseFunc conn *conn agentListener net.Listener // non-nil if agent-forwarding requested+allowed
// initialized by launchProcess: cmd *exec.Cmd wrStdin io.WriteCloser rdStdout io.ReadCloser rdStderr io.ReadCloser // rdStderr is nil for pty sessions ptyReq *ssh.Pty // non-nil for pty sessions
// childPipes is a list of pipes that need to be closed when the process exits. // For pty sessions, this is the tty fd. // For non-pty sessions, this is the stdin, stdout, stderr fds. childPipes []io.Closer
// We use this sync.Once to ensure that we only terminate the process once, // either it exits itself or is terminated exitOnce sync.Once
// exitHandled is closed when killProcessOnContextDone finishes writing any // termination message to the client. run() waits on this before calling // ss.Exit to ensure the message is flushed before the SSH channel is torn // down. It is initialized by run() before starting killProcessOnContextDone. exitHandled chan struct{}}
func (ss *sshSession) vlogf(format string, args ...any) { if sshVerboseLogging() { ss.logf(format, args...) }}
func (c *conn) newSSHSession(s ssh.Session) *sshSession { sharedID := fmt.Sprintf("sess-%s-%02x", c.srv.now().UTC().Format("20060102T150405"), randBytes(5)) c.logf("starting session: %v", sharedID) ctx, cancel := context.WithCancelCause(s.Context()) return &sshSession{ Session: s, sharedID: sharedID, ctx: ctx, cancelCtx: cancel, conn: c, logf: logger.WithPrefix(c.srv.logf, "ssh-session("+sharedID+"): "), }}
// isStillValid reports whether the conn is still valid.func (c *conn) isStillValid() bool { a, localUser, _, result := c.evaluatePolicy() c.vlogf("stillValid: %+v %v %v", a, localUser, result) if result != accepted { return false } if !a.Accept && a.HoldAndDelegate == "" { return false } return c.localUser.Username == localUser}
// checkStillValid checks that the conn is still valid per the latest SSHPolicy.// If not, it terminates all sessions associated with the conn.func (c *conn) checkStillValid() { if c.isStillValid() { return } metricPolicyChangeKick.Add(1) c.logf("session no longer valid per new SSH policy; closing") c.mu.Lock() defer c.mu.Unlock() for _, s := range c.sessions { s.cancelCtx(userVisibleError{ fmt.Sprintf("Access revoked.\r\n"), context.Canceled, }) }}
func (c *conn) fetchSSHAction(ctx context.Context, url string) (*tailcfg.SSHAction, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() bo := backoff.NewBackoff("fetch-ssh-action", c.logf, 10*time.Second) for { if err := ctx.Err(); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } res, err := c.srv.lb.DoNoiseRequest(req) if err != nil { bo.BackOff(ctx, err) continue } if res.StatusCode != 200 { body, _ := io.ReadAll(res.Body) res.Body.Close() if len(body) > 1<<10 { body = body[:1<<10] } c.logf("fetch of %v: %s, %s", url, res.Status, body) bo.BackOff(ctx, fmt.Errorf("unexpected status: %v", res.Status)) continue } a := new(tailcfg.SSHAction) err = json.NewDecoder(res.Body).Decode(a) res.Body.Close() if err != nil { c.logf("invalid next SSHAction JSON from %v: %v", url, err) bo.BackOff(ctx, err) continue } return a, nil }}
// killProcessOnContextDone waits for ss.ctx to be done and kills the process,// unless the process has already exited.func (ss *sshSession) killProcessOnContextDone() { defer close(ss.exitHandled) <-ss.ctx.Done() // Either the process has already exited, in which case this does nothing. // Or, the process is still running in which case this will kill it. ss.exitOnce.Do(func() { err := context.Cause(ss.ctx) if serr, ok := err.(SSHTerminationError); ok { msg := serr.SSHTerminationMessage() if msg != "" { io.WriteString(ss.Stderr(), "\r\n\r\n"+msg+"\r\n\r\n") } } ss.logf("terminating SSH session from %v: %v", ss.conn.info.src.Addr(), err) // We don't need to Process.Wait here, sshSession.run() does // the waiting regardless of termination reason.
// TODO(maisem): should this be a SIGTERM followed by a SIGKILL? ss.cmd.Process.Kill() })}
// attachSession registers ss as an active session.func (c *conn) attachSession(ss *sshSession) { c.srv.sessionWaitGroup.Add(1) if ss.sharedID == "" { panic("empty sharedID") } c.mu.Lock() defer c.mu.Unlock() c.sessions = append(c.sessions, ss)}
// detachSession unregisters s from the list of active sessions.func (c *conn) detachSession(ss *sshSession) { defer c.srv.sessionWaitGroup.Done() c.mu.Lock() defer c.mu.Unlock() for i, s := range c.sessions { if s == ss { c.sessions = append(c.sessions[:i], c.sessions[i+1:]...) break } }}
var errSessionDone = errors.New("session is done")
// handleSSHAgentForwarding starts a Unix socket listener and in the background// forwards agent connections between the listener and the ssh.Session.// On success, it assigns ss.agentListener.func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *userMeta) error { if !ssh.AgentRequested(ss) || !ss.conn.finalAction.AllowAgentForwarding { return nil } if sshDisableForwarding() { // TODO(bradfitz): or do we want to return an error here instead so the user // gets an error if they ran with ssh -A? But for now we just silently // don't work, like the condition above. return nil } ss.logf("ssh: agent forwarding requested") ln, err := ssh.NewAgentListener() if err != nil { return err } defer func() { if err != nil && ln != nil { ln.Close() } }()
uid, err := strconv.ParseUint(lu.Uid, 10, 32) if err != nil { return err } gid, err := strconv.ParseUint(lu.Gid, 10, 32) if err != nil { return err } socket := ln.Addr().String() dir := filepath.Dir(socket) // Make sure the socket is accessible only by the user. if err := os.Chmod(socket, 0600); err != nil { return err } if err := os.Chown(socket, int(uid), int(gid)); err != nil { return err } // Make sure the dir is also accessible. if err := os.Chmod(dir, 0755); err != nil { return err }
go ssh.ForwardAgentConnections(ln, s) ss.agentListener = ln return nil}
// run is the entrypoint for a newly accepted SSH session.//// It handles ss once it's been accepted and determined// that it should run.func (ss *sshSession) run() { metricActiveSessions.Add(1) defer metricActiveSessions.Add(-1) defer ss.cancelCtx(errSessionDone)
if attached := ss.conn.srv.attachSessionToConnIfNotShutdown(ss); !attached { fmt.Fprintf(ss, "Tailscale SSH is shutting down\r\n") ss.Exit(1) return } defer ss.conn.detachSession(ss)
lu := ss.conn.localUser logf := ss.logf
if ss.conn.finalAction.SessionDuration != 0 { t := time.AfterFunc(ss.conn.finalAction.SessionDuration, func() { ss.cancelCtx(userVisibleError{ fmt.Sprintf("Session timeout of %v elapsed.", ss.conn.finalAction.SessionDuration), context.DeadlineExceeded, }) }) defer t.Stop() }
if euid := os.Geteuid(); euid != 0 && runtime.GOOS != "plan9" { if lu.Uid != fmt.Sprint(euid) { ss.logf("can't switch to user %q from process euid %v", lu.Username, euid) fmt.Fprintf(ss, "can't switch user\r\n") ss.Exit(1) return } }
// Take control of the PTY so that we can configure it below. // See https://github.com/tailscale/tailscale/issues/4146 ss.DisablePTYEmulation()
var rec *recording // or nil if disabled if ss.Subsystem() != "sftp" { if err := ss.handleSSHAgentForwarding(ss, lu); err != nil { ss.logf("agent forwarding failed: %v", err) } else if ss.agentListener != nil { // TODO(maisem/bradfitz): add a way to close all session resources defer ss.agentListener.Close() }
if ss.shouldRecord() { var err error rec, err = ss.startNewRecording() if err != nil { if uve, ok := errors.AsType[userVisibleError](err); ok { fmt.Fprintf(ss, "%s\r\n", uve.SSHTerminationMessage()) } else { fmt.Fprintf(ss, "can't start new recording\r\n") } ss.logf("startNewRecording: %v", err) ss.Exit(1) return } ss.logf("startNewRecording: <nil>") if rec != nil { defer rec.Close() } } }
err := ss.launchProcess() if err != nil { logf("start failed: %v", err.Error()) if errors.Is(err, context.Canceled) { err := context.Cause(ss.ctx) if uve, ok := errors.AsType[userVisibleError](err); ok { fmt.Fprintf(ss, "%s\r\n", uve) } } ss.Exit(1) return } ss.exitHandled = make(chan struct{}) go ss.killProcessOnContextDone()
var processDone atomic.Bool go func() { defer ss.wrStdin.Close() if _, err := io.Copy(rec.writer("i", ss.wrStdin), ss); err != nil { logf("stdin copy: %v", err) ss.cancelCtx(err) } }() outputDone := make(chan struct{}) var openOutputStreams atomic.Int32 if ss.rdStderr != nil { openOutputStreams.Store(2) } else { openOutputStreams.Store(1) } go func() { defer ss.rdStdout.Close() _, err := io.Copy(rec.writer("o", ss), ss.rdStdout) if err != nil && !errors.Is(err, io.EOF) { isErrBecauseProcessExited := processDone.Load() && errors.Is(err, syscall.EIO) if !isErrBecauseProcessExited { logf("stdout copy: %v", err) ss.cancelCtx(err) } } if openOutputStreams.Add(-1) == 0 { ss.CloseWrite() close(outputDone) } }() // rdStderr is nil for ptys. if ss.rdStderr != nil { go func() { defer ss.rdStderr.Close() _, err := io.Copy(ss.Stderr(), ss.rdStderr) if err != nil { logf("stderr copy: %v", err) } if openOutputStreams.Add(-1) == 0 { ss.CloseWrite() close(outputDone) } }() }
err = ss.cmd.Wait() processDone.Store(true)
// This will either make the SSH Termination goroutine be a no-op, // or itself will be a no-op because the process was killed by the // aforementioned goroutine. ss.exitOnce.Do(func() {})
// Close the process-side of all pipes to signal the asynchronous // io.Copy routines reading/writing from the pipes to terminate. // Block for the io.Copy to finish before calling ss.Exit below. closeAll(ss.childPipes...) select { case <-outputDone: case <-ss.ctx.Done(): // Wait for killProcessOnContextDone to finish writing any // termination message to the client before we call ss.Exit, // which tears down the SSH channel. <-ss.exitHandled }
if err == nil { ss.logf("Session complete") ss.Exit(0) return } if ee, ok := err.(*exec.ExitError); ok { code := ee.ProcessState.ExitCode() ss.logf("Wait: code=%v", code) ss.Exit(code) return }
ss.logf("Wait: %v", err) ss.Exit(1) return}
// recordSSHToLocalDisk is a deprecated dev knob to allow recording SSH sessions// to local storage. It is only used if there is no recording configured by the// coordination server. This will be removed in the future.var recordSSHToLocalDisk = envknob.RegisterBool("TS_DEBUG_LOG_SSH")
// recorders returns the list of recorders to use for this session.// If the final action has a non-empty list of recorders, that list is// returned. Otherwise, the list of recorders from the initial action// is returned.func (ss *sshSession) recorders() ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) { if len(ss.conn.finalAction.Recorders) > 0 { return ss.conn.finalAction.Recorders, ss.conn.finalAction.OnRecordingFailure } return ss.conn.action0.Recorders, ss.conn.action0.OnRecordingFailure}
func (ss *sshSession) shouldRecord() bool { recs, _ := ss.recorders() return len(recs) > 0 || recordSSHToLocalDisk()}
type sshConnInfo struct { // sshUser is the requested local SSH username ("root", "alice", etc). sshUser string
// src is the Tailscale IP and port that the connection came from. src netip.AddrPort
// dst is the Tailscale IP and port that the connection came for. dst netip.AddrPort
// node is srcIP's node. node tailcfg.NodeView
// uprof is node's UserProfile. uprof tailcfg.UserProfile}
func (ci *sshConnInfo) String() string { return fmt.Sprintf("%v->%v@%v", ci.src, ci.sshUser, ci.dst)}
func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool { if r.RuleExpires == nil { return false } return r.RuleExpires.Before(c.srv.now())}
func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, result evalResult) { failedOnUser := false for _, r := range pol.Rules { if a, localUser, acceptEnv, err := c.matchRule(r); err == nil { return a, localUser, acceptEnv, accepted } else if errors.Is(err, errUserMatch) { failedOnUser = true } } result = rejected if failedOnUser { result = rejectedUser } return nil, "", nil, result}
// internal errors for testing; they don't escape to callers or logs.var ( errNilRule = errors.New("nil rule") errNilAction = errors.New("nil action") errRuleExpired = errors.New("rule expired") errPrincipalMatch = errors.New("principal didn't match") errUserMatch = errors.New("user didn't match") errInvalidConn = errors.New("invalid connection state"))
func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) { defer func() { c.vlogf("matchRule(%+v): %v", r, err) }()
if c == nil { return nil, "", nil, errInvalidConn } if c.info == nil { c.logf("invalid connection state") return nil, "", nil, errInvalidConn } if r == nil { return nil, "", nil, errNilRule } if r.Action == nil { return nil, "", nil, errNilAction } if c.ruleExpired(r) { return nil, "", nil, errRuleExpired } if !c.anyPrincipalMatches(r.Principals) { return nil, "", nil, errPrincipalMatch } if !r.Action.Reject { // For all but Reject rules, SSHUsers is required. // If SSHUsers is nil or empty, mapLocalUser will return an // empty string anyway. localUser = mapLocalUser(r.SSHUsers, c.info.sshUser) if localUser == "" { return nil, "", nil, errUserMatch } } return r.Action, localUser, r.AcceptEnv, nil}
func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser string) { v, ok := ruleSSHUsers[reqSSHUser] if !ok { v = ruleSSHUsers["*"] } if v == "=" { return reqSSHUser } return v}
func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal) bool { for _, p := range ps { if p == nil { continue } if c.principalMatchesTailscaleIdentity(p) { return true } } return false}
// principalMatchesTailscaleIdentity reports whether one of p's four fields// that match the Tailscale identity match (Node, NodeIP, UserLogin, Any).func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { ci := c.info if p.Any { return true } if !p.Node.IsZero() && ci.node.Valid() && p.Node == ci.node.StableID() { return true } if p.NodeIP != "" { if ip, _ := netip.ParseAddr(p.NodeIP); ip == ci.src.Addr() { return true } } if p.UserLogin != "" && ci.uprof.LoginName == p.UserLogin { return true } return false}
func randBytes(n int) []byte { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) } return b}
func (ss *sshSession) openFileForRecording(now time.Time) (_ io.WriteCloser, err error) { varRoot := ss.conn.srv.lb.TailscaleVarRoot() if varRoot == "" { return nil, errors.New("no var root for recording storage") } dir := filepath.Join(varRoot, "ssh-sessions") if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } f, err := os.CreateTemp(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano())) if err != nil { return nil, err } return f, nil}
// startNewRecording starts a new SSH session recording.// It may return a nil recording if recording is not available.func (ss *sshSession) startNewRecording() (_ *recording, err error) { // We store the node key as soon as possible when creating // a new recording incase of FUS. nodeKey := ss.conn.srv.lb.NodeKey() if nodeKey.IsZero() { return nil, errors.New("ssh server is unavailable: no node key") }
recorders, onFailure := ss.recorders() var localRecording bool if len(recorders) == 0 { if recordSSHToLocalDisk() { localRecording = true } else { return nil, errors.New("no recorders configured") } }
var w ssh.Window if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq { w = ptyReq.Window }
term := envValFromList(ss.Environ(), "TERM") if term == "" { term = "xterm-256color" // something non-empty }
now := time.Now() rec := &recording{ ss: ss, start: now, failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "", }
// We want to use a background context for uploading and not ss.ctx. // ss.ctx is closed when the session closes, but we don't want to break the upload at that time. // Instead we want to wait for the session to close the writer when it finishes. ctx := context.Background() if localRecording { rec.out, err = ss.openFileForRecording(now) if err != nil { return nil, err } } else { var errChan <-chan error var attempts []*tailcfg.SSHRecordingAttempt rec.out, attempts, errChan, err = sessionrecording.ConnectToRecorder(ctx, recorders, ss.conn.srv.lb.Dialer().UserDial) if err != nil { if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 { eventType := tailcfg.SSHSessionRecordingFailed if onFailure.RejectSessionWithMessage != "" { eventType = tailcfg.SSHSessionRecordingRejected } ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL) }
if onFailure != nil && onFailure.RejectSessionWithMessage != "" { ss.logf("recording: error starting recording (rejecting session): %v", err) return nil, userVisibleError{ error: err, msg: onFailure.RejectSessionWithMessage, } } ss.logf("recording: error starting recording (failing open): %v", err) return nil, nil } go func() { err := <-errChan if err == nil { select { case <-ss.ctx.Done(): // Success. ss.logf("recording: finished uploading recording") return default: err = errors.New("recording upload ended before the SSH session") } } if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 { lastAttempt := attempts[len(attempts)-1] lastAttempt.FailureMessage = err.Error()
eventType := tailcfg.SSHSessionRecordingFailed if onFailure.TerminateSessionWithMessage != "" { eventType = tailcfg.SSHSessionRecordingTerminated }
ss.notifyControl(ctx, nodeKey, eventType, attempts, onFailure.NotifyURL) } if onFailure != nil && onFailure.TerminateSessionWithMessage != "" { ss.logf("recording: error uploading recording (closing session): %v", err) ss.cancelCtx(userVisibleError{ error: err, msg: onFailure.TerminateSessionWithMessage, }) return } ss.logf("recording: error uploading recording (failing open): %v", err) }() }
ch := sessionrecording.CastHeader{ Version: 2, Width: w.Width, Height: w.Height, Timestamp: now.Unix(), Command: strings.Join(ss.Command(), " "), Env: map[string]string{ "TERM": term, // TODO(bradfitz): anything else important? // including all seems noisey, but maybe we should // for auditing. But first need to break // launchProcess's startWithStdPipes and // startWithPTY up so that they first return the cmd // without starting it, and then a step that starts // it. Then we can (1) make the cmd, (2) start the // recording, (3) start the process. }, SSHUser: ss.conn.info.sshUser, LocalUser: ss.conn.localUser.Username, SrcNode: strings.TrimSuffix(ss.conn.info.node.Name(), "."), SrcNodeID: ss.conn.info.node.StableID(), ConnectionID: ss.conn.connID, } if !ss.conn.info.node.IsTagged() { ch.SrcNodeUser = ss.conn.info.uprof.LoginName ch.SrcNodeUserID = ss.conn.info.node.User() } else { ch.SrcNodeTags = ss.conn.info.node.Tags().AsSlice() } j, err := json.Marshal(ch) if err != nil { return nil, err } j = append(j, '\n') if _, err := rec.out.Write(j); err != nil { if errors.Is(err, io.ErrClosedPipe) && ss.ctx.Err() != nil { // If we got an io.ErrClosedPipe, it's likely because // the recording server closed the connection on us. Return // the original context error instead. return nil, context.Cause(ss.ctx) } return nil, err } return rec, nil}
// notifyControl sends a SSHEventNotifyRequest to control over noise.// A SSHEventNotifyRequest is sent when an action or state reached during// an SSH session is a defined EventType.func (ss *sshSession) notifyControl(ctx context.Context, nodeKey key.NodePublic, notifyType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, url string) { re := tailcfg.SSHEventNotifyRequest{ EventType: notifyType, ConnectionID: ss.conn.connID, CapVersion: tailcfg.CurrentCapabilityVersion, NodeKey: nodeKey, SrcNode: ss.conn.info.node.ID(), SSHUser: ss.conn.info.sshUser, LocalUser: ss.conn.localUser.Username, RecordingAttempts: attempts, }
body, err := json.Marshal(re) if err != nil { ss.logf("notifyControl: unable to marshal SSHNotifyRequest:", err) return }
req, err := http.NewRequestWithContext(ctx, httpm.POST, url, bytes.NewReader(body)) if err != nil { ss.logf("notifyControl: unable to create request:", err) return }
resp, err := ss.conn.srv.lb.DoNoiseRequest(req) if err != nil { ss.logf("notifyControl: unable to send noise request:", err) return }
if resp.StatusCode != http.StatusCreated { ss.logf("notifyControl: noise request returned status code %v", resp.StatusCode) return }}
// recording is the state for an SSH session recording.type recording struct { ss *sshSession start time.Time
// failOpen specifies whether the session should be allowed to // continue if writing to the recording fails. failOpen bool
mu sync.Mutex // guards writes to, close of out out io.WriteCloser}
func (r *recording) Close() error { r.mu.Lock() defer r.mu.Unlock() if r.out == nil { return nil } err := r.out.Close() r.out = nil return err}
// writer returns an io.Writer around w that first records the write.//// The dir should be "i" for input or "o" for output.//// If r is nil, it returns w unchanged.//// Currently (2023-03-21) we only record output, not input.func (r *recording) writer(dir string, w io.Writer) io.Writer { if r == nil { return w } if dir == "i" { // TODO: record input? Maybe not, since it might contain // passwords. return w } return &loggingWriter{r: r, dir: dir, w: w}}
// loggingWriter is an io.Writer wrapper that writes first an// asciinema JSON cast format recording line, and then writes to w.type loggingWriter struct { r *recording dir string // "i" or "o" (input or output) w io.Writer // underlying Writer, after writing to r.out
// recordingFailedOpen specifies whether we've failed to write to // r.out and should stop trying. It is set to true if we fail to write // to r.out and r.failOpen is set. recordingFailedOpen bool}
func (w *loggingWriter) Write(p []byte) (n int, err error) { if !w.recordingFailedOpen { j, err := json.Marshal([]any{ time.Since(w.r.start).Seconds(), w.dir, string(p), }) if err != nil { return 0, err } j = append(j, '\n') if err := w.writeCastLine(j); err != nil { if !w.r.failOpen { return 0, err } w.recordingFailedOpen = true } } return w.w.Write(p)}
func (w loggingWriter) writeCastLine(j []byte) error { w.r.mu.Lock() defer w.r.mu.Unlock() if w.r.out == nil { return errors.New("logger closed") } _, err := w.r.out.Write(j) if err != nil { return fmt.Errorf("logger Write: %w", err) } return nil}
func envValFromList(env []string, wantKey string) (v string) { for _, kv := range env { if thisKey, v, ok := strings.Cut(kv, "="); ok && envEq(thisKey, wantKey) { return v } } return ""}
// envEq reports whether environment variable a == b for the current// operating system.func envEq(a, b string) bool { //lint:ignore SA4032 in case this func moves elsewhere, permit the GOOS check if runtime.GOOS == "windows" { return strings.EqualFold(a, b) } return a == b}
var ( metricActiveSessions = clientmetric.NewGauge("ssh_active_sessions") metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections") metricTerminalAccept = clientmetric.NewCounter("ssh_terminalaction_accept") metricTerminalReject = clientmetric.NewCounter("ssh_terminalaction_reject") metricTerminalMalformed = clientmetric.NewCounter("ssh_terminalaction_malformed") metricTerminalFetchError = clientmetric.NewCounter("ssh_terminalaction_fetch_error") metricHolds = clientmetric.NewCounter("ssh_holds") metricPolicyChangeKick = clientmetric.NewCounter("ssh_policy_change_kick") metricSFTP = clientmetric.NewCounter("ssh_sftp_sessions") metricLocalPortForward = clientmetric.NewCounter("ssh_local_port_forward_requests") metricRemotePortForward = clientmetric.NewCounter("ssh_remote_port_forward_requests"))
// userVisibleError is a wrapper around an error that implements// SSHTerminationError, so msg is written to their session.type userVisibleError struct { msg string error}
func (ue userVisibleError) SSHTerminationMessage() string { return ue.msg }
// SSHTerminationError is implemented by errors that terminate an SSH// session and should be written to user's sessions.type SSHTerminationError interface { error SSHTerminationMessage() string}
func closeAll(cs ...io.Closer) { for _, c := range cs { if c != nil { c.Close() } }}