diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 77ea399d5..6eb720de2 100644 --- a/cmd/tailscale/cli/up.go +++ b/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": diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 1107d3bcc..6ef206a3f 100644 --- a/cmd/tailscaled/depaware.txt +++ b/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+ diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 1fc47bf69..80743e522 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/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) diff --git a/envknob/envknob.go b/envknob/envknob.go index 850ca6b0d..048424a5f 100644 --- a/envknob/envknob.go +++ b/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") } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index a48949c86..7741b0a81 100644 --- a/ipn/ipnlocal/local.go +++ b/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. diff --git a/ipn/prefs.go b/ipn/prefs.go index 275edf3ba..813fc4cd4 100644 --- a/ipn/prefs.go +++ b/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 && diff --git a/ipn/prefs_clone.go b/ipn/prefs_clone.go index ef6d5f462..a53331603 100644 --- a/ipn/prefs_clone.go +++ b/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 diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index ee0fc5443..d9204b901 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -42,6 +42,7 @@ func TestPrefsEqual(t *testing.T) { "ExitNodeIP", "ExitNodeAllowLANAccess", "CorpDNS", + "RunSSH", "WantRunning", "LoggedOut", "ShieldsUp", diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 418d545c4..6956f4df6 100644 --- a/tsnet/tsnet.go +++ b/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") } diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go index b40f38f17..d2302a7df 100644 --- a/wgengine/netstack/netstack.go +++ b/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 diff --git a/wgengine/netstack/ssh.go b/wgengine/netstack/ssh.go new file mode 100644 index 000000000..bd70bfc8b --- /dev/null +++ b/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}))) +}