Browse Source

wgengine/netstack: add an SSH server experiment

Disabled by default.

To use, run tailscaled with:

    [email protected]

And enable with:

    $ TAILSCALE_USE_WIP_CODE=true tailscale up --ssh=true

Then ssh [any-user]@[your-tailscale-ip] for a root bash shell.
(both the "root" and "bash" part are temporary)

Updates #3802

Change-Id: I268f8c3c95c8eed5f3231d712a5dc89615a406f0
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 4 years ago
parent
commit
f3c0023add

+ 11 - 0
cmd/tailscale/cli/up.go

@@ -24,6 +24,7 @@ import (
 	qrcode "github.com/skip2/go-qrcode"
 	"inet.af/netaddr"
 	"tailscale.com/client/tailscale"
+	"tailscale.com/envknob"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/safesocket"
@@ -81,6 +82,8 @@ func acceptRouteDefault(goos string) bool {
 
 var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgs)
 
+func inTest() bool { return flag.Lookup("test.v") != nil }
+
 func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
 	upf := newFlagSet("up")
 
@@ -96,6 +99,9 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
 	upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
 	upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
 	upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
+	if envknob.UseWIPCode() || inTest() {
+		upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
+	}
 	upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
 	upf.StringVar(&upArgs.authKeyOrFile, "authkey", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
 	upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
@@ -131,6 +137,7 @@ type upArgsT struct {
 	exitNodeIP             string
 	exitNodeAllowLANAccess bool
 	shieldsUp              bool
+	runSSH                 bool
 	forceReauth            bool
 	forceDaemon            bool
 	advertiseRoutes        string
@@ -352,6 +359,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
 	prefs.CorpDNS = upArgs.acceptDNS
 	prefs.AllowSingleHosts = upArgs.singleRoutes
 	prefs.ShieldsUp = upArgs.shieldsUp
+	prefs.RunSSH = upArgs.runSSH
 	prefs.AdvertiseRoutes = routes
 	prefs.AdvertiseTags = tags
 	prefs.Hostname = upArgs.hostname
@@ -712,6 +720,7 @@ func init() {
 	addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
 	addPrefFlagMapping("unattended", "ForceDaemon")
 	addPrefFlagMapping("operator", "OperatorUser")
+	addPrefFlagMapping("ssh", "RunSSH")
 }
 
 func addPrefFlagMapping(flagName string, prefNames ...string) {
@@ -902,6 +911,8 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]interfac
 		switch f.Name {
 		default:
 			panic(fmt.Sprintf("unhandled flag %q", f.Name))
+		case "ssh":
+			set(prefs.RunSSH)
 		case "login-server":
 			set(prefs.ControlURL)
 		case "accept-routes":

+ 10 - 4
cmd/tailscaled/depaware.txt

@@ -3,6 +3,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
    W 💣 github.com/alexbrainman/sspi                                 from github.com/alexbrainman/sspi/internal/common+
    W    github.com/alexbrainman/sspi/internal/common                 from github.com/alexbrainman/sspi/negotiate
    W 💣 github.com/alexbrainman/sspi/negotiate                       from tailscale.com/net/tshttpproxy
+   L    github.com/anmitsu/go-shlex                                  from github.com/gliderlabs/ssh
    L    github.com/aws/aws-sdk-go-v2                                 from github.com/aws/aws-sdk-go-v2/internal/ini
    L    github.com/aws/aws-sdk-go-v2/aws                             from github.com/aws/aws-sdk-go-v2/aws/middleware+
    L    github.com/aws/aws-sdk-go-v2/aws/arn                         from tailscale.com/ipn/store/aws
@@ -60,6 +61,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
    L    github.com/aws/smithy-go/transport/http/internal/io          from github.com/aws/smithy-go/transport/http
    L    github.com/aws/smithy-go/waiter                              from github.com/aws/aws-sdk-go-v2/service/ssm
    L    github.com/coreos/go-iptables/iptables                       from tailscale.com/wgengine/router
+   L 💣 github.com/creack/pty                                        from tailscale.com/wgengine/netstack
+   L    github.com/gliderlabs/ssh                                    from tailscale.com/wgengine/netstack
    W 💣 github.com/go-ole/go-ole                                     from github.com/go-ole/go-ole/oleutil+
    W 💣 github.com/go-ole/go-ole/oleutil                             from tailscale.com/wgengine/winnet
    L 💣 github.com/godbus/dbus/v5                                    from tailscale.com/net/dns
@@ -256,7 +259,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/wgengine/filter                                from tailscale.com/control/controlclient+
         tailscale.com/wgengine/magicsock                             from tailscale.com/wgengine+
         tailscale.com/wgengine/monitor                               from tailscale.com/cmd/tailscaled+
-        tailscale.com/wgengine/netstack                              from tailscale.com/cmd/tailscaled
+     💣 tailscale.com/wgengine/netstack                              from tailscale.com/cmd/tailscaled
         tailscale.com/wgengine/router                                from tailscale.com/cmd/tailscaled+
         tailscale.com/wgengine/wgcfg                                 from tailscale.com/ipn/ipnlocal+
         tailscale.com/wgengine/wgcfg/nmcfg                           from tailscale.com/ipn/ipnlocal
@@ -265,16 +268,19 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         golang.org/x/crypto/acme                                     from tailscale.com/ipn/localapi
         golang.org/x/crypto/blake2b                                  from golang.org/x/crypto/nacl/box
         golang.org/x/crypto/blake2s                                  from golang.zx2c4.com/wireguard/device
-        golang.org/x/crypto/chacha20                                 from golang.org/x/crypto/chacha20poly1305
+   L    golang.org/x/crypto/blowfish                                 from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
+        golang.org/x/crypto/chacha20                                 from golang.org/x/crypto/chacha20poly1305+
         golang.org/x/crypto/chacha20poly1305                         from crypto/tls+
         golang.org/x/crypto/cryptobyte                               from crypto/ecdsa+
         golang.org/x/crypto/cryptobyte/asn1                          from crypto/ecdsa+
         golang.org/x/crypto/curve25519                               from crypto/tls+
+   L    golang.org/x/crypto/ed25519                                  from golang.org/x/crypto/ssh
         golang.org/x/crypto/hkdf                                     from crypto/tls
         golang.org/x/crypto/nacl/box                                 from tailscale.com/types/key
         golang.org/x/crypto/nacl/secretbox                           from golang.org/x/crypto/nacl/box
         golang.org/x/crypto/poly1305                                 from golang.org/x/crypto/chacha20poly1305+
         golang.org/x/crypto/salsa20/salsa                            from golang.org/x/crypto/nacl/box+
+   L    golang.org/x/crypto/ssh                                      from github.com/gliderlabs/ssh+
         golang.org/x/net/bpf                                         from github.com/mdlayher/netlink+
         golang.org/x/net/dns/dnsmessage                              from net+
         golang.org/x/net/http/httpguts                               from net/http+
@@ -312,14 +318,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         crypto/aes                                                   from crypto/ecdsa+
         crypto/cipher                                                from crypto/aes+
         crypto/des                                                   from crypto/tls+
-        crypto/dsa                                                   from crypto/x509
+        crypto/dsa                                                   from crypto/x509+
         crypto/ecdsa                                                 from crypto/tls+
         crypto/ed25519                                               from crypto/tls+
         crypto/elliptic                                              from crypto/ecdsa+
         crypto/hmac                                                  from crypto/tls+
         crypto/md5                                                   from crypto/tls+
         crypto/rand                                                  from crypto/ed25519+
-        crypto/rc4                                                   from crypto/tls
+        crypto/rc4                                                   from crypto/tls+
         crypto/rsa                                                   from crypto/tls+
         crypto/sha1                                                  from crypto/tls+
         crypto/sha256                                                from crypto/tls+

+ 4 - 4
cmd/tailscaled/tailscaled.go

@@ -329,9 +329,6 @@ func run() error {
 	}
 	ns.ProcessLocalIPs = useNetstack
 	ns.ProcessSubnets = useNetstack || wrapNetstack
-	if err := ns.Start(); err != nil {
-		return fmt.Errorf("failed to start netstack: %w", err)
-	}
 
 	if useNetstack {
 		dialer.UseNetstackForIP = func(ip netaddr.IP) bool {
@@ -342,7 +339,6 @@ func run() error {
 			return ns.DialContextTCP(ctx, dst)
 		}
 	}
-
 	if socksListener != nil || httpProxyListener != nil {
 		if httpProxyListener != nil {
 			hs := &http.Server{Handler: httpProxyHandler(dialer.UserDial)}
@@ -392,6 +388,10 @@ func run() error {
 	if err != nil {
 		return fmt.Errorf("ipnserver.New: %w", err)
 	}
+	ns.SetLocalBackend(srv.LocalBackend())
+	if err := ns.Start(); err != nil {
+		log.Fatalf("failed to start netstack: %v", err)
+	}
 
 	if debugMux != nil {
 		debugMux.HandleFunc("/debug/ipn", srv.ServeHTMLStatus)

+ 4 - 0
envknob/envknob.go

@@ -100,3 +100,7 @@ func LookupInt(envVar string) (v int, ok bool) {
 	log.Fatalf("invalid environment variable %s value %q: %v", envVar, val, err)
 	panic("unreachable")
 }
+
+// UseWIPCode is whether TAILSCALE_USE_WIP_CODE is set to permit use
+// of Work-In-Progress code.
+func UseWIPCode() bool { return Bool("TAILSCALE_USE_WIP_CODE") }

+ 10 - 0
ipn/ipnlocal/local.go

@@ -39,6 +39,7 @@ import (
 	"tailscale.com/net/tsdial"
 	"tailscale.com/paths"
 	"tailscale.com/portlist"
+	"tailscale.com/syncs"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/dnstype"
 	"tailscale.com/types/empty"
@@ -100,6 +101,7 @@ type LocalBackend struct {
 	serverURL             string           // tailcontrol URL
 	newDecompressor       func() (controlclient.Decompressor, error)
 	varRoot               string // or empty if SetVarRoot never called
+	sshAtomicBool         syncs.AtomicBool
 
 	filterHash deephash.Sum
 
@@ -1536,6 +1538,9 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err
 	}
 
 	b.logf("backend prefs for %q: %s", key, b.prefs.Pretty())
+
+	b.sshAtomicBool.Set(b.prefs != nil && b.prefs.RunSSH)
+
 	return nil
 }
 
@@ -1709,6 +1714,8 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) {
 	netMap := b.netMap
 	stateKey := b.stateKey
 
+	b.sshAtomicBool.Set(newp.RunSSH)
+
 	oldp := b.prefs
 	newp.Persist = oldp.Persist // caller isn't allowed to override this
 	b.prefs = newp
@@ -2618,8 +2625,11 @@ func (b *LocalBackend) ResetForClientDisconnect() {
 	b.authURL = ""
 	b.authURLSticky = ""
 	b.activeLogin = ""
+	b.sshAtomicBool.Set(false)
 }
 
+func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Get() }
+
 // Logout tells the controlclient that we want to log out, and
 // transitions the local engine to the logged-out state without
 // waiting for controlclient to be in that state.

+ 10 - 0
ipn/prefs.go

@@ -98,6 +98,11 @@ type Prefs struct {
 	// DNS configuration, if it exists.
 	CorpDNS bool
 
+	// RunSSH bool is whether this node should run an SSH
+	// server, permitting access to peers according to the
+	// policies as configured by the Tailnet's admin(s).
+	RunSSH bool
+
 	// WantRunning indicates whether networking should be active on
 	// this node.
 	WantRunning bool
@@ -193,6 +198,7 @@ type MaskedPrefs struct {
 	ExitNodeIPSet             bool `json:",omitempty"`
 	ExitNodeAllowLANAccessSet bool `json:",omitempty"`
 	CorpDNSSet                bool `json:",omitempty"`
+	RunSSHSet                 bool `json:",omitempty"`
 	WantRunningSet            bool `json:",omitempty"`
 	LoggedOutSet              bool `json:",omitempty"`
 	ShieldsUpSet              bool `json:",omitempty"`
@@ -277,6 +283,9 @@ func (p *Prefs) pretty(goos string) string {
 		sb.WriteString("mesh=false ")
 	}
 	fmt.Fprintf(&sb, "dns=%v want=%v ", p.CorpDNS, p.WantRunning)
+	if p.RunSSH {
+		sb.WriteString("ssh=true ")
+	}
 	if p.LoggedOut {
 		sb.WriteString("loggedout=true ")
 	}
@@ -348,6 +357,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
 		p.ExitNodeIP == p2.ExitNodeIP &&
 		p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
 		p.CorpDNS == p2.CorpDNS &&
+		p.RunSSH == p2.RunSSH &&
 		p.WantRunning == p2.WantRunning &&
 		p.LoggedOut == p2.LoggedOut &&
 		p.NotepadURLs == p2.NotepadURLs &&

+ 1 - 0
ipn/prefs_clone.go

@@ -40,6 +40,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
 	ExitNodeIP             netaddr.IP
 	ExitNodeAllowLANAccess bool
 	CorpDNS                bool
+	RunSSH                 bool
 	WantRunning            bool
 	LoggedOut              bool
 	ShieldsUp              bool

+ 1 - 0
ipn/prefs_test.go

@@ -42,6 +42,7 @@ func TestPrefsEqual(t *testing.T) {
 		"ExitNodeIP",
 		"ExitNodeAllowLANAccess",
 		"CorpDNS",
+		"RunSSH",
 		"WantRunning",
 		"LoggedOut",
 		"ShieldsUp",

+ 2 - 2
tsnet/tsnet.go

@@ -16,7 +16,6 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -24,6 +23,7 @@ import (
 	"inet.af/netaddr"
 	"tailscale.com/client/tailscale"
 	"tailscale.com/control/controlclient"
+	"tailscale.com/envknob"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnlocal"
 	"tailscale.com/ipn/localapi"
@@ -89,7 +89,7 @@ func (s *Server) Start() error {
 }
 
 func (s *Server) start() error {
-	if v, _ := strconv.ParseBool(os.Getenv("TAILSCALE_USE_WIP_CODE")); !v {
+	if !envknob.UseWIPCode() {
 		return errors.New("code disabled without environment variable TAILSCALE_USE_WIP_CODE set true")
 	}
 

+ 41 - 4
wgengine/netstack/netstack.go

@@ -35,11 +35,13 @@ import (
 	"inet.af/netstack/tcpip/transport/udp"
 	"inet.af/netstack/waiter"
 	"tailscale.com/envknob"
+	"tailscale.com/ipn/ipnlocal"
 	"tailscale.com/net/packet"
 	"tailscale.com/net/tsaddr"
 	"tailscale.com/net/tsdial"
 	"tailscale.com/net/tstun"
 	"tailscale.com/syncs"
+	"tailscale.com/types/ipproto"
 	"tailscale.com/types/logger"
 	"tailscale.com/types/netmap"
 	"tailscale.com/version/distro"
@@ -82,6 +84,7 @@ type Impl struct {
 	dialer    *tsdial.Dialer
 	ctx       context.Context    // alive until Close
 	ctxCancel context.CancelFunc // called on Close
+	lb        *ipnlocal.LocalBackend
 
 	// atomicIsLocalIPFunc holds a func that reports whether an IP
 	// is a local (non-subnet) Tailscale IP address of this
@@ -97,6 +100,10 @@ type Impl struct {
 	connsOpenBySubnetIP map[netaddr.IP]int
 }
 
+// sshDemo is initialized in ssh.go (on Linux only) to register an SSH server
+// handler. See https://github.com/tailscale/tailscale/issues/3802.
+var sshDemo func(*Impl, net.Conn) error
+
 const nicID = 1
 const mtu = 1500
 
@@ -165,6 +172,12 @@ func (ns *Impl) Close() error {
 	return nil
 }
 
+// SetLocalBackend sets the LocalBackend; it should only be run before
+// the Start method is called.
+func (ns *Impl) SetLocalBackend(lb *ipnlocal.LocalBackend) {
+	ns.lb = lb
+}
+
 // wrapProtoHandler returns protocol handler h wrapped in a version
 // that dynamically reconfigures ns's subnet addresses as needed for
 // outbound traffic.
@@ -252,8 +265,9 @@ func (ns *Impl) updateIPs(nm *netmap.NetworkMap) {
 		ap := protocolAddr.AddressWithPrefix
 		ip := netaddrIPFromNetstackIP(ap.Address)
 		if ip == v4broadcast && ap.PrefixLen == 32 {
-			// Don't delete this one later. It seems to be important.
-			// Related to Issue 2642? Likely.
+			// Don't add 255.255.255.255/32 to oldIPs so we don't
+			// delete it later. We didn't install it, so it's not
+			// ours to delete.
 			continue
 		}
 		oldIPs[ap] = true
@@ -264,10 +278,10 @@ func (ns *Impl) updateIPs(nm *netmap.NetworkMap) {
 	if nm.SelfNode != nil {
 		for _, ipp := range nm.SelfNode.Addresses {
 			isAddr[ipp] = true
+			newIPs[ipPrefixToAddressWithPrefix(ipp)] = true
 		}
 		for _, ipp := range nm.SelfNode.AllowedIPs {
-			local := isAddr[ipp]
-			if local && ns.ProcessLocalIPs || !local && ns.ProcessSubnets {
+			if !isAddr[ipp] && ns.ProcessSubnets {
 				newIPs[ipPrefixToAddressWithPrefix(ipp)] = true
 			}
 		}
@@ -390,9 +404,16 @@ func (ns *Impl) isLocalIP(ip netaddr.IP) bool {
 	return ns.atomicIsLocalIPFunc.Load().(func(netaddr.IP) bool)(ip)
 }
 
+func (ns *Impl) processSSH() bool {
+	return ns.lb != nil && ns.lb.ShouldRunSSH()
+}
+
 // shouldProcessInbound reports whether an inbound packet should be
 // handled by netstack.
 func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
+	if ns.isInboundTSSH(p) && ns.processSSH() {
+		return true
+	}
 	if !ns.ProcessLocalIPs && !ns.ProcessSubnets {
 		// Fast path for common case (e.g. Linux server in TUN mode) where
 		// netstack isn't used at all; don't even do an isLocalIP lookup.
@@ -484,6 +505,12 @@ func (ns *Impl) userPing(dstIP netaddr.IP, pingResPkt []byte) {
 	}
 }
 
+func (ns *Impl) isInboundTSSH(p *packet.Parsed) bool {
+	return p.IPProto == ipproto.TCP &&
+		p.Dst.Port() == 22 &&
+		ns.isLocalIP(p.Dst.IP())
+}
+
 func (ns *Impl) injectInbound(p *packet.Parsed, t *tstun.Wrapper) filter.Response {
 	if !ns.shouldProcessInbound(p, t) {
 		// Let the host network stack (if any) deal with it.
@@ -585,6 +612,16 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
 	// block until the TCP handshake is complete.
 	c := gonet.NewTCPConn(&wq, ep)
 
+	if reqDetails.LocalPort == 22 && ns.processSSH() && ns.isLocalIP(dialIP) && sshDemo != nil {
+		// TODO(bradfitz): un-demo this.
+		ns.logf("doing ssh demo thing....")
+		if err := sshDemo(ns, c); err != nil {
+			ns.logf("ssh demo error: %v", err)
+		} else {
+			ns.logf("ssh demo: ok")
+		}
+		return
+	}
 	if ns.ForwardTCPIn != nil {
 		ns.ForwardTCPIn(c, reqDetails.LocalPort)
 		return

+ 139 - 0
wgengine/netstack/ssh.go

@@ -0,0 +1,139 @@
+// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build linux
+// +build linux
+
+package netstack
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"net"
+	"os"
+	"os/exec"
+	"syscall"
+	"unsafe"
+
+	"github.com/creack/pty"
+	"github.com/gliderlabs/ssh"
+	gossh "golang.org/x/crypto/ssh"
+	"inet.af/netaddr"
+	"tailscale.com/envknob"
+	"tailscale.com/net/tsaddr"
+)
+
+func init() {
+	sshDemo = sshDemoImpl
+}
+
+func sshDemoImpl(ns *Impl, c net.Conn) error {
+	hostKey, err := ioutil.ReadFile("/etc/ssh/ssh_host_ed25519_key")
+	if err != nil {
+		return err
+	}
+	signer, err := gossh.ParsePrivateKey(hostKey)
+	if err != nil {
+		return err
+	}
+	srv := &ssh.Server{
+		Handler:           ns.handleSSH,
+		RequestHandlers:   map[string]ssh.RequestHandler{},
+		SubsystemHandlers: map[string]ssh.SubsystemHandler{},
+		ChannelHandlers:   map[string]ssh.ChannelHandler{},
+	}
+	for k, v := range ssh.DefaultRequestHandlers {
+		srv.RequestHandlers[k] = v
+	}
+	for k, v := range ssh.DefaultChannelHandlers {
+		srv.ChannelHandlers[k] = v
+	}
+	for k, v := range ssh.DefaultSubsystemHandlers {
+		srv.SubsystemHandlers[k] = v
+	}
+	srv.AddHostKey(signer)
+
+	srv.HandleConn(c)
+	return nil
+}
+
+func (ns *Impl) handleSSH(s ssh.Session) {
+	lb := ns.lb
+	user := s.User()
+	addr := s.RemoteAddr()
+	log.Printf("Handling SSH from %v for user %v", addr, user)
+	ta, ok := addr.(*net.TCPAddr)
+	if !ok {
+		log.Printf("tsshd: rejecting non-TCP addr %T %v", addr, addr)
+		s.Exit(1)
+		return
+	}
+	tanetaddr, ok := netaddr.FromStdIP(ta.IP)
+	if !ok {
+		log.Printf("tsshd: rejecting unparseable addr %v", ta.IP)
+		s.Exit(1)
+		return
+	}
+	if !tsaddr.IsTailscaleIP(tanetaddr) {
+		log.Printf("tsshd: rejecting non-Tailscale addr %v", ta.IP)
+		s.Exit(1)
+		return
+	}
+
+	ptyReq, winCh, isPty := s.Pty()
+	if !isPty {
+		fmt.Fprintf(s, "TODO scp etc")
+		s.Exit(1)
+		return
+	}
+	srcIPP := netaddr.IPPortFrom(tanetaddr, uint16(ta.Port))
+	node, uprof, ok := lb.WhoIs(srcIPP)
+	if !ok {
+		fmt.Fprintf(s, "Hello, %v. I don't know who you are.\n", srcIPP)
+		s.Exit(0)
+		return
+	}
+	allow := envknob.String("TS_SSH_ALLOW_LOGIN")
+	if allow == "" || uprof.LoginName != allow {
+		log.Printf("ssh: access denied for %q (only allowing %q)", uprof.LoginName, allow)
+		jnode, _ := json.Marshal(node)
+		jprof, _ := json.Marshal(uprof)
+		fmt.Fprintf(s, "Access denied.\n\nYou are node: %s\n\nYour profile: %s\n\nYou wanted %+v\n", jnode, jprof, ptyReq)
+		s.Exit(1)
+		return
+	}
+
+	cmd := exec.Command("/bin/bash")
+	cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
+	f, err := pty.Start(cmd)
+	if err != nil {
+		log.Printf("running shell: %v", err)
+		s.Exit(1)
+		return
+	}
+	defer f.Close()
+	go func() {
+		for win := range winCh {
+			setWinsize(f, win.Width, win.Height)
+		}
+	}()
+	go func() {
+		io.Copy(f, s) // stdin
+	}()
+	io.Copy(s, f) // stdout
+	cmd.Process.Kill()
+	if err := cmd.Wait(); err != nil {
+		s.Exit(1)
+	}
+	s.Exit(0)
+	return
+}
+
+func setWinsize(f *os.File, w, h int) {
+	syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
+		uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
+}