diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index d8142048d..04710e868 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -3,7 +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 - LD github.com/anmitsu/go-shlex from github.com/tailscale/ssh + LD github.com/anmitsu/go-shlex from tailscale.com/tempfork/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/awsstore @@ -90,6 +90,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket W github.com/pkg/errors from github.com/tailscale/certstore W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient + LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh + LD 💣 github.com/tailscale/golang-x-crypto/internal/subtle from github.com/tailscale/golang-x-crypto/chacha20 + LD github.com/tailscale/golang-x-crypto/ssh from tailscale.com/ipn/ipnlocal+ + LD github.com/tailscale/golang-x-crypto/ssh/internal/bcrypt_pbkdf from github.com/tailscale/golang-x-crypto/ssh github.com/tailscale/goupnp from github.com/tailscale/goupnp/dcps/internetgateway2+ github.com/tailscale/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper github.com/tailscale/goupnp/httpu from github.com/tailscale/goupnp+ @@ -97,7 +101,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+ github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router - LD github.com/tailscale/ssh from tailscale.com/ssh/tailssh github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4 @@ -230,6 +233,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de LD 💣 tailscale.com/ssh/tailssh from tailscale.com/wgengine/netstack tailscale.com/syncs from tailscale.com/control/controlknobs+ tailscale.com/tailcfg from tailscale.com/client/tailscale+ + LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh W tailscale.com/tsconst from tailscale.com/net/interfaces tailscale.com/tstime from tailscale.com/wgengine/magicsock 💣 tailscale.com/tstime/mono from tailscale.com/net/tstun+ @@ -281,19 +285,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+ - LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf + LD 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+ - LD golang.org/x/crypto/ed25519 from golang.org/x/crypto/ssh + LD 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.zx2c4.com/wireguard/device + golang.org/x/crypto/poly1305 from golang.zx2c4.com/wireguard/device+ golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ - LD golang.org/x/crypto/ssh from github.com/tailscale/ssh+ + LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh golang.org/x/net/bpf from github.com/mdlayher/genetlink+ golang.org/x/net/dns/dnsmessage from net+ golang.org/x/net/http/httpguts from golang.org/x/net/http2+ diff --git a/cmd/tsshd/tsshd.go b/cmd/tsshd/tsshd.go index 8da41798a..e19438a2c 100644 --- a/cmd/tsshd/tsshd.go +++ b/cmd/tsshd/tsshd.go @@ -31,11 +31,11 @@ "unsafe" "github.com/creack/pty" - "github.com/tailscale/ssh" - gossh "golang.org/x/crypto/ssh" + gossh "github.com/tailscale/golang-x-crypto/ssh" "inet.af/netaddr" "tailscale.com/net/interfaces" "tailscale.com/net/tsaddr" + "tailscale.com/tempfork/gliderlabs/ssh" ) var ( diff --git a/go.mod b/go.mod index 2fe4aefbe..2cf49038a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/akutz/memconn v0.1.0 github.com/alessio/shellescape v1.4.1 github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/aws/aws-sdk-go-v2 v1.11.2 github.com/aws/aws-sdk-go-v2/config v1.11.0 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.7.4 @@ -16,6 +17,7 @@ require ( github.com/creack/pty v1.1.17 github.com/dave/jennifer v1.4.1 github.com/frankban/quicktest v1.14.0 + github.com/gliderlabs/ssh v0.3.3 github.com/go-ole/go-ole v1.2.6 github.com/godbus/dbus/v5 v5.0.6 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da @@ -42,7 +44,6 @@ require ( github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 github.com/tailscale/hujson v0.0.0-20211105212140-3a0adc019d83 github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 - github.com/tailscale/ssh v0.3.4-0.20220313013419-be8b7add4057 github.com/tcnksm/go-httpstat v0.2.0 github.com/toqueteos/webbrowser v1.2.0 github.com/u-root/u-root v0.8.0 @@ -80,7 +81,6 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect github.com/alexkohler/prealloc v1.0.0 // indirect - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/ashanbrown/forbidigo v1.2.0 // indirect github.com/ashanbrown/makezero v0.0.0-20210520155254-b6261585ddde // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 // indirect @@ -116,7 +116,6 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/fzipp/gocyclo v0.3.1 // indirect - github.com/gliderlabs/ssh v0.3.3 // indirect github.com/go-critic/go-critic v0.6.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect github.com/go-git/go-billy/v5 v5.3.1 // indirect @@ -234,6 +233,7 @@ require ( github.com/stretchr/testify v1.7.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/sylvia7788/contextcheck v1.0.4 // indirect + github.com/tailscale/golang-x-crypto v0.0.0-20220326011347-d690bbfb6b5f // indirect github.com/tdakkota/asciicheck v0.1.1 // indirect github.com/tetafro/godot v1.4.11 // indirect github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144 // indirect diff --git a/go.sum b/go.sum index 5548c874b..07bb4242f 100644 --- a/go.sum +++ b/go.sum @@ -1087,14 +1087,14 @@ github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HP github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns= github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ= +github.com/tailscale/golang-x-crypto v0.0.0-20220326011347-d690bbfb6b5f h1:SO0bJlfWstNuolA3zjWDcLq0mjLfIw6RWEImAPxCkSU= +github.com/tailscale/golang-x-crypto v0.0.0-20220326011347-d690bbfb6b5f/go.mod h1:95n9fbUCixVSI4QXLEvdKJjnYK2eUlkTx9+QwLPXFKU= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20211105212140-3a0adc019d83 h1:f7nwzdAHTUUOJjHZuDvLz9CEAlUM228amCRvwzlPvsA= github.com/tailscale/hujson v0.0.0-20211105212140-3a0adc019d83/go.mod h1:iTDXJsA6A2wNNjurgic2rk+is6uzU4U2NLm4T+edr6M= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/ssh v0.3.4-0.20220313013419-be8b7add4057 h1:aN1DLGpS7j6LLQaM33w4Lo6Otvq8Rx60D2ciI/UC1KQ= -github.com/tailscale/ssh v0.3.4-0.20220313013419-be8b7add4057/go.mod h1:LC21Rp6xYOAc7NEeMhYN0xTXUB74MZAN60GRPegZLkg= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= diff --git a/ipn/ipnlocal/ssh.go b/ipn/ipnlocal/ssh.go index 8689fe2d4..e02d25294 100644 --- a/ipn/ipnlocal/ssh.go +++ b/ipn/ipnlocal/ssh.go @@ -23,7 +23,7 @@ "strings" "sync" - "golang.org/x/crypto/ssh" + "github.com/tailscale/golang-x-crypto/ssh" "tailscale.com/envknob" ) diff --git a/ssh/tailssh/incubator.go b/ssh/tailssh/incubator.go index dc073883f..fdd3acff0 100644 --- a/ssh/tailssh/incubator.go +++ b/ssh/tailssh/incubator.go @@ -29,11 +29,11 @@ "syscall" "github.com/creack/pty" - "github.com/tailscale/ssh" "github.com/u-root/u-root/pkg/termios" gossh "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" "tailscale.com/cmd/tailscaled/childproc" + "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" ) diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index def871370..f679d23f4 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -29,13 +29,13 @@ "sync" "time" - "github.com/tailscale/ssh" "inet.af/netaddr" "tailscale.com/envknob" "tailscale.com/ipn/ipnlocal" "tailscale.com/logtail/backoff" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" ) diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 57767d6ab..afae4984a 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -19,12 +19,12 @@ "testing" "time" - "github.com/tailscale/ssh" "inet.af/netaddr" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/store/mem" "tailscale.com/net/tsdial" "tailscale.com/tailcfg" + "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" "tailscale.com/util/cibuild" "tailscale.com/util/lineread" diff --git a/tempfork/gliderlabs/ssh/LICENSE b/tempfork/gliderlabs/ssh/LICENSE new file mode 100644 index 000000000..4a03f02a2 --- /dev/null +++ b/tempfork/gliderlabs/ssh/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2016 Glider Labs. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Glider Labs nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tempfork/gliderlabs/ssh/README.md b/tempfork/gliderlabs/ssh/README.md new file mode 100644 index 000000000..79b5b89fa --- /dev/null +++ b/tempfork/gliderlabs/ssh/README.md @@ -0,0 +1,96 @@ +# gliderlabs/ssh + +[![GoDoc](https://godoc.org/tailscale.com/tempfork/gliderlabs/ssh?status.svg)](https://godoc.org/github.com/gliderlabs/ssh) +[![CircleCI](https://img.shields.io/circleci/project/github/gliderlabs/ssh.svg)](https://circleci.com/gh/gliderlabs/ssh) +[![Go Report Card](https://goreportcard.com/badge/tailscale.com/tempfork/gliderlabs/ssh)](https://goreportcard.com/report/github.com/gliderlabs/ssh) +[![OpenCollective](https://opencollective.com/ssh/sponsors/badge.svg)](#sponsors) +[![Slack](http://slack.gliderlabs.com/badge.svg)](http://slack.gliderlabs.com) +[![Email Updates](https://img.shields.io/badge/updates-subscribe-yellow.svg)](https://app.convertkit.com/landing_pages/243312) + +> The Glider Labs SSH server package is dope. —[@bradfitz](https://twitter.com/bradfitz), Go team member + +This Go package wraps the [crypto/ssh +package](https://godoc.org/golang.org/x/crypto/ssh) with a higher-level API for +building SSH servers. The goal of the API was to make it as simple as using +[net/http](https://golang.org/pkg/net/http/), so the API is very similar: + +```go + package main + + import ( + "tailscale.com/tempfork/gliderlabs/ssh" + "io" + "log" + ) + + func main() { + ssh.Handle(func(s ssh.Session) { + io.WriteString(s, "Hello world\n") + }) + + log.Fatal(ssh.ListenAndServe(":2222", nil)) + } + +``` +This package was built by [@progrium](https://twitter.com/progrium) after working on nearly a dozen projects at Glider Labs using SSH and collaborating with [@shazow](https://twitter.com/shazow) (known for [ssh-chat](https://github.com/shazow/ssh-chat)). + +## Examples + +A bunch of great examples are in the `_examples` directory. + +## Usage + +[See GoDoc reference.](https://godoc.org/tailscale.com/tempfork/gliderlabs/ssh) + +## Contributing + +Pull requests are welcome! However, since this project is very much about API +design, please submit API changes as issues to discuss before submitting PRs. + +Also, you can [join our Slack](http://slack.gliderlabs.com) to discuss as well. + +## Roadmap + +* Non-session channel handlers +* Cleanup callback API +* 1.0 release +* High-level client? + +## Sponsors + +Become a sponsor and get your logo on our README on Github with a link to your site. [[Become a sponsor](https://opencollective.com/ssh#sponsor)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +## License + +[BSD](LICENSE) diff --git a/tempfork/gliderlabs/ssh/agent.go b/tempfork/gliderlabs/ssh/agent.go new file mode 100644 index 000000000..f0ffb5b53 --- /dev/null +++ b/tempfork/gliderlabs/ssh/agent.go @@ -0,0 +1,83 @@ +package ssh + +import ( + "io" + "io/ioutil" + "net" + "path" + "sync" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +const ( + agentRequestType = "auth-agent-req@openssh.com" + agentChannelType = "auth-agent@openssh.com" + + agentTempDir = "auth-agent" + agentListenFile = "listener.sock" +) + +// contextKeyAgentRequest is an internal context key for storing if the +// client requested agent forwarding +var contextKeyAgentRequest = &contextKey{"auth-agent-req"} + +// SetAgentRequested sets up the session context so that AgentRequested +// returns true. +func SetAgentRequested(ctx Context) { + ctx.SetValue(contextKeyAgentRequest, true) +} + +// AgentRequested returns true if the client requested agent forwarding. +func AgentRequested(sess Session) bool { + return sess.Context().Value(contextKeyAgentRequest) == true +} + +// NewAgentListener sets up a temporary Unix socket that can be communicated +// to the session environment and used for forwarding connections. +func NewAgentListener() (net.Listener, error) { + dir, err := ioutil.TempDir("", agentTempDir) + if err != nil { + return nil, err + } + l, err := net.Listen("unix", path.Join(dir, agentListenFile)) + if err != nil { + return nil, err + } + return l, nil +} + +// ForwardAgentConnections takes connections from a listener to proxy into the +// session on the OpenSSH channel for agent connections. It blocks and services +// connections until the listener stop accepting. +func ForwardAgentConnections(l net.Listener, s Session) { + sshConn := s.Context().Value(ContextKeyConn).(gossh.Conn) + for { + conn, err := l.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + channel, reqs, err := sshConn.OpenChannel(agentChannelType, nil) + if err != nil { + return + } + defer channel.Close() + go gossh.DiscardRequests(reqs) + var wg sync.WaitGroup + wg.Add(2) + go func() { + io.Copy(conn, channel) + conn.(*net.UnixConn).CloseWrite() + wg.Done() + }() + go func() { + io.Copy(channel, conn) + channel.CloseWrite() + wg.Done() + }() + wg.Wait() + }(conn) + } +} diff --git a/tempfork/gliderlabs/ssh/conn.go b/tempfork/gliderlabs/ssh/conn.go new file mode 100644 index 000000000..ebef8845b --- /dev/null +++ b/tempfork/gliderlabs/ssh/conn.go @@ -0,0 +1,55 @@ +package ssh + +import ( + "context" + "net" + "time" +) + +type serverConn struct { + net.Conn + + idleTimeout time.Duration + maxDeadline time.Time + closeCanceler context.CancelFunc +} + +func (c *serverConn) Write(p []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Write(p) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Read(b []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Read(b) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Close() (err error) { + err = c.Conn.Close() + if c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) updateDeadline() { + switch { + case c.idleTimeout > 0: + idleDeadline := time.Now().Add(c.idleTimeout) + if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { + c.Conn.SetDeadline(idleDeadline) + return + } + fallthrough + default: + c.Conn.SetDeadline(c.maxDeadline) + } +} diff --git a/tempfork/gliderlabs/ssh/context.go b/tempfork/gliderlabs/ssh/context.go new file mode 100644 index 000000000..6715bcd4c --- /dev/null +++ b/tempfork/gliderlabs/ssh/context.go @@ -0,0 +1,155 @@ +package ssh + +import ( + "context" + "encoding/hex" + "net" + "sync" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +// contextKey is a value for use with context.WithValue. It's used as +// a pointer so it fits in an interface{} without allocation. +type contextKey struct { + name string +} + +var ( + // ContextKeyUser is a context key for use with Contexts in this package. + // The associated value will be of type string. + ContextKeyUser = &contextKey{"user"} + + // ContextKeySessionID is a context key for use with Contexts in this package. + // The associated value will be of type string. + ContextKeySessionID = &contextKey{"session-id"} + + // ContextKeyPermissions is a context key for use with Contexts in this package. + // The associated value will be of type *Permissions. + ContextKeyPermissions = &contextKey{"permissions"} + + // ContextKeyClientVersion is a context key for use with Contexts in this package. + // The associated value will be of type string. + ContextKeyClientVersion = &contextKey{"client-version"} + + // ContextKeyServerVersion is a context key for use with Contexts in this package. + // The associated value will be of type string. + ContextKeyServerVersion = &contextKey{"server-version"} + + // ContextKeyLocalAddr is a context key for use with Contexts in this package. + // The associated value will be of type net.Addr. + ContextKeyLocalAddr = &contextKey{"local-addr"} + + // ContextKeyRemoteAddr is a context key for use with Contexts in this package. + // The associated value will be of type net.Addr. + ContextKeyRemoteAddr = &contextKey{"remote-addr"} + + // ContextKeyServer is a context key for use with Contexts in this package. + // The associated value will be of type *Server. + ContextKeyServer = &contextKey{"ssh-server"} + + // ContextKeyConn is a context key for use with Contexts in this package. + // The associated value will be of type gossh.ServerConn. + ContextKeyConn = &contextKey{"ssh-conn"} + + // ContextKeyPublicKey is a context key for use with Contexts in this package. + // The associated value will be of type PublicKey. + ContextKeyPublicKey = &contextKey{"public-key"} +) + +// Context is a package specific context interface. It exposes connection +// metadata and allows new values to be easily written to it. It's used in +// authentication handlers and callbacks, and its underlying context.Context is +// exposed on Session in the session Handler. A connection-scoped lock is also +// embedded in the context to make it easier to limit operations per-connection. +type Context interface { + context.Context + sync.Locker + + // User returns the username used when establishing the SSH connection. + User() string + + // SessionID returns the session hash. + SessionID() string + + // ClientVersion returns the version reported by the client. + ClientVersion() string + + // ServerVersion returns the version reported by the server. + ServerVersion() string + + // RemoteAddr returns the remote address for this connection. + RemoteAddr() net.Addr + + // LocalAddr returns the local address for this connection. + LocalAddr() net.Addr + + // Permissions returns the Permissions object used for this connection. + Permissions() *Permissions + + // SetValue allows you to easily write new values into the underlying context. + SetValue(key, value interface{}) +} + +type sshContext struct { + context.Context + *sync.Mutex +} + +func newContext(srv *Server) (*sshContext, context.CancelFunc) { + innerCtx, cancel := context.WithCancel(context.Background()) + ctx := &sshContext{innerCtx, &sync.Mutex{}} + ctx.SetValue(ContextKeyServer, srv) + perms := &Permissions{&gossh.Permissions{}} + ctx.SetValue(ContextKeyPermissions, perms) + return ctx, cancel +} + +// this is separate from newContext because we will get ConnMetadata +// at different points so it needs to be applied separately +func applyConnMetadata(ctx Context, conn gossh.ConnMetadata) { + if ctx.Value(ContextKeySessionID) != nil { + return + } + ctx.SetValue(ContextKeySessionID, hex.EncodeToString(conn.SessionID())) + ctx.SetValue(ContextKeyClientVersion, string(conn.ClientVersion())) + ctx.SetValue(ContextKeyServerVersion, string(conn.ServerVersion())) + ctx.SetValue(ContextKeyUser, conn.User()) + ctx.SetValue(ContextKeyLocalAddr, conn.LocalAddr()) + ctx.SetValue(ContextKeyRemoteAddr, conn.RemoteAddr()) +} + +func (ctx *sshContext) SetValue(key, value interface{}) { + ctx.Context = context.WithValue(ctx.Context, key, value) +} + +func (ctx *sshContext) User() string { + return ctx.Value(ContextKeyUser).(string) +} + +func (ctx *sshContext) SessionID() string { + return ctx.Value(ContextKeySessionID).(string) +} + +func (ctx *sshContext) ClientVersion() string { + return ctx.Value(ContextKeyClientVersion).(string) +} + +func (ctx *sshContext) ServerVersion() string { + return ctx.Value(ContextKeyServerVersion).(string) +} + +func (ctx *sshContext) RemoteAddr() net.Addr { + if addr, ok := ctx.Value(ContextKeyRemoteAddr).(net.Addr); ok { + return addr + } + return nil +} + +func (ctx *sshContext) LocalAddr() net.Addr { + return ctx.Value(ContextKeyLocalAddr).(net.Addr) +} + +func (ctx *sshContext) Permissions() *Permissions { + return ctx.Value(ContextKeyPermissions).(*Permissions) +} diff --git a/tempfork/gliderlabs/ssh/context_test.go b/tempfork/gliderlabs/ssh/context_test.go new file mode 100644 index 000000000..d7c0b2406 --- /dev/null +++ b/tempfork/gliderlabs/ssh/context_test.go @@ -0,0 +1,50 @@ +//go:build glidertests +// +build glidertests + +package ssh + +import "testing" + +func TestSetPermissions(t *testing.T) { + t.Parallel() + permsExt := map[string]string{ + "foo": "bar", + } + session, _, cleanup := newTestSessionWithOptions(t, &Server{ + Handler: func(s Session) { + if _, ok := s.Permissions().Extensions["foo"]; !ok { + t.Fatalf("got %#v; want %#v", s.Permissions().Extensions, permsExt) + } + }, + }, nil, PasswordAuth(func(ctx Context, password string) bool { + ctx.Permissions().Extensions = permsExt + return true + })) + defer cleanup() + if err := session.Run(""); err != nil { + t.Fatal(err) + } +} + +func TestSetValue(t *testing.T) { + t.Parallel() + value := map[string]string{ + "foo": "bar", + } + key := "testValue" + session, _, cleanup := newTestSessionWithOptions(t, &Server{ + Handler: func(s Session) { + v := s.Context().Value(key).(map[string]string) + if v["foo"] != value["foo"] { + t.Fatalf("got %#v; want %#v", v, value) + } + }, + }, nil, PasswordAuth(func(ctx Context, password string) bool { + ctx.SetValue(key, value) + return true + })) + defer cleanup() + if err := session.Run(""); err != nil { + t.Fatal(err) + } +} diff --git a/tempfork/gliderlabs/ssh/doc.go b/tempfork/gliderlabs/ssh/doc.go new file mode 100644 index 000000000..5a10393c2 --- /dev/null +++ b/tempfork/gliderlabs/ssh/doc.go @@ -0,0 +1,45 @@ +/* +Package ssh wraps the crypto/ssh package with a higher-level API for building +SSH servers. The goal of the API was to make it as simple as using net/http, so +the API is very similar. + +You should be able to build any SSH server using only this package, which wraps +relevant types and some functions from crypto/ssh. However, you still need to +use crypto/ssh for building SSH clients. + +ListenAndServe starts an SSH server with a given address, handler, and options. The +handler is usually nil, which means to use DefaultHandler. Handle sets DefaultHandler: + + ssh.Handle(func(s ssh.Session) { + io.WriteString(s, "Hello world\n") + }) + + log.Fatal(ssh.ListenAndServe(":2222", nil)) + +If you don't specify a host key, it will generate one every time. This is convenient +except you'll have to deal with clients being confused that the host key is different. +It's a better idea to generate or point to an existing key on your system: + + log.Fatal(ssh.ListenAndServe(":2222", nil, ssh.HostKeyFile("/Users/progrium/.ssh/id_rsa"))) + +Although all options have functional option helpers, another way to control the +server's behavior is by creating a custom Server: + + s := &ssh.Server{ + Addr: ":2222", + Handler: sessionHandler, + PublicKeyHandler: authHandler, + } + s.AddHostKey(hostKeySigner) + + log.Fatal(s.ListenAndServe()) + +This package automatically handles basic SSH requests like setting environment +variables, requesting PTY, and changing window size. These requests are +processed, responded to, and any relevant state is updated. This state is then +exposed to you via the Session interface. + +The one big feature missing from the Session abstraction is signals. This was +started, but not completed. Pull Requests welcome! +*/ +package ssh diff --git a/tempfork/gliderlabs/ssh/example_test.go b/tempfork/gliderlabs/ssh/example_test.go new file mode 100644 index 000000000..1425d7a34 --- /dev/null +++ b/tempfork/gliderlabs/ssh/example_test.go @@ -0,0 +1,40 @@ +package ssh_test + +import ( + "io" + "io/ioutil" + + "tailscale.com/tempfork/gliderlabs/ssh" +) + +func ExampleListenAndServe() { + ssh.ListenAndServe(":2222", func(s ssh.Session) { + io.WriteString(s, "Hello world\n") + }) +} + +func ExamplePasswordAuth() { + ssh.ListenAndServe(":2222", nil, + ssh.PasswordAuth(func(ctx ssh.Context, pass string) bool { + return pass == "secret" + }), + ) +} + +func ExampleNoPty() { + ssh.ListenAndServe(":2222", nil, ssh.NoPty()) +} + +func ExamplePublicKeyAuth() { + ssh.ListenAndServe(":2222", nil, + ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + data, _ := ioutil.ReadFile("/path/to/allowed/key.pub") + allowed, _, _, _, _ := ssh.ParseAuthorizedKey(data) + return ssh.KeysEqual(key, allowed) + }), + ) +} + +func ExampleHostKeyFile() { + ssh.ListenAndServe(":2222", nil, ssh.HostKeyFile("/path/to/host/key")) +} diff --git a/tempfork/gliderlabs/ssh/options.go b/tempfork/gliderlabs/ssh/options.go new file mode 100644 index 000000000..18ec45416 --- /dev/null +++ b/tempfork/gliderlabs/ssh/options.go @@ -0,0 +1,84 @@ +package ssh + +import ( + "io/ioutil" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +// PasswordAuth returns a functional option that sets PasswordHandler on the server. +func PasswordAuth(fn PasswordHandler) Option { + return func(srv *Server) error { + srv.PasswordHandler = fn + return nil + } +} + +// PublicKeyAuth returns a functional option that sets PublicKeyHandler on the server. +func PublicKeyAuth(fn PublicKeyHandler) Option { + return func(srv *Server) error { + srv.PublicKeyHandler = fn + return nil + } +} + +// HostKeyFile returns a functional option that adds HostSigners to the server +// from a PEM file at filepath. +func HostKeyFile(filepath string) Option { + return func(srv *Server) error { + pemBytes, err := ioutil.ReadFile(filepath) + if err != nil { + return err + } + + signer, err := gossh.ParsePrivateKey(pemBytes) + if err != nil { + return err + } + + srv.AddHostKey(signer) + + return nil + } +} + +func KeyboardInteractiveAuth(fn KeyboardInteractiveHandler) Option { + return func(srv *Server) error { + srv.KeyboardInteractiveHandler = fn + return nil + } +} + +// HostKeyPEM returns a functional option that adds HostSigners to the server +// from a PEM file as bytes. +func HostKeyPEM(bytes []byte) Option { + return func(srv *Server) error { + signer, err := gossh.ParsePrivateKey(bytes) + if err != nil { + return err + } + + srv.AddHostKey(signer) + + return nil + } +} + +// NoPty returns a functional option that sets PtyCallback to return false, +// denying PTY requests. +func NoPty() Option { + return func(srv *Server) error { + srv.PtyCallback = func(ctx Context, pty Pty) bool { + return false + } + return nil + } +} + +// WrapConn returns a functional option that sets ConnCallback on the server. +func WrapConn(fn ConnCallback) Option { + return func(srv *Server) error { + srv.ConnCallback = fn + return nil + } +} diff --git a/tempfork/gliderlabs/ssh/options_test.go b/tempfork/gliderlabs/ssh/options_test.go new file mode 100644 index 000000000..3d4415c38 --- /dev/null +++ b/tempfork/gliderlabs/ssh/options_test.go @@ -0,0 +1,112 @@ +//go:build glidertests +// +build glidertests + +package ssh + +import ( + "net" + "strings" + "sync/atomic" + "testing" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +func newTestSessionWithOptions(t *testing.T, srv *Server, cfg *gossh.ClientConfig, options ...Option) (*gossh.Session, *gossh.Client, func()) { + for _, option := range options { + if err := srv.SetOption(option); err != nil { + t.Fatal(err) + } + } + return newTestSession(t, srv, cfg) +} + +func TestPasswordAuth(t *testing.T) { + t.Parallel() + testUser := "testuser" + testPass := "testpass" + session, _, cleanup := newTestSessionWithOptions(t, &Server{ + Handler: func(s Session) { + // noop + }, + }, &gossh.ClientConfig{ + User: testUser, + Auth: []gossh.AuthMethod{ + gossh.Password(testPass), + }, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }, PasswordAuth(func(ctx Context, password string) bool { + if ctx.User() != testUser { + t.Fatalf("user = %#v; want %#v", ctx.User(), testUser) + } + if password != testPass { + t.Fatalf("user = %#v; want %#v", password, testPass) + } + return true + })) + defer cleanup() + if err := session.Run(""); err != nil { + t.Fatal(err) + } +} + +func TestPasswordAuthBadPass(t *testing.T) { + t.Parallel() + l := newLocalListener() + srv := &Server{Handler: func(s Session) {}} + srv.SetOption(PasswordAuth(func(ctx Context, password string) bool { + return false + })) + go srv.serveOnce(l) + _, err := gossh.Dial("tcp", l.Addr().String(), &gossh.ClientConfig{ + User: "testuser", + Auth: []gossh.AuthMethod{ + gossh.Password("testpass"), + }, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }) + if err != nil { + if !strings.Contains(err.Error(), "unable to authenticate") { + t.Fatal(err) + } + } +} + +type wrappedConn struct { + net.Conn + written int32 +} + +func (c *wrappedConn) Write(p []byte) (n int, err error) { + n, err = c.Conn.Write(p) + atomic.AddInt32(&(c.written), int32(n)) + return +} + +func TestConnWrapping(t *testing.T) { + t.Parallel() + var wrapped *wrappedConn + session, _, cleanup := newTestSessionWithOptions(t, &Server{ + Handler: func(s Session) { + // nothing + }, + }, &gossh.ClientConfig{ + User: "testuser", + Auth: []gossh.AuthMethod{ + gossh.Password("testpass"), + }, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }, PasswordAuth(func(ctx Context, password string) bool { + return true + }), WrapConn(func(ctx Context, conn net.Conn) net.Conn { + wrapped = &wrappedConn{conn, 0} + return wrapped + })) + defer cleanup() + if err := session.Shell(); err != nil { + t.Fatal(err) + } + if atomic.LoadInt32(&(wrapped.written)) == 0 { + t.Fatal("wrapped conn not written to") + } +} diff --git a/tempfork/gliderlabs/ssh/server.go b/tempfork/gliderlabs/ssh/server.go new file mode 100644 index 000000000..934139e2c --- /dev/null +++ b/tempfork/gliderlabs/ssh/server.go @@ -0,0 +1,449 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "time" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +// ErrServerClosed is returned by the Server's Serve, ListenAndServe, +// and ListenAndServeTLS methods after a call to Shutdown or Close. +var ErrServerClosed = errors.New("ssh: Server closed") + +type SubsystemHandler func(s Session) + +var DefaultSubsystemHandlers = map[string]SubsystemHandler{} + +type RequestHandler func(ctx Context, srv *Server, req *gossh.Request) (ok bool, payload []byte) + +var DefaultRequestHandlers = map[string]RequestHandler{} + +type ChannelHandler func(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) + +var DefaultChannelHandlers = map[string]ChannelHandler{ + "session": DefaultSessionHandler, +} + +// Server defines parameters for running an SSH server. The zero value for +// Server is a valid configuration. When both PasswordHandler and +// PublicKeyHandler are nil, no client authentication is performed. +type Server struct { + Addr string // TCP address to listen on, ":22" if empty + Handler Handler // handler to invoke, ssh.DefaultHandler if nil + HostSigners []Signer // private keys for the host key, must have at least one + Version string // server version to be sent before the initial handshake + + KeyboardInteractiveHandler KeyboardInteractiveHandler // keyboard-interactive authentication handler + PasswordHandler PasswordHandler // password authentication handler + PublicKeyHandler PublicKeyHandler // public key authentication handler + PtyCallback PtyCallback // callback for allowing PTY sessions, allows all if nil + ConnCallback ConnCallback // optional callback for wrapping net.Conn before handling + LocalPortForwardingCallback LocalPortForwardingCallback // callback for allowing local port forwarding, denies all if nil + ReversePortForwardingCallback ReversePortForwardingCallback // callback for allowing reverse port forwarding, denies all if nil + ServerConfigCallback ServerConfigCallback // callback for configuring detailed SSH options + SessionRequestCallback SessionRequestCallback // callback for allowing or denying SSH sessions + + ConnectionFailedCallback ConnectionFailedCallback // callback to report connection failures + + IdleTimeout time.Duration // connection timeout when no activity, none if empty + MaxTimeout time.Duration // absolute connection timeout, none if empty + + // ChannelHandlers allow overriding the built-in session handlers or provide + // extensions to the protocol, such as tcpip forwarding. By default only the + // "session" handler is enabled. + ChannelHandlers map[string]ChannelHandler + + // RequestHandlers allow overriding the server-level request handlers or + // provide extensions to the protocol, such as tcpip forwarding. By default + // no handlers are enabled. + RequestHandlers map[string]RequestHandler + + // SubsystemHandlers are handlers which are similar to the usual SSH command + // handlers, but handle named subsystems. + SubsystemHandlers map[string]SubsystemHandler + + listenerWg sync.WaitGroup + mu sync.RWMutex + listeners map[net.Listener]struct{} + conns map[*gossh.ServerConn]struct{} + connWg sync.WaitGroup + doneChan chan struct{} +} + +func (srv *Server) ensureHostSigner() error { + srv.mu.Lock() + defer srv.mu.Unlock() + + if len(srv.HostSigners) == 0 { + signer, err := generateSigner() + if err != nil { + return err + } + srv.HostSigners = append(srv.HostSigners, signer) + } + return nil +} + +func (srv *Server) ensureHandlers() { + srv.mu.Lock() + defer srv.mu.Unlock() + + if srv.RequestHandlers == nil { + srv.RequestHandlers = map[string]RequestHandler{} + for k, v := range DefaultRequestHandlers { + srv.RequestHandlers[k] = v + } + } + if srv.ChannelHandlers == nil { + srv.ChannelHandlers = map[string]ChannelHandler{} + for k, v := range DefaultChannelHandlers { + srv.ChannelHandlers[k] = v + } + } + if srv.SubsystemHandlers == nil { + srv.SubsystemHandlers = map[string]SubsystemHandler{} + for k, v := range DefaultSubsystemHandlers { + srv.SubsystemHandlers[k] = v + } + } +} + +func (srv *Server) config(ctx Context) *gossh.ServerConfig { + srv.mu.RLock() + defer srv.mu.RUnlock() + + var config *gossh.ServerConfig + if srv.ServerConfigCallback == nil { + config = &gossh.ServerConfig{} + } else { + config = srv.ServerConfigCallback(ctx) + } + for _, signer := range srv.HostSigners { + config.AddHostKey(signer) + } + if srv.PasswordHandler == nil && srv.PublicKeyHandler == nil && srv.KeyboardInteractiveHandler == nil { + config.NoClientAuth = true + } + if srv.Version != "" { + config.ServerVersion = "SSH-2.0-" + srv.Version + } + if srv.PasswordHandler != nil { + config.PasswordCallback = func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) { + applyConnMetadata(ctx, conn) + if ok := srv.PasswordHandler(ctx, string(password)); !ok { + return ctx.Permissions().Permissions, fmt.Errorf("permission denied") + } + return ctx.Permissions().Permissions, nil + } + } + if srv.PublicKeyHandler != nil { + config.PublicKeyCallback = func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + applyConnMetadata(ctx, conn) + if ok := srv.PublicKeyHandler(ctx, key); !ok { + return ctx.Permissions().Permissions, fmt.Errorf("permission denied") + } + ctx.SetValue(ContextKeyPublicKey, key) + return ctx.Permissions().Permissions, nil + } + } + if srv.KeyboardInteractiveHandler != nil { + config.KeyboardInteractiveCallback = func(conn gossh.ConnMetadata, challenger gossh.KeyboardInteractiveChallenge) (*gossh.Permissions, error) { + applyConnMetadata(ctx, conn) + if ok := srv.KeyboardInteractiveHandler(ctx, challenger); !ok { + return ctx.Permissions().Permissions, fmt.Errorf("permission denied") + } + return ctx.Permissions().Permissions, nil + } + } + return config +} + +// Handle sets the Handler for the server. +func (srv *Server) Handle(fn Handler) { + srv.mu.Lock() + defer srv.mu.Unlock() + + srv.Handler = fn +} + +// Close immediately closes all active listeners and all active +// connections. +// +// Close returns any error returned from closing the Server's +// underlying Listener(s). +func (srv *Server) Close() error { + srv.mu.Lock() + defer srv.mu.Unlock() + + srv.closeDoneChanLocked() + err := srv.closeListenersLocked() + for c := range srv.conns { + c.Close() + delete(srv.conns, c) + } + return err +} + +// Shutdown gracefully shuts down the server without interrupting any +// active connections. Shutdown works by first closing all open +// listeners, and then waiting indefinitely for connections to close. +// If the provided context expires before the shutdown is complete, +// then the context's error is returned. +func (srv *Server) Shutdown(ctx context.Context) error { + srv.mu.Lock() + lnerr := srv.closeListenersLocked() + srv.closeDoneChanLocked() + srv.mu.Unlock() + + finished := make(chan struct{}, 1) + go func() { + srv.listenerWg.Wait() + srv.connWg.Wait() + finished <- struct{}{} + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-finished: + return lnerr + } +} + +// Serve accepts incoming connections on the Listener l, creating a new +// connection goroutine for each. The connection goroutines read requests and then +// calls srv.Handler to handle sessions. +// +// Serve always returns a non-nil error. +func (srv *Server) Serve(l net.Listener) error { + srv.ensureHandlers() + defer l.Close() + if err := srv.ensureHostSigner(); err != nil { + return err + } + if srv.Handler == nil { + srv.Handler = DefaultHandler + } + var tempDelay time.Duration + + srv.trackListener(l, true) + defer srv.trackListener(l, false) + for { + conn, e := l.Accept() + if e != nil { + select { + case <-srv.getDoneChan(): + return ErrServerClosed + default: + } + if ne, ok := e.(net.Error); ok && ne.Temporary() { + if tempDelay == 0 { + tempDelay = 5 * time.Millisecond + } else { + tempDelay *= 2 + } + if max := 1 * time.Second; tempDelay > max { + tempDelay = max + } + time.Sleep(tempDelay) + continue + } + return e + } + go srv.HandleConn(conn) + } +} + +func (srv *Server) HandleConn(newConn net.Conn) { + ctx, cancel := newContext(srv) + if srv.ConnCallback != nil { + cbConn := srv.ConnCallback(ctx, newConn) + if cbConn == nil { + newConn.Close() + return + } + newConn = cbConn + } + conn := &serverConn{ + Conn: newConn, + idleTimeout: srv.IdleTimeout, + closeCanceler: cancel, + } + if srv.MaxTimeout > 0 { + conn.maxDeadline = time.Now().Add(srv.MaxTimeout) + } + defer conn.Close() + sshConn, chans, reqs, err := gossh.NewServerConn(conn, srv.config(ctx)) + if err != nil { + if srv.ConnectionFailedCallback != nil { + srv.ConnectionFailedCallback(conn, err) + } + return + } + + srv.trackConn(sshConn, true) + defer srv.trackConn(sshConn, false) + + ctx.SetValue(ContextKeyConn, sshConn) + applyConnMetadata(ctx, sshConn) + //go gossh.DiscardRequests(reqs) + go srv.handleRequests(ctx, reqs) + for ch := range chans { + handler := srv.ChannelHandlers[ch.ChannelType()] + if handler == nil { + handler = srv.ChannelHandlers["default"] + } + if handler == nil { + ch.Reject(gossh.UnknownChannelType, "unsupported channel type") + continue + } + go handler(srv, sshConn, ch, ctx) + } +} + +func (srv *Server) handleRequests(ctx Context, in <-chan *gossh.Request) { + for req := range in { + handler := srv.RequestHandlers[req.Type] + if handler == nil { + handler = srv.RequestHandlers["default"] + } + if handler == nil { + req.Reply(false, nil) + continue + } + /*reqCtx, cancel := context.WithCancel(ctx) + defer cancel() */ + ret, payload := handler(ctx, srv, req) + req.Reply(ret, payload) + } +} + +// ListenAndServe listens on the TCP network address srv.Addr and then calls +// Serve to handle incoming connections. If srv.Addr is blank, ":22" is used. +// ListenAndServe always returns a non-nil error. +func (srv *Server) ListenAndServe() error { + addr := srv.Addr + if addr == "" { + addr = ":22" + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return srv.Serve(ln) +} + +// AddHostKey adds a private key as a host key. If an existing host key exists +// with the same algorithm, it is overwritten. Each server config must have at +// least one host key. +func (srv *Server) AddHostKey(key Signer) { + srv.mu.Lock() + defer srv.mu.Unlock() + + // these are later added via AddHostKey on ServerConfig, which performs the + // check for one of every algorithm. + + // This check is based on the AddHostKey method from the x/crypto/ssh + // library. This allows us to only keep one active key for each type on a + // server at once. So, if you're dynamically updating keys at runtime, this + // list will not keep growing. + for i, k := range srv.HostSigners { + if k.PublicKey().Type() == key.PublicKey().Type() { + srv.HostSigners[i] = key + return + } + } + + srv.HostSigners = append(srv.HostSigners, key) +} + +// SetOption runs a functional option against the server. +func (srv *Server) SetOption(option Option) error { + // NOTE: there is a potential race here for any option that doesn't call an + // internal method. We can't actually lock here because if something calls + // (as an example) AddHostKey, it will deadlock. + + //srv.mu.Lock() + //defer srv.mu.Unlock() + + return option(srv) +} + +func (srv *Server) getDoneChan() <-chan struct{} { + srv.mu.Lock() + defer srv.mu.Unlock() + + return srv.getDoneChanLocked() +} + +func (srv *Server) getDoneChanLocked() chan struct{} { + if srv.doneChan == nil { + srv.doneChan = make(chan struct{}) + } + return srv.doneChan +} + +func (srv *Server) closeDoneChanLocked() { + ch := srv.getDoneChanLocked() + select { + case <-ch: + // Already closed. Don't close again. + default: + // Safe to close here. We're the only closer, guarded + // by srv.mu. + close(ch) + } +} + +func (srv *Server) closeListenersLocked() error { + var err error + for ln := range srv.listeners { + if cerr := ln.Close(); cerr != nil && err == nil { + err = cerr + } + delete(srv.listeners, ln) + } + return err +} + +func (srv *Server) trackListener(ln net.Listener, add bool) { + srv.mu.Lock() + defer srv.mu.Unlock() + + if srv.listeners == nil { + srv.listeners = make(map[net.Listener]struct{}) + } + if add { + // If the *Server is being reused after a previous + // Close or Shutdown, reset its doneChan: + if len(srv.listeners) == 0 && len(srv.conns) == 0 { + srv.doneChan = nil + } + srv.listeners[ln] = struct{}{} + srv.listenerWg.Add(1) + } else { + delete(srv.listeners, ln) + srv.listenerWg.Done() + } +} + +func (srv *Server) trackConn(c *gossh.ServerConn, add bool) { + srv.mu.Lock() + defer srv.mu.Unlock() + + if srv.conns == nil { + srv.conns = make(map[*gossh.ServerConn]struct{}) + } + if add { + srv.conns[c] = struct{}{} + srv.connWg.Add(1) + } else { + delete(srv.conns, c) + srv.connWg.Done() + } +} diff --git a/tempfork/gliderlabs/ssh/server_test.go b/tempfork/gliderlabs/ssh/server_test.go new file mode 100644 index 000000000..113f477c8 --- /dev/null +++ b/tempfork/gliderlabs/ssh/server_test.go @@ -0,0 +1,129 @@ +//go:build glidertests +// +build glidertests + +package ssh + +import ( + "bytes" + "context" + "io" + "testing" + "time" +) + +func TestAddHostKey(t *testing.T) { + s := Server{} + signer, err := generateSigner() + if err != nil { + t.Fatal(err) + } + s.AddHostKey(signer) + if len(s.HostSigners) != 1 { + t.Fatal("Key was not properly added") + } + signer, err = generateSigner() + if err != nil { + t.Fatal(err) + } + s.AddHostKey(signer) + if len(s.HostSigners) != 1 { + t.Fatal("Key was not properly replaced") + } +} + +func TestServerShutdown(t *testing.T) { + l := newLocalListener() + testBytes := []byte("Hello world\n") + s := &Server{ + Handler: func(s Session) { + s.Write(testBytes) + time.Sleep(50 * time.Millisecond) + }, + } + go func() { + err := s.Serve(l) + if err != nil && err != ErrServerClosed { + t.Fatal(err) + } + }() + sessDone := make(chan struct{}) + sess, _, cleanup := newClientSession(t, l.Addr().String(), nil) + go func() { + defer cleanup() + defer close(sessDone) + var stdout bytes.Buffer + sess.Stdout = &stdout + if err := sess.Run(""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stdout.Bytes(), testBytes) { + t.Fatalf("expected = %s; got %s", testBytes, stdout.Bytes()) + } + }() + + srvDone := make(chan struct{}) + go func() { + defer close(srvDone) + err := s.Shutdown(context.Background()) + if err != nil { + t.Fatal(err) + } + }() + + timeout := time.After(2 * time.Second) + select { + case <-timeout: + t.Fatal("timeout") + return + case <-srvDone: + // TODO: add timeout for sessDone + <-sessDone + return + } +} + +func TestServerClose(t *testing.T) { + l := newLocalListener() + s := &Server{ + Handler: func(s Session) { + time.Sleep(5 * time.Second) + }, + } + go func() { + err := s.Serve(l) + if err != nil && err != ErrServerClosed { + t.Fatal(err) + } + }() + + clientDoneChan := make(chan struct{}) + closeDoneChan := make(chan struct{}) + + sess, _, cleanup := newClientSession(t, l.Addr().String(), nil) + go func() { + defer cleanup() + defer close(clientDoneChan) + <-closeDoneChan + if err := sess.Run(""); err != nil && err != io.EOF { + t.Fatal(err) + } + }() + + go func() { + err := s.Close() + if err != nil { + t.Fatal(err) + } + close(closeDoneChan) + }() + + timeout := time.After(100 * time.Millisecond) + select { + case <-timeout: + t.Error("timeout") + return + case <-s.getDoneChan(): + <-clientDoneChan + return + } +} diff --git a/tempfork/gliderlabs/ssh/session.go b/tempfork/gliderlabs/ssh/session.go new file mode 100644 index 000000000..0a4a21e53 --- /dev/null +++ b/tempfork/gliderlabs/ssh/session.go @@ -0,0 +1,386 @@ +package ssh + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "sync" + + "github.com/anmitsu/go-shlex" + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +// Session provides access to information about an SSH session and methods +// to read and write to the SSH channel with an embedded Channel interface from +// crypto/ssh. +// +// When Command() returns an empty slice, the user requested a shell. Otherwise +// the user is performing an exec with those command arguments. +// +// TODO: Signals +type Session interface { + gossh.Channel + + // User returns the username used when establishing the SSH connection. + User() string + + // RemoteAddr returns the net.Addr of the client side of the connection. + RemoteAddr() net.Addr + + // LocalAddr returns the net.Addr of the server side of the connection. + LocalAddr() net.Addr + + // Environ returns a copy of strings representing the environment set by the + // user for this session, in the form "key=value". + Environ() []string + + // Exit sends an exit status and then closes the session. + Exit(code int) error + + // Command returns a shell parsed slice of arguments that were provided by the + // user. Shell parsing splits the command string according to POSIX shell rules, + // which considers quoting not just whitespace. + Command() []string + + // RawCommand returns the exact command that was provided by the user. + RawCommand() string + + // Subsystem returns the subsystem requested by the user. + Subsystem() string + + // PublicKey returns the PublicKey used to authenticate. If a public key was not + // used it will return nil. + PublicKey() PublicKey + + // Context returns the connection's context. The returned context is always + // non-nil and holds the same data as the Context passed into auth + // handlers and callbacks. + // + // The context is canceled when the client's connection closes or I/O + // operation fails. + Context() context.Context + + // Permissions returns a copy of the Permissions object that was available for + // setup in the auth handlers via the Context. + Permissions() Permissions + + // Pty returns PTY information, a channel of window size changes, and a boolean + // of whether or not a PTY was accepted for this session. + Pty() (Pty, <-chan Window, bool) + + // Signals registers a channel to receive signals sent from the client. The + // channel must handle signal sends or it will block the SSH request loop. + // Registering nil will unregister the channel from signal sends. During the + // time no channel is registered signals are buffered up to a reasonable amount. + // If there are buffered signals when a channel is registered, they will be + // sent in order on the channel immediately after registering. + Signals(c chan<- Signal) + + // Break regisers a channel to receive notifications of break requests sent + // from the client. The channel must handle break requests, or it will block + // the request handling loop. Registering nil will unregister the channel. + // During the time that no channel is registered, breaks are ignored. + Break(c chan<- bool) + + // DisablePTYEmulation disables the session's default minimal PTY emulation. + // If you're setting the pty's termios settings from the Pty request, use + // this method to avoid corruption. + // Currently (2022-03-12) the only emulation implemented is NL-to-CRNL translation (`\n`=>`\r\n`). + // A call of DisablePTYEmulation must precede any call to Write. + DisablePTYEmulation() +} + +// maxSigBufSize is how many signals will be buffered +// when there is no signal channel specified +const maxSigBufSize = 128 + +func DefaultSessionHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) { + ch, reqs, err := newChan.Accept() + if err != nil { + // TODO: trigger event callback + return + } + sess := &session{ + Channel: ch, + conn: conn, + handler: srv.Handler, + ptyCb: srv.PtyCallback, + sessReqCb: srv.SessionRequestCallback, + subsystemHandlers: srv.SubsystemHandlers, + ctx: ctx, + } + sess.handleRequests(reqs) +} + +type session struct { + sync.Mutex + gossh.Channel + conn *gossh.ServerConn + handler Handler + subsystemHandlers map[string]SubsystemHandler + handled bool + exited bool + pty *Pty + winch chan Window + env []string + ptyCb PtyCallback + sessReqCb SessionRequestCallback + rawCmd string + subsystem string + ctx Context + sigCh chan<- Signal + sigBuf []Signal + breakCh chan<- bool + disablePtyEmulation bool +} + +func (sess *session) DisablePTYEmulation() { + sess.disablePtyEmulation = true +} + +func (sess *session) Write(p []byte) (n int, err error) { + if sess.pty != nil && !sess.disablePtyEmulation { + m := len(p) + // normalize \n to \r\n when pty is accepted. + // this is a hardcoded shortcut since we don't support terminal modes. + p = bytes.Replace(p, []byte{'\n'}, []byte{'\r', '\n'}, -1) + p = bytes.Replace(p, []byte{'\r', '\r', '\n'}, []byte{'\r', '\n'}, -1) + n, err = sess.Channel.Write(p) + if n > m { + n = m + } + return + } + return sess.Channel.Write(p) +} + +func (sess *session) PublicKey() PublicKey { + sessionkey := sess.ctx.Value(ContextKeyPublicKey) + if sessionkey == nil { + return nil + } + return sessionkey.(PublicKey) +} + +func (sess *session) Permissions() Permissions { + // use context permissions because its properly + // wrapped and easier to dereference + perms := sess.ctx.Value(ContextKeyPermissions).(*Permissions) + return *perms +} + +func (sess *session) Context() context.Context { + return sess.ctx +} + +func (sess *session) Exit(code int) error { + sess.Lock() + defer sess.Unlock() + if sess.exited { + return errors.New("Session.Exit called multiple times") + } + sess.exited = true + + status := struct{ Status uint32 }{uint32(code)} + _, err := sess.SendRequest("exit-status", false, gossh.Marshal(&status)) + if err != nil { + return err + } + return sess.Close() +} + +func (sess *session) User() string { + return sess.conn.User() +} + +func (sess *session) RemoteAddr() net.Addr { + return sess.conn.RemoteAddr() +} + +func (sess *session) LocalAddr() net.Addr { + return sess.conn.LocalAddr() +} + +func (sess *session) Environ() []string { + return append([]string(nil), sess.env...) +} + +func (sess *session) RawCommand() string { + return sess.rawCmd +} + +func (sess *session) Command() []string { + cmd, _ := shlex.Split(sess.rawCmd, true) + return append([]string(nil), cmd...) +} + +func (sess *session) Subsystem() string { + return sess.subsystem +} + +func (sess *session) Pty() (Pty, <-chan Window, bool) { + if sess.pty != nil { + return *sess.pty, sess.winch, true + } + return Pty{}, sess.winch, false +} + +func (sess *session) Signals(c chan<- Signal) { + sess.Lock() + defer sess.Unlock() + sess.sigCh = c + if len(sess.sigBuf) > 0 { + go func() { + for _, sig := range sess.sigBuf { + sess.sigCh <- sig + } + }() + } +} + +func (sess *session) Break(c chan<- bool) { + sess.Lock() + defer sess.Unlock() + sess.breakCh = c +} + +func (sess *session) handleRequests(reqs <-chan *gossh.Request) { + for req := range reqs { + switch req.Type { + case "shell", "exec": + if sess.handled { + req.Reply(false, nil) + continue + } + + var payload = struct{ Value string }{} + gossh.Unmarshal(req.Payload, &payload) + sess.rawCmd = payload.Value + + // If there's a session policy callback, we need to confirm before + // accepting the session. + if sess.sessReqCb != nil && !sess.sessReqCb(sess, req.Type) { + sess.rawCmd = "" + req.Reply(false, nil) + continue + } + + sess.handled = true + req.Reply(true, nil) + + go func() { + sess.handler(sess) + sess.Exit(0) + }() + case "subsystem": + if sess.handled { + req.Reply(false, nil) + continue + } + + var payload = struct{ Value string }{} + gossh.Unmarshal(req.Payload, &payload) + sess.subsystem = payload.Value + + // If there's a session policy callback, we need to confirm before + // accepting the session. + if sess.sessReqCb != nil && !sess.sessReqCb(sess, req.Type) { + sess.rawCmd = "" + req.Reply(false, nil) + continue + } + + handler := sess.subsystemHandlers[payload.Value] + if handler == nil { + handler = sess.subsystemHandlers["default"] + } + if handler == nil { + req.Reply(false, nil) + continue + } + + sess.handled = true + req.Reply(true, nil) + + go func() { + handler(sess) + sess.Exit(0) + }() + case "env": + if sess.handled { + req.Reply(false, nil) + continue + } + var kv struct{ Key, Value string } + gossh.Unmarshal(req.Payload, &kv) + sess.env = append(sess.env, fmt.Sprintf("%s=%s", kv.Key, kv.Value)) + req.Reply(true, nil) + case "signal": + var payload struct{ Signal string } + gossh.Unmarshal(req.Payload, &payload) + sess.Lock() + if sess.sigCh != nil { + sess.sigCh <- Signal(payload.Signal) + } else { + if len(sess.sigBuf) < maxSigBufSize { + sess.sigBuf = append(sess.sigBuf, Signal(payload.Signal)) + } + } + sess.Unlock() + case "pty-req": + if sess.handled || sess.pty != nil { + req.Reply(false, nil) + continue + } + ptyReq, ok := parsePtyRequest(req.Payload) + if !ok { + req.Reply(false, nil) + continue + } + if sess.ptyCb != nil { + ok := sess.ptyCb(sess.ctx, ptyReq) + if !ok { + req.Reply(false, nil) + continue + } + } + sess.pty = &ptyReq + sess.winch = make(chan Window, 1) + sess.winch <- ptyReq.Window + defer func() { + // when reqs is closed + close(sess.winch) + }() + req.Reply(ok, nil) + case "window-change": + if sess.pty == nil { + req.Reply(false, nil) + continue + } + win, _, ok := parseWindow(req.Payload) + if ok { + sess.pty.Window = win + sess.winch <- win + } + req.Reply(ok, nil) + case agentRequestType: + // TODO: option/callback to allow agent forwarding + SetAgentRequested(sess.ctx) + req.Reply(true, nil) + case "break": + ok := false + sess.Lock() + if sess.breakCh != nil { + sess.breakCh <- true + ok = true + } + req.Reply(ok, nil) + sess.Unlock() + default: + // TODO: debug log + req.Reply(false, nil) + } + } +} diff --git a/tempfork/gliderlabs/ssh/session_test.go b/tempfork/gliderlabs/ssh/session_test.go new file mode 100644 index 000000000..2a915c319 --- /dev/null +++ b/tempfork/gliderlabs/ssh/session_test.go @@ -0,0 +1,441 @@ +//go:build glidertests +// +build glidertests + +package ssh + +import ( + "bytes" + "fmt" + "io" + "net" + "testing" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +func (srv *Server) serveOnce(l net.Listener) error { + srv.ensureHandlers() + if err := srv.ensureHostSigner(); err != nil { + return err + } + conn, e := l.Accept() + if e != nil { + return e + } + srv.ChannelHandlers = map[string]ChannelHandler{ + "session": DefaultSessionHandler, + "direct-tcpip": DirectTCPIPHandler, + } + srv.HandleConn(conn) + return nil +} + +func newLocalListener() net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { + panic(fmt.Sprintf("failed to listen on a port: %v", err)) + } + } + return l +} + +func newClientSession(t *testing.T, addr string, config *gossh.ClientConfig) (*gossh.Session, *gossh.Client, func()) { + if config == nil { + config = &gossh.ClientConfig{ + User: "testuser", + Auth: []gossh.AuthMethod{ + gossh.Password("testpass"), + }, + } + } + if config.HostKeyCallback == nil { + config.HostKeyCallback = gossh.InsecureIgnoreHostKey() + } + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + t.Fatal(err) + } + session, err := client.NewSession() + if err != nil { + t.Fatal(err) + } + return session, client, func() { + session.Close() + client.Close() + } +} + +func newTestSession(t *testing.T, srv *Server, cfg *gossh.ClientConfig) (*gossh.Session, *gossh.Client, func()) { + l := newLocalListener() + go srv.serveOnce(l) + return newClientSession(t, l.Addr().String(), cfg) +} + +func TestStdout(t *testing.T) { + t.Parallel() + testBytes := []byte("Hello world\n") + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + s.Write(testBytes) + }, + }, nil) + defer cleanup() + var stdout bytes.Buffer + session.Stdout = &stdout + if err := session.Run(""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stdout.Bytes(), testBytes) { + t.Fatalf("stdout = %#v; want %#v", stdout.Bytes(), testBytes) + } +} + +func TestStderr(t *testing.T) { + t.Parallel() + testBytes := []byte("Hello world\n") + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + s.Stderr().Write(testBytes) + }, + }, nil) + defer cleanup() + var stderr bytes.Buffer + session.Stderr = &stderr + if err := session.Run(""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stderr.Bytes(), testBytes) { + t.Fatalf("stderr = %#v; want %#v", stderr.Bytes(), testBytes) + } +} + +func TestStdin(t *testing.T) { + t.Parallel() + testBytes := []byte("Hello world\n") + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + io.Copy(s, s) // stdin back into stdout + }, + }, nil) + defer cleanup() + var stdout bytes.Buffer + session.Stdout = &stdout + session.Stdin = bytes.NewBuffer(testBytes) + if err := session.Run(""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stdout.Bytes(), testBytes) { + t.Fatalf("stdout = %#v; want %#v given stdin = %#v", stdout.Bytes(), testBytes, testBytes) + } +} + +func TestUser(t *testing.T) { + t.Parallel() + testUser := []byte("progrium") + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + io.WriteString(s, s.User()) + }, + }, &gossh.ClientConfig{ + User: string(testUser), + }) + defer cleanup() + var stdout bytes.Buffer + session.Stdout = &stdout + if err := session.Run(""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stdout.Bytes(), testUser) { + t.Fatalf("stdout = %#v; want %#v given user = %#v", stdout.Bytes(), testUser, string(testUser)) + } +} + +func TestDefaultExitStatusZero(t *testing.T) { + t.Parallel() + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + // noop + }, + }, nil) + defer cleanup() + err := session.Run("") + if err != nil { + t.Fatalf("expected nil but got %v", err) + } +} + +func TestExplicitExitStatusZero(t *testing.T) { + t.Parallel() + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + s.Exit(0) + }, + }, nil) + defer cleanup() + err := session.Run("") + if err != nil { + t.Fatalf("expected nil but got %v", err) + } +} + +func TestExitStatusNonZero(t *testing.T) { + t.Parallel() + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + s.Exit(1) + }, + }, nil) + defer cleanup() + err := session.Run("") + e, ok := err.(*gossh.ExitError) + if !ok { + t.Fatalf("expected ExitError but got %T", err) + } + if e.ExitStatus() != 1 { + t.Fatalf("exit-status = %#v; want %#v", e.ExitStatus(), 1) + } +} + +func TestPty(t *testing.T) { + t.Parallel() + term := "xterm" + winWidth := 40 + winHeight := 80 + done := make(chan bool) + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + ptyReq, _, isPty := s.Pty() + if !isPty { + t.Fatalf("expected pty but none requested") + } + if ptyReq.Term != term { + t.Fatalf("expected term %#v but got %#v", term, ptyReq.Term) + } + if ptyReq.Window.Width != winWidth { + t.Fatalf("expected window width %#v but got %#v", winWidth, ptyReq.Window.Width) + } + if ptyReq.Window.Height != winHeight { + t.Fatalf("expected window height %#v but got %#v", winHeight, ptyReq.Window.Height) + } + close(done) + }, + }, nil) + defer cleanup() + if err := session.RequestPty(term, winHeight, winWidth, gossh.TerminalModes{}); err != nil { + t.Fatalf("expected nil but got %v", err) + } + if err := session.Shell(); err != nil { + t.Fatalf("expected nil but got %v", err) + } + <-done +} + +func TestPtyResize(t *testing.T) { + t.Parallel() + winch0 := Window{Width: 40, Height: 80} + winch1 := Window{Width: 80, Height: 160} + winch2 := Window{Width: 20, Height: 40} + winches := make(chan Window) + done := make(chan bool) + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + ptyReq, winCh, isPty := s.Pty() + if !isPty { + t.Fatalf("expected pty but none requested") + } + if ptyReq.Window != winch0 { + t.Fatalf("expected window %#v but got %#v", winch0, ptyReq.Window) + } + for win := range winCh { + winches <- win + } + close(done) + }, + }, nil) + defer cleanup() + // winch0 + if err := session.RequestPty("xterm", winch0.Height, winch0.Width, gossh.TerminalModes{}); err != nil { + t.Fatalf("expected nil but got %v", err) + } + if err := session.Shell(); err != nil { + t.Fatalf("expected nil but got %v", err) + } + gotWinch := <-winches + if gotWinch != winch0 { + t.Fatalf("expected window %#v but got %#v", winch0, gotWinch) + } + // winch1 + winchMsg := struct{ w, h uint32 }{uint32(winch1.Width), uint32(winch1.Height)} + ok, err := session.SendRequest("window-change", true, gossh.Marshal(&winchMsg)) + if err == nil && !ok { + t.Fatalf("unexpected error or bad reply on send request") + } + gotWinch = <-winches + if gotWinch != winch1 { + t.Fatalf("expected window %#v but got %#v", winch1, gotWinch) + } + // winch2 + winchMsg = struct{ w, h uint32 }{uint32(winch2.Width), uint32(winch2.Height)} + ok, err = session.SendRequest("window-change", true, gossh.Marshal(&winchMsg)) + if err == nil && !ok { + t.Fatalf("unexpected error or bad reply on send request") + } + gotWinch = <-winches + if gotWinch != winch2 { + t.Fatalf("expected window %#v but got %#v", winch2, gotWinch) + } + session.Close() + <-done +} + +func TestSignals(t *testing.T) { + t.Parallel() + + // errChan lets us get errors back from the session + errChan := make(chan error, 5) + + // doneChan lets us specify that we should exit. + doneChan := make(chan interface{}) + + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + // We need to use a buffered channel here, otherwise it's possible for the + // second call to Signal to get discarded. + signals := make(chan Signal, 2) + s.Signals(signals) + + select { + case sig := <-signals: + if sig != SIGINT { + errChan <- fmt.Errorf("expected signal %v but got %v", SIGINT, sig) + return + } + case <-doneChan: + errChan <- fmt.Errorf("Unexpected done") + return + } + + select { + case sig := <-signals: + if sig != SIGKILL { + errChan <- fmt.Errorf("expected signal %v but got %v", SIGKILL, sig) + return + } + case <-doneChan: + errChan <- fmt.Errorf("Unexpected done") + return + } + }, + }, nil) + defer cleanup() + + go func() { + session.Signal(gossh.SIGINT) + session.Signal(gossh.SIGKILL) + }() + + go func() { + errChan <- session.Run("") + }() + + err := <-errChan + close(doneChan) + + if err != nil { + t.Fatalf("expected nil but got %v", err) + } +} + +func TestBreakWithChanRegistered(t *testing.T) { + t.Parallel() + + // errChan lets us get errors back from the session + errChan := make(chan error, 5) + + // doneChan lets us specify that we should exit. + doneChan := make(chan interface{}) + + breakChan := make(chan bool) + + readyToReceiveBreak := make(chan bool) + + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + s.Break(breakChan) // register a break channel with the session + readyToReceiveBreak <- true + + select { + case <-breakChan: + io.WriteString(s, "break") + case <-doneChan: + errChan <- fmt.Errorf("Unexpected done") + return + } + }, + }, nil) + defer cleanup() + var stdout bytes.Buffer + session.Stdout = &stdout + go func() { + errChan <- session.Run("") + }() + + <-readyToReceiveBreak + ok, err := session.SendRequest("break", true, nil) + if err != nil { + t.Fatalf("expected nil but got %v", err) + } + if ok != true { + t.Fatalf("expected true but got %v", ok) + } + + err = <-errChan + close(doneChan) + + if err != nil { + t.Fatalf("expected nil but got %v", err) + } + if !bytes.Equal(stdout.Bytes(), []byte("break")) { + t.Fatalf("stdout = %#v, expected 'break'", stdout.Bytes()) + } +} + +func TestBreakWithoutChanRegistered(t *testing.T) { + t.Parallel() + + // errChan lets us get errors back from the session + errChan := make(chan error, 5) + + // doneChan lets us specify that we should exit. + doneChan := make(chan interface{}) + + waitUntilAfterBreakSent := make(chan bool) + + session, _, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) { + <-waitUntilAfterBreakSent + }, + }, nil) + defer cleanup() + var stdout bytes.Buffer + session.Stdout = &stdout + go func() { + errChan <- session.Run("") + }() + + ok, err := session.SendRequest("break", true, nil) + if err != nil { + t.Fatalf("expected nil but got %v", err) + } + if ok != false { + t.Fatalf("expected false but got %v", ok) + } + waitUntilAfterBreakSent <- true + + err = <-errChan + close(doneChan) + if err != nil { + t.Fatalf("expected nil but got %v", err) + } +} diff --git a/tempfork/gliderlabs/ssh/ssh.go b/tempfork/gliderlabs/ssh/ssh.go new file mode 100644 index 000000000..3262d84b2 --- /dev/null +++ b/tempfork/gliderlabs/ssh/ssh.go @@ -0,0 +1,152 @@ +package ssh + +import ( + "crypto/subtle" + "net" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +type Signal string + +// POSIX signals as listed in RFC 4254 Section 6.10. +const ( + SIGABRT Signal = "ABRT" + SIGALRM Signal = "ALRM" + SIGFPE Signal = "FPE" + SIGHUP Signal = "HUP" + SIGILL Signal = "ILL" + SIGINT Signal = "INT" + SIGKILL Signal = "KILL" + SIGPIPE Signal = "PIPE" + SIGQUIT Signal = "QUIT" + SIGSEGV Signal = "SEGV" + SIGTERM Signal = "TERM" + SIGUSR1 Signal = "USR1" + SIGUSR2 Signal = "USR2" +) + +// DefaultHandler is the default Handler used by Serve. +var DefaultHandler Handler + +// Option is a functional option handler for Server. +type Option func(*Server) error + +// Handler is a callback for handling established SSH sessions. +type Handler func(Session) + +// PublicKeyHandler is a callback for performing public key authentication. +type PublicKeyHandler func(ctx Context, key PublicKey) bool + +// PasswordHandler is a callback for performing password authentication. +type PasswordHandler func(ctx Context, password string) bool + +// KeyboardInteractiveHandler is a callback for performing keyboard-interactive authentication. +type KeyboardInteractiveHandler func(ctx Context, challenger gossh.KeyboardInteractiveChallenge) bool + +// PtyCallback is a hook for allowing PTY sessions. +type PtyCallback func(ctx Context, pty Pty) bool + +// SessionRequestCallback is a callback for allowing or denying SSH sessions. +type SessionRequestCallback func(sess Session, requestType string) bool + +// ConnCallback is a hook for new connections before handling. +// It allows wrapping for timeouts and limiting by returning +// the net.Conn that will be used as the underlying connection. +type ConnCallback func(ctx Context, conn net.Conn) net.Conn + +// LocalPortForwardingCallback is a hook for allowing port forwarding +type LocalPortForwardingCallback func(ctx Context, destinationHost string, destinationPort uint32) bool + +// ReversePortForwardingCallback is a hook for allowing reverse port forwarding +type ReversePortForwardingCallback func(ctx Context, bindHost string, bindPort uint32) bool + +// ServerConfigCallback is a hook for creating custom default server configs +type ServerConfigCallback func(ctx Context) *gossh.ServerConfig + +// ConnectionFailedCallback is a hook for reporting failed connections +// Please note: the net.Conn is likely to be closed at this point +type ConnectionFailedCallback func(conn net.Conn, err error) + +// Window represents the size of a PTY window. +// +// See https://datatracker.ietf.org/doc/html/rfc4254#section-6.2 +// +// Zero dimension parameters MUST be ignored. The character/row dimensions +// override the pixel dimensions (when nonzero). Pixel dimensions refer +// to the drawable area of the window. +type Window struct { + // Width is the number of columns. + // It overrides WidthPixels. + Width int + // Height is the number of rows. + // It overrides HeightPixels. + Height int + + // WidthPixels is the drawable width of the window, in pixels. + WidthPixels int + // HeightPixels is the drawable height of the window, in pixels. + HeightPixels int +} + +// Pty represents a PTY request and configuration. +type Pty struct { + // Term is the TERM environment variable value. + Term string + + // Window is the Window sent as part of the pty-req. + Window Window + + // Modes represent a mapping of Terminal Mode opcode to value as it was + // requested by the client as part of the pty-req. These are outlined as + // part of https://datatracker.ietf.org/doc/html/rfc4254#section-8. + // + // The opcodes are defined as constants in github.com/tailscale/golang-x-crypto/ssh (VINTR,VQUIT,etc.). + // Boolean opcodes have values 0 or 1. + Modes gossh.TerminalModes +} + +// Serve accepts incoming SSH connections on the listener l, creating a new +// connection goroutine for each. The connection goroutines read requests and +// then calls handler to handle sessions. Handler is typically nil, in which +// case the DefaultHandler is used. +func Serve(l net.Listener, handler Handler, options ...Option) error { + srv := &Server{Handler: handler} + for _, option := range options { + if err := srv.SetOption(option); err != nil { + return err + } + } + return srv.Serve(l) +} + +// ListenAndServe listens on the TCP network address addr and then calls Serve +// with handler to handle sessions on incoming connections. Handler is typically +// nil, in which case the DefaultHandler is used. +func ListenAndServe(addr string, handler Handler, options ...Option) error { + srv := &Server{Addr: addr, Handler: handler} + for _, option := range options { + if err := srv.SetOption(option); err != nil { + return err + } + } + return srv.ListenAndServe() +} + +// Handle registers the handler as the DefaultHandler. +func Handle(handler Handler) { + DefaultHandler = handler +} + +// KeysEqual is constant time compare of the keys to avoid timing attacks. +func KeysEqual(ak, bk PublicKey) bool { + + //avoid panic if one of the keys is nil, return false instead + if ak == nil || bk == nil { + return false + } + + a := ak.Marshal() + b := bk.Marshal() + return (len(a) == len(b) && subtle.ConstantTimeCompare(a, b) == 1) +} diff --git a/tempfork/gliderlabs/ssh/ssh_test.go b/tempfork/gliderlabs/ssh/ssh_test.go new file mode 100644 index 000000000..aa301b048 --- /dev/null +++ b/tempfork/gliderlabs/ssh/ssh_test.go @@ -0,0 +1,17 @@ +package ssh + +import ( + "testing" +) + +func TestKeysEqual(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("The code did panic") + } + }() + + if KeysEqual(nil, nil) { + t.Error("two nil keys should not return true") + } +} diff --git a/tempfork/gliderlabs/ssh/tcpip.go b/tempfork/gliderlabs/ssh/tcpip.go new file mode 100644 index 000000000..056a0c734 --- /dev/null +++ b/tempfork/gliderlabs/ssh/tcpip.go @@ -0,0 +1,193 @@ +package ssh + +import ( + "io" + "log" + "net" + "strconv" + "sync" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +const ( + forwardedTCPChannelType = "forwarded-tcpip" +) + +// direct-tcpip data struct as specified in RFC4254, Section 7.2 +type localForwardChannelData struct { + DestAddr string + DestPort uint32 + + OriginAddr string + OriginPort uint32 +} + +// DirectTCPIPHandler can be enabled by adding it to the server's +// ChannelHandlers under direct-tcpip. +func DirectTCPIPHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) { + d := localForwardChannelData{} + if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil { + newChan.Reject(gossh.ConnectionFailed, "error parsing forward data: "+err.Error()) + return + } + + if srv.LocalPortForwardingCallback == nil || !srv.LocalPortForwardingCallback(ctx, d.DestAddr, d.DestPort) { + newChan.Reject(gossh.Prohibited, "port forwarding is disabled") + return + } + + dest := net.JoinHostPort(d.DestAddr, strconv.FormatInt(int64(d.DestPort), 10)) + + var dialer net.Dialer + dconn, err := dialer.DialContext(ctx, "tcp", dest) + if err != nil { + newChan.Reject(gossh.ConnectionFailed, err.Error()) + return + } + + ch, reqs, err := newChan.Accept() + if err != nil { + dconn.Close() + return + } + go gossh.DiscardRequests(reqs) + + go func() { + defer ch.Close() + defer dconn.Close() + io.Copy(ch, dconn) + }() + go func() { + defer ch.Close() + defer dconn.Close() + io.Copy(dconn, ch) + }() +} + +type remoteForwardRequest struct { + BindAddr string + BindPort uint32 +} + +type remoteForwardSuccess struct { + BindPort uint32 +} + +type remoteForwardCancelRequest struct { + BindAddr string + BindPort uint32 +} + +type remoteForwardChannelData struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 +} + +// ForwardedTCPHandler can be enabled by creating a ForwardedTCPHandler and +// adding the HandleSSHRequest callback to the server's RequestHandlers under +// tcpip-forward and cancel-tcpip-forward. +type ForwardedTCPHandler struct { + forwards map[string]net.Listener + sync.Mutex +} + +func (h *ForwardedTCPHandler) HandleSSHRequest(ctx Context, srv *Server, req *gossh.Request) (bool, []byte) { + h.Lock() + if h.forwards == nil { + h.forwards = make(map[string]net.Listener) + } + h.Unlock() + conn := ctx.Value(ContextKeyConn).(*gossh.ServerConn) + switch req.Type { + case "tcpip-forward": + var reqPayload remoteForwardRequest + if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil { + // TODO: log parse failure + return false, []byte{} + } + if srv.ReversePortForwardingCallback == nil || !srv.ReversePortForwardingCallback(ctx, reqPayload.BindAddr, reqPayload.BindPort) { + return false, []byte("port forwarding is disabled") + } + addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(reqPayload.BindPort))) + ln, err := net.Listen("tcp", addr) + if err != nil { + // TODO: log listen failure + return false, []byte{} + } + _, destPortStr, _ := net.SplitHostPort(ln.Addr().String()) + destPort, _ := strconv.Atoi(destPortStr) + h.Lock() + h.forwards[addr] = ln + h.Unlock() + go func() { + <-ctx.Done() + h.Lock() + ln, ok := h.forwards[addr] + h.Unlock() + if ok { + ln.Close() + } + }() + go func() { + for { + c, err := ln.Accept() + if err != nil { + // TODO: log accept failure + break + } + originAddr, orignPortStr, _ := net.SplitHostPort(c.RemoteAddr().String()) + originPort, _ := strconv.Atoi(orignPortStr) + payload := gossh.Marshal(&remoteForwardChannelData{ + DestAddr: reqPayload.BindAddr, + DestPort: uint32(destPort), + OriginAddr: originAddr, + OriginPort: uint32(originPort), + }) + go func() { + ch, reqs, err := conn.OpenChannel(forwardedTCPChannelType, payload) + if err != nil { + // TODO: log failure to open channel + log.Println(err) + c.Close() + return + } + go gossh.DiscardRequests(reqs) + go func() { + defer ch.Close() + defer c.Close() + io.Copy(ch, c) + }() + go func() { + defer ch.Close() + defer c.Close() + io.Copy(c, ch) + }() + }() + } + h.Lock() + delete(h.forwards, addr) + h.Unlock() + }() + return true, gossh.Marshal(&remoteForwardSuccess{uint32(destPort)}) + + case "cancel-tcpip-forward": + var reqPayload remoteForwardCancelRequest + if err := gossh.Unmarshal(req.Payload, &reqPayload); err != nil { + // TODO: log parse failure + return false, []byte{} + } + addr := net.JoinHostPort(reqPayload.BindAddr, strconv.Itoa(int(reqPayload.BindPort))) + h.Lock() + ln, ok := h.forwards[addr] + h.Unlock() + if ok { + ln.Close() + } + return true, nil + default: + return false, nil + } +} diff --git a/tempfork/gliderlabs/ssh/tcpip_test.go b/tempfork/gliderlabs/ssh/tcpip_test.go new file mode 100644 index 000000000..7d43bb0a9 --- /dev/null +++ b/tempfork/gliderlabs/ssh/tcpip_test.go @@ -0,0 +1,86 @@ +//go:build glidertests +// +build glidertests + +package ssh + +import ( + "bytes" + "io/ioutil" + "net" + "strconv" + "strings" + "testing" + + gossh "github.com/tailscale/golang-x-crypto/ssh" +) + +var sampleServerResponse = []byte("Hello world") + +func sampleSocketServer() net.Listener { + l := newLocalListener() + + go func() { + conn, err := l.Accept() + if err != nil { + return + } + conn.Write(sampleServerResponse) + conn.Close() + }() + + return l +} + +func newTestSessionWithForwarding(t *testing.T, forwardingEnabled bool) (net.Listener, *gossh.Client, func()) { + l := sampleSocketServer() + + _, client, cleanup := newTestSession(t, &Server{ + Handler: func(s Session) {}, + LocalPortForwardingCallback: func(ctx Context, destinationHost string, destinationPort uint32) bool { + addr := net.JoinHostPort(destinationHost, strconv.FormatInt(int64(destinationPort), 10)) + if addr != l.Addr().String() { + panic("unexpected destinationHost: " + addr) + } + return forwardingEnabled + }, + }, nil) + + return l, client, func() { + cleanup() + l.Close() + } +} + +func TestLocalPortForwardingWorks(t *testing.T) { + t.Parallel() + + l, client, cleanup := newTestSessionWithForwarding(t, true) + defer cleanup() + + conn, err := client.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatalf("Error connecting to %v: %v", l.Addr().String(), err) + } + result, err := ioutil.ReadAll(conn) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(result, sampleServerResponse) { + t.Fatalf("result = %#v; want %#v", result, sampleServerResponse) + } +} + +func TestLocalPortForwardingRespectsCallback(t *testing.T) { + t.Parallel() + + l, client, cleanup := newTestSessionWithForwarding(t, false) + defer cleanup() + + _, err := client.Dial("tcp", l.Addr().String()) + if err == nil { + t.Fatalf("Expected error connecting to %v but it succeeded", l.Addr().String()) + } + if !strings.Contains(err.Error(), "port forwarding is disabled") { + t.Fatalf("Expected permission error but got %#v", err) + } +} diff --git a/tempfork/gliderlabs/ssh/util.go b/tempfork/gliderlabs/ssh/util.go new file mode 100644 index 000000000..e3b5716a3 --- /dev/null +++ b/tempfork/gliderlabs/ssh/util.go @@ -0,0 +1,157 @@ +package ssh + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/binary" + + "github.com/tailscale/golang-x-crypto/ssh" +) + +func generateSigner() (ssh.Signer, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) +} + +func parsePtyRequest(payload []byte) (pty Pty, ok bool) { + // See https://datatracker.ietf.org/doc/html/rfc4254#section-6.2 + // 6.2. Requesting a Pseudo-Terminal + // A pseudo-terminal can be allocated for the session by sending the + // following message. + // byte SSH_MSG_CHANNEL_REQUEST + // uint32 recipient channel + // string "pty-req" + // boolean want_reply + // string TERM environment variable value (e.g., vt100) + // uint32 terminal width, characters (e.g., 80) + // uint32 terminal height, rows (e.g., 24) + // uint32 terminal width, pixels (e.g., 640) + // uint32 terminal height, pixels (e.g., 480) + // string encoded terminal modes + + // The payload starts from the TERM variable. + term, rem, ok := parseString(payload) + if !ok { + return + } + win, rem, ok := parseWindow(rem) + if !ok { + return + } + modes, ok := parseTerminalModes(rem) + if !ok { + return + } + pty = Pty{ + Term: term, + Window: win, + Modes: modes, + } + return +} + +func parseTerminalModes(in []byte) (modes ssh.TerminalModes, ok bool) { + // See https://datatracker.ietf.org/doc/html/rfc4254#section-8 + // 8. Encoding of Terminal Modes + // + // All 'encoded terminal modes' (as passed in a pty request) are encoded + // into a byte stream. It is intended that the coding be portable + // across different environments. The stream consists of opcode- + // argument pairs wherein the opcode is a byte value. Opcodes 1 to 159 + // have a single uint32 argument. Opcodes 160 to 255 are not yet + // defined, and cause parsing to stop (they should only be used after + // any other data). The stream is terminated by opcode TTY_OP_END + // (0x00). + // + // The client SHOULD put any modes it knows about in the stream, and the + // server MAY ignore any modes it does not know about. This allows some + // degree of machine-independence, at least between systems that use a + // POSIX-like tty interface. The protocol can support other systems as + // well, but the client may need to fill reasonable values for a number + // of parameters so the server pty gets set to a reasonable mode (the + // server leaves all unspecified mode bits in their default values, and + // only some combinations make sense). + _, rem, ok := parseUint32(in) + if !ok { + return + } + const ttyOpEnd = 0 + for len(rem) > 0 { + if modes == nil { + modes = make(ssh.TerminalModes) + } + code := uint8(rem[0]) + rem = rem[1:] + if code == ttyOpEnd || code > 160 { + break + } + var val uint32 + val, rem, ok = parseUint32(rem) + if !ok { + return + } + modes[code] = val + } + ok = true + return +} + +func parseWindow(s []byte) (win Window, rem []byte, ok bool) { + // See https://datatracker.ietf.org/doc/html/rfc4254#section-6.7 + // 6.7. Window Dimension Change Message + // When the window (terminal) size changes on the client side, it MAY + // send a message to the other side to inform it of the new dimensions. + + // byte SSH_MSG_CHANNEL_REQUEST + // uint32 recipient channel + // string "window-change" + // boolean FALSE + // uint32 terminal width, columns + // uint32 terminal height, rows + // uint32 terminal width, pixels + // uint32 terminal height, pixels + wCols, rem, ok := parseUint32(s) + if !ok { + return + } + hRows, rem, ok := parseUint32(rem) + if !ok { + return + } + wPixels, rem, ok := parseUint32(rem) + if !ok { + return + } + hPixels, rem, ok := parseUint32(rem) + if !ok { + return + } + win = Window{ + Width: int(wCols), + Height: int(hRows), + WidthPixels: int(wPixels), + HeightPixels: int(hPixels), + } + return +} + +func parseString(in []byte) (out string, rem []byte, ok bool) { + length, rem, ok := parseUint32(in) + if uint32(len(rem)) < length || !ok { + ok = false + return + } + out, rem = string(rem[:length]), rem[length:] + ok = true + return +} + +func parseUint32(in []byte) (uint32, []byte, bool) { + if len(in) < 4 { + return 0, nil, false + } + return binary.BigEndian.Uint32(in), in[4:], true +} diff --git a/tempfork/gliderlabs/ssh/wrap.go b/tempfork/gliderlabs/ssh/wrap.go new file mode 100644 index 000000000..17867d751 --- /dev/null +++ b/tempfork/gliderlabs/ssh/wrap.go @@ -0,0 +1,33 @@ +package ssh + +import gossh "github.com/tailscale/golang-x-crypto/ssh" + +// PublicKey is an abstraction of different types of public keys. +type PublicKey interface { + gossh.PublicKey +} + +// The Permissions type holds fine-grained permissions that are specific to a +// user or a specific authentication method for a user. Permissions, except for +// "source-address", must be enforced in the server application layer, after +// successful authentication. +type Permissions struct { + *gossh.Permissions +} + +// A Signer can create signatures that verify against a public key. +type Signer interface { + gossh.Signer +} + +// ParseAuthorizedKey parses a public key from an authorized_keys file used in +// OpenSSH according to the sshd(8) manual page. +func ParseAuthorizedKey(in []byte) (out PublicKey, comment string, options []string, rest []byte, err error) { + return gossh.ParseAuthorizedKey(in) +} + +// ParsePublicKey parses an SSH public key formatted for use in +// the SSH wire protocol according to RFC 4253, section 6.6. +func ParsePublicKey(in []byte) (out PublicKey, err error) { + return gossh.ParsePublicKey(in) +}