wgengine/netstack: add an SSH server experiment

Disabled by default.

To use, run tailscaled with:

    TS_SSH_ALLOW_LOGIN=you@bar.com

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 <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2021-08-26 14:50:55 -07:00 committed by Brad Fitzpatrick
parent 41fd4eab5c
commit f3c0023add
11 changed files with 233 additions and 14 deletions

View File

@ -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":

View File

@ -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+

View File

@ -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)

View File

@ -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") }

View File

@ -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.

View File

@ -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 &&

View File

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

View File

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

View File

@ -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")
}

View File

@ -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
wgengine/netstack/ssh.go Normal file
View File

@ -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})))
}