From 53e72fe2ba498d654a47a3cedc48ac008aa22638 Mon Sep 17 00:00:00 2001 From: Jaime Pillora Date: Sat, 18 Jul 2020 11:05:53 +1000 Subject: [PATCH] add udp support, massive refactor and more (see below) * split out share/ package into multiple subpackages * added share/compat.go to keep backward compatibility(ish) * moved shared tunnelling logic from client/server packages into share/tunnel/ * added remote protocol ":/", currently supporting tcp and udp * added an end-to-end test suite * added TODO e2e tests as contribution targets * added deep context integration for improved cleanup and cancellation --- .github/gocompare.sh | 7 + .github/goreleaser.yml | 4 +- .github/workflows/ci.yml | 46 ++ .github/workflows/release.yml | 19 - README.md | 153 +++---- client/client.go | 393 +++++++++--------- client/client_test.go | 19 +- go.mod | 7 +- go.sum | 15 +- main.go | 67 +-- server/server.go | 136 +++--- server/{handler.go => server_handler.go} | 126 +++--- share/{ => ccrypto}/determ_rand.go | 8 +- share/ccrypto/keys.go | 41 ++ share/{ => cio}/logger.go | 2 +- share/cio/pipe.go | 48 +++ share/{ => cio}/stdio.go | 2 +- share/{ => cnet}/conn_rwc.go | 2 +- share/{ => cnet}/conn_ws.go | 15 +- share/{ => cnet}/connstats.go | 2 +- share/cnet/http_server.go | 71 ++++ share/compat.go | 59 +++ share/{ => cos}/gostats.go | 2 +- share/cos/interupt.go | 21 + share/cos/sleep.go | 33 ++ .../sleep_windows.go} | 2 +- share/http_server.go | 61 --- share/pipe.go | 29 -- share/proxy.go | 106 ----- share/remote.go | 138 ------ share/{ => settings}/config.go | 10 +- share/settings/remote.go | 244 +++++++++++ share/settings/remote_test.go | 108 +++++ share/{ => settings}/user.go | 2 +- share/{ => settings}/users.go | 18 +- share/signal.go | 24 -- share/ssh.go | 72 ---- share/tunnel/tunnel.go | 162 ++++++++ share/tunnel/tunnel_in_proxy.go | 142 +++++++ share/tunnel/tunnel_in_proxy_udp.go | 157 +++++++ share/tunnel/tunnel_out_ssh.go | 84 ++++ share/tunnel/tunnel_out_ssh_udp.go | 145 +++++++ share/tunnel/udp.go | 44 ++ {bench => test/bench}/main.go | 8 +- test/bench/perf.md | 66 +++ {bench => test/bench}/userfile | 0 test/e2e/auth_test.go | 48 +++ test/e2e/base_test.go | 48 +++ test/e2e/proxy_test.go | 5 + test/e2e/setup_test.go | 132 ++++++ test/e2e/socks_test.go | 5 + test/e2e/udp_test.go | 88 ++++ 52 files changed, 2272 insertions(+), 974 deletions(-) create mode 100755 .github/gocompare.sh create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release.yml rename server/{handler.go => server_handler.go} (52%) rename share/{ => ccrypto}/determ_rand.go (85%) create mode 100644 share/ccrypto/keys.go rename share/{ => cio}/logger.go (98%) create mode 100644 share/cio/pipe.go rename share/{ => cio}/stdio.go (90%) rename share/{ => cnet}/conn_rwc.go (97%) rename share/{ => cnet}/conn_ws.go (84%) rename share/{ => cnet}/connstats.go (96%) create mode 100644 share/cnet/http_server.go create mode 100644 share/compat.go rename share/{ => cos}/gostats.go (97%) create mode 100644 share/cos/interupt.go create mode 100644 share/cos/sleep.go rename share/{signal_windows.go => cos/sleep_windows.go} (88%) delete mode 100644 share/http_server.go delete mode 100644 share/pipe.go delete mode 100644 share/proxy.go delete mode 100644 share/remote.go rename share/{ => settings}/config.go (62%) create mode 100644 share/settings/remote.go create mode 100644 share/settings/remote_test.go rename share/{ => settings}/user.go (96%) rename share/{ => settings}/users.go (91%) delete mode 100644 share/signal.go delete mode 100644 share/ssh.go create mode 100644 share/tunnel/tunnel.go create mode 100644 share/tunnel/tunnel_in_proxy.go create mode 100644 share/tunnel/tunnel_in_proxy_udp.go create mode 100644 share/tunnel/tunnel_out_ssh.go create mode 100644 share/tunnel/tunnel_out_ssh_udp.go create mode 100644 share/tunnel/udp.go rename {bench => test/bench}/main.go (96%) create mode 100644 test/bench/perf.md rename {bench => test/bench}/userfile (100%) create mode 100644 test/e2e/auth_test.go create mode 100644 test/e2e/base_test.go create mode 100644 test/e2e/proxy_test.go create mode 100644 test/e2e/setup_test.go create mode 100644 test/e2e/socks_test.go create mode 100644 test/e2e/udp_test.go diff --git a/.github/gocompare.sh b/.github/gocompare.sh new file mode 100755 index 0000000..e349695 --- /dev/null +++ b/.github/gocompare.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# TODO +gocompat compare \ + --go1compat \ + --log-level=debug \ + --git-refs=origin/master..$(git rev-parse --abbrev-ref HEAD) \ + ./... diff --git a/.github/goreleaser.yml b/.github/goreleaser.yml index 637c592..cbfb1a6 100644 --- a/.github/goreleaser.yml +++ b/.github/goreleaser.yml @@ -1,5 +1,5 @@ # test this goreleaser config with: -# - cd chisel/ +# - cd chisel # - goreleaser --skip-publish --rm-dist --config .github/goreleaser.yml builds: - env: @@ -36,6 +36,8 @@ archives: - format: gz files: - none* +release: + prerelease: auto changelog: sort: asc filters: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33dae1d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +on: [push, pull_request] +name: CI +jobs: + # ================ + # TEST JOB + # runs on every push and PR + # runs 2x3 times (see matrix) + # ================ + test: + name: Test + strategy: + matrix: + go-version: [1.13.x, 1.14.x] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: Install Go + uses: actions/setup-go@v1 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Build + run: go build -v . + - name: Test + run: go test -v ./... + # ================ + # RELEASE JOB + # runs after a success test + # only runs on push "v*" tag + # ================ + release: + name: Release + needs: test + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + - name: goreleaser + if: success() + uses: docker://goreleaser/goreleaser:latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: release --config .github/goreleaser.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index ee91d6c..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,19 +0,0 @@ -on: - push: - tags: - - "v*" -name: CI -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@master - - name: goreleaser - uses: docker://goreleaser/goreleaser:latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - args: release --config .github/goreleaser.yml - if: success() diff --git a/README.md b/README.md index 5fe1daf..c2cb310 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![GoDoc](https://godoc.org/github.com/jpillora/chisel?status.svg)](https://godoc.org/github.com/jpillora/chisel) [![CI](https://github.com/jpillora/chisel/workflows/CI/badge.svg)](https://github.com/jpillora/chisel/actions?workflow=CI) -Chisel is a fast TCP tunnel, transported over HTTP, secured via SSH. Single executable including both client and server. Written in Go (golang). Chisel is mainly useful for passing through firewalls, though it can also be used to provide a secure endpoint into your network. Chisel is very similar to [crowbar](https://github.com/q3k/crowbar) though achieves **much** higher [performance](#performance). +Chisel is a fast TCP tunnel, transported over HTTP, secured via SSH. Single executable including both client and server. Written in Go (golang). Chisel is mainly useful for passing through firewalls, though it can also be used to provide a secure endpoint into your network. ![overview](https://docs.google.com/drawings/d/1p53VWxzGNfy8rjr-mW8pvisJmhkoLl82vAgctO_6f1w/pub?w=960&h=720) ### Features - Easy to use -- [Performant](#performance)\* +- [Performant](./test/bench/perf.md)\* - [Encrypted connections](#security) using the SSH protocol (via `crypto/ssh`) - [Authenticated connections](#authentication); authenticated client connections with a users config file, authenticated server connections with fingerprint matching. - Client auto-reconnects with [exponential backoff](https://github.com/jpillora/backoff) @@ -64,22 +64,31 @@ and then visit [localhost:3000](http://localhost:3000/), we should see a directo ### Usage -``` + + + +``` plain $ chisel --help - Usage: chisel [command] [--help] + Usage: chisel [command] [--help] - Version: 1.X.X + Version: 0.0.0-src (go1.14.3) - Commands: - server - runs chisel in server mode - client - runs chisel in client mode + Commands: + server - runs chisel in server mode + client - runs chisel in client mode - Read more: - https://github.com/jpillora/chisel -``` + Read more: + https://github.com/jpillora/chisel ``` + + + + +``` plain $ chisel server --help Usage: chisel server [options] @@ -111,8 +120,15 @@ $ chisel server --help remotes. This file will be automatically reloaded on change. --auth, An optional string representing a single user with full - access, in the form of . This is equivalent to creating an - authfile with {"": [""]}. + access, in the form of . It is equivalent to creating an + authfile with {"": [""]}. If unset, it will use the + environment variable AUTH. + + --keepalive, An optional keepalive interval. Since the underlying + transport is HTTP, in many instances we'll be traversing through + proxies, often these proxies will close idle connections. You must + specify a time with a unit, for example '5s' or '2m'. Defaults + to '25s' (set to 0s to disable). --proxy, Specifies another HTTP server to proxy requests to when chisel receives a normal HTTP request. Useful for hiding chisel in @@ -136,14 +152,17 @@ $ chisel server --help a SIGHUP to short-circuit the client reconnect timer Version: - 1.X.X (go1.14) + 0.0.0-src (go1.14.3) Read more: https://github.com/jpillora/chisel ``` + -``` + + +``` plain $ chisel client --help Usage: chisel client [options] [remote] [remote] ... @@ -217,8 +236,8 @@ $ chisel client --help --keepalive, An optional keepalive interval. Since the underlying transport is HTTP, in many instances we'll be traversing through proxies, often these proxies will close idle connections. You must - specify a time with a unit, for example '30s' or '2m'. Defaults - to '0s' (disabled). + specify a time with a unit, for example '5s' or '2m'. Defaults + to '25s' (set to 0s to disable). --max-retry-count, Maximum number of times to retry before exiting. Defaults to unlimited. @@ -230,7 +249,7 @@ $ chisel client --help used to reach the chisel server. Authentication can be specified inside the URL. For example, http://admin:password@my-server.com:8081 - or: socks://admin:password@my-server.com:1080 + or: socks://admin:password@my-server.com:1080 --header, Set a custom header in the form "HeaderName: HeaderContent". Can be used multiple times. (e.g --header "Foo: Bar" --header "Hello: World") @@ -250,12 +269,13 @@ $ chisel client --help a SIGHUP to short-circuit the client reconnect timer Version: - 1.X.X (go1.14) + 0.0.0-src (go1.14.3) Read more: https://github.com/jpillora/chisel ``` + ### Security @@ -281,92 +301,27 @@ docker run \ 2. Connect your chisel client (using server's fingerprint) ```sh -chisel client --fingerprint ab:12:34 server-address:9312 socks +chisel client --fingerprint ab:12:34 :9312 socks ``` 3. Point your SOCKS5 clients (e.g. OS/Browser) to: ``` -localhost:1080 +:1080 ``` 4. Now you have an encrypted, authenticated SOCKS5 connection over HTTP -### Performance -With [crowbar](https://github.com/q3k/crowbar), a connection is tunneled by repeatedly querying the server with updates. This results in a large amount of HTTP and TCP connection overhead. Chisel overcomes this using WebSockets combined with [crypto/ssh](https://golang.org/x/crypto/ssh) to create hundreds of logical connections, resulting in **one** TCP connection per client. +### Caveats -In this simple benchmark, we have: +Since WebSockets support is required: -``` - (direct) - .--------------->----------------. - / chisel chisel \ -request--->client:2001--->server:2002---->fileserver:3000 - \ / - '--> crowbar:4001--->crowbar:4002' - client server -``` - -Note, we're using an in-memory "file" server on localhost for these tests - -_direct_ - -``` -:3000 => 1 bytes in 1.291417ms -:3000 => 10 bytes in 713.525µs -:3000 => 100 bytes in 562.48µs -:3000 => 1000 bytes in 595.445µs -:3000 => 10000 bytes in 1.053298ms -:3000 => 100000 bytes in 741.351µs -:3000 => 1000000 bytes in 1.367143ms -:3000 => 10000000 bytes in 8.601549ms -:3000 => 100000000 bytes in 76.3939ms -``` - -`chisel` - -``` -:2001 => 1 bytes in 1.351976ms -:2001 => 10 bytes in 1.106086ms -:2001 => 100 bytes in 1.005729ms -:2001 => 1000 bytes in 1.254396ms -:2001 => 10000 bytes in 1.139777ms -:2001 => 100000 bytes in 2.35437ms -:2001 => 1000000 bytes in 11.502673ms -:2001 => 10000000 bytes in 123.130246ms -:2001 => 100000000 bytes in 966.48636ms -``` - -~100MB in **~1 second** - -`crowbar` - -``` -:4001 => 1 bytes in 3.335797ms -:4001 => 10 bytes in 1.453007ms -:4001 => 100 bytes in 1.811727ms -:4001 => 1000 bytes in 1.621525ms -:4001 => 10000 bytes in 5.20729ms -:4001 => 100000 bytes in 38.461926ms -:4001 => 1000000 bytes in 358.784864ms -:4001 => 10000000 bytes in 3.603206487s -:4001 => 100000000 bytes in 36.332395213s -``` - -~100MB in **36 seconds** - -See more [test/](test/) - -### Known Issues - -- WebSockets support is required - _ IaaS providers all will support WebSockets - _ Unless an unsupporting HTTP proxy has been forced in front of you, in which case I'd argue that you've been downgraded to PaaS. - _ PaaS providers vary in their support for WebSockets - _ Heroku has full support - _ Openshift has full support though connections are only accepted on ports 8443 and 8080 - _ Google App Engine has **no** support (Track this on [their repo](https://code.google.com/p/googleappengine/issues/detail?id=2535)) +- IaaS providers all will support WebSockets (unless an unsupporting HTTP proxy has been forced in front of you, in which case I'd argue that you've been downgraded to PaaS) +- PaaS providers vary in their support for WebSockets + - Heroku has full support + - Openshift has full support though connections are only accepted on ports 8443 and 8080 + - Google App Engine has **no** support (Track this on [their repo](https://code.google.com/p/googleappengine/issues/detail?id=2535)) ### Contributing @@ -379,12 +334,10 @@ See more [test/](test/) ### Changelog - `1.0` - Initial release -- `1.1` - Swapped out simple symmetric encryption for ECDSA SSH +- `1.1` - Replaced simple symmetric encryption for ECDSA SSH - `1.2` - Added SOCKS5 (server) and HTTP CONNECT (client) support - `1.3` - Added reverse tunnelling support - -### Todo - -- Better, faster tests -- Expose a stats page for proxy throughput -- Treat client stdin/stdout as a socket +- `1.4` - Added arbitrary HTTP header support +- `1.5` - Added reverse SOCKS support (by @aus) +- `1.6` - Added client stdio support (by @BoleynSu) +- `1.7` - Added UDP support diff --git a/client/client.go b/client/client.go index 97fd1a2..1c47a82 100644 --- a/client/client.go +++ b/client/client.go @@ -12,18 +12,23 @@ import ( "strings" "time" - "github.com/armon/go-socks5" "github.com/gorilla/websocket" "github.com/jpillora/backoff" chshare "github.com/jpillora/chisel/share" + "github.com/jpillora/chisel/share/ccrypto" + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/cos" + "github.com/jpillora/chisel/share/settings" + "github.com/jpillora/chisel/share/tunnel" "golang.org/x/crypto/ssh" "golang.org/x/net/proxy" + "golang.org/x/sync/errgroup" ) //Config represents a client configuration type Config struct { - shared *chshare.Config Fingerprint string Auth string KeepAlive time.Duration @@ -38,28 +43,28 @@ type Config struct { //Client represents a client instance type Client struct { - *chshare.Logger - config *Config - sshConfig *ssh.ClientConfig - sshConn ssh.Conn - proxyURL *url.URL - server string - running bool - runningc chan error - connStats chshare.ConnStats - socksServer *socks5.Server + *cio.Logger + config *Config + computed settings.Config + sshConfig *ssh.ClientConfig + proxyURL *url.URL + server string + connStats cnet.ConnStats + stop func() + eg *errgroup.Group + tunnel *tunnel.Tunnel } //NewClient creates a new client instance -func NewClient(config *Config) (*Client, error) { +func NewClient(c *Config) (*Client, error) { //apply default scheme - if !strings.HasPrefix(config.Server, "http") { - config.Server = "http://" + config.Server + if !strings.HasPrefix(c.Server, "http") { + c.Server = "http://" + c.Server } - if config.MaxRetryInterval < time.Second { - config.MaxRetryInterval = 5 * time.Minute + if c.MaxRetryInterval < time.Second { + c.MaxRetryInterval = 5 * time.Minute } - u, err := url.Parse(config.Server) + u, err := url.Parse(c.Server) if err != nil { return nil, err } @@ -73,16 +78,27 @@ func NewClient(config *Config) (*Client, error) { } //swap to websockets scheme u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1) - shared := &chshare.Config{} - createSocksServer := false + hasReverse := false + hasSocks := false hasStdio := false - for _, s := range config.Remotes { - r, err := chshare.DecodeRemote(s) + client := &Client{ + Logger: cio.NewLogger("client"), + config: c, + computed: settings.Config{ + Version: chshare.BuildVersion, + }, + server: u.String(), + } + for _, s := range c.Remotes { + r, err := settings.DecodeRemote(s) if err != nil { return nil, fmt.Errorf("Failed to decode remote '%s': %s", s, err) } - if r.Socks && r.Reverse { - createSocksServer = true + if r.Socks { + hasSocks = true + } + if r.Reverse { + hasReverse = true } if r.Stdio { if hasStdio { @@ -90,27 +106,19 @@ func NewClient(config *Config) (*Client, error) { } hasStdio = true } - shared.Remotes = append(shared.Remotes, r) + client.computed.Remotes = append(client.computed.Remotes, r) } - config.shared = shared - client := &Client{ - Logger: chshare.NewLogger("client"), - config: config, - server: u.String(), - running: true, - runningc: make(chan error, 1), - } - client.Info = true - - if p := config.Proxy; p != "" { + //set default log level + client.Logger.Info = true + //outbound proxy + if p := c.Proxy; p != "" { client.proxyURL, err = url.Parse(p) if err != nil { return nil, fmt.Errorf("Invalid proxy URL (%s)", err) } } - - user, pass := chshare.ParseAuth(config.Auth) - + //ssh auth and config + user, pass := settings.ParseAuth(c.Auth) client.sshConfig = &ssh.ClientConfig{ User: user, Auth: []ssh.AuthMethod{ssh.Password(pass)}, @@ -118,15 +126,13 @@ func NewClient(config *Config) (*Client, error) { HostKeyCallback: client.verifyServer, Timeout: 30 * time.Second, } - - if createSocksServer { - socksConfig := &socks5.Config{} - client.socksServer, err = socks5.New(socksConfig) - if err != nil { - return nil, err - } - } - + //prepare client tunnel + client.tunnel = tunnel.New(tunnel.Config{ + Logger: client.Logger, + Inbound: true, //client always accepts inbound + Outbound: hasReverse, + Socks: hasReverse && hasSocks, + }) return client, nil } @@ -142,7 +148,7 @@ func (c *Client) Run() error { func (c *Client) verifyServer(hostname string, remote net.Addr, key ssh.PublicKey) error { expect := c.config.Fingerprint - got := chshare.FingerprintKey(key) + got := ccrypto.FingerprintKey(key) if expect != "" && !strings.HasPrefix(got, expect) { return fmt.Errorf("Invalid fingerprint (%s)", got) } @@ -153,49 +159,38 @@ func (c *Client) verifyServer(hostname string, remote net.Addr, key ssh.PublicKe //Start client and does not block func (c *Client) Start(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + c.stop = cancel + eg, ctx := errgroup.WithContext(ctx) + c.eg = eg via := "" if c.proxyURL != nil { via = " via " + c.proxyURL.String() } - //prepare non-reverse proxies - for i, r := range c.config.shared.Remotes { - if !r.Reverse { - proxy := chshare.NewTCPProxy(c.Logger, func() ssh.Conn { return c.sshConn }, i, r) - if err := proxy.Start(ctx); err != nil { - return err - } - } - } c.Infof("Connecting to %s%s\n", c.server, via) - //optional keepalive loop - if c.config.KeepAlive > 0 { - go c.keepAliveLoop() - } - //connection loop - go c.connectionLoop() + //connect chisel server + eg.Go(func() error { + return c.connectionLoop(ctx) + }) + //listen sockets + eg.Go(func() error { + clientInbound := c.computed.Remotes.Reversed(false) + return c.tunnel.BindRemotes(ctx, clientInbound) + }) return nil } -func (c *Client) keepAliveLoop() { - for c.running { - time.Sleep(c.config.KeepAlive) - if c.sshConn != nil { - c.sshConn.SendRequest("ping", true, nil) - } - } -} - -func (c *Client) connectionLoop() { +func (c *Client) connectionLoop(ctx context.Context) error { //connection loop! - var connerr error b := &backoff.Backoff{Max: c.config.MaxRetryInterval} - for c.running { - if connerr != nil { - attempt := int(b.Attempt()) - maxAttempt := c.config.MaxRetryCount - d := b.Duration() + for { + retry, err := c.connectionOnce(ctx) + //connection error + attempt := int(b.Attempt()) + maxAttempt := c.config.MaxRetryCount + if err != nil { //show error and attempt counts - msg := fmt.Sprintf("Connection error: %s", connerr) + msg := fmt.Sprintf("Connection error: %s", err) if attempt > 0 { msg += fmt.Sprintf(" (Attempt: %d", attempt) if maxAttempt > 0 { @@ -204,136 +199,136 @@ func (c *Client) connectionLoop() { msg += ")" } c.Debugf(msg) - //give up? - if maxAttempt >= 0 && attempt >= maxAttempt { - break - } - c.Infof("Retrying in %s...", d) - connerr = nil - chshare.SleepSignal(d) } - d := websocket.Dialer{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - HandshakeTimeout: 45 * time.Second, - Subprotocols: []string{chshare.ProtocolVersion}, - NetDialContext: c.config.DialContext, - } - //optionally proxy - if c.proxyURL != nil { - if strings.HasPrefix(c.proxyURL.Scheme, "socks") { - // SOCKS5 proxy - if c.proxyURL.Scheme != "socks" && c.proxyURL.Scheme != "socks5h" { - c.Infof( - "unsupported socks proxy type: %s:// (only socks5h:// or socks:// is supported)", - c.proxyURL.Scheme) - break - } - var auth *proxy.Auth - if c.proxyURL.User != nil { - pass, _ := c.proxyURL.User.Password() - auth = &proxy.Auth{ - User: c.proxyURL.User.Username(), - Password: pass, - } - } - socksDialer, err := proxy.SOCKS5("tcp", c.proxyURL.Host, auth, proxy.Direct) - if err != nil { - connerr = err - continue - } - d.NetDial = socksDialer.Dial - } else { - // CONNECT proxy - d.Proxy = func(*http.Request) (*url.URL, error) { - return c.proxyURL, nil - } - } - } - wsConn, _, err := d.Dial(c.server, c.config.Headers) - if err != nil { - connerr = err - continue - } - conn := chshare.NewWebSocketConn(wsConn) - // perform SSH handshake on net.Conn - c.Debugf("Handshaking...") - sshConn, chans, reqs, err := ssh.NewClientConn(conn, "", c.sshConfig) - if err != nil { - if strings.Contains(err.Error(), "unable to authenticate") { - c.Infof("Authentication failed") - c.Debugf(err.Error()) - } else { - c.Infof(err.Error()) - } + //give up? + if !retry || (maxAttempt >= 0 && attempt >= maxAttempt) { break } - c.config.shared.Version = chshare.BuildVersion - conf, _ := chshare.EncodeConfig(c.config.shared) - c.Debugf("Sending config") - t0 := time.Now() - _, configerr, err := sshConn.SendRequest("config", true, conf) - if err != nil { - c.Infof("Config verification failed") - break + d := b.Duration() + c.Infof("Retrying in %s...", d) + select { + case <-cos.AfterSignal(d): + continue //retry now + case <-ctx.Done(): + c.Infof("Cancelled") + return nil } - if len(configerr) > 0 { - c.Infof(string(configerr)) - break - } - c.Infof("Connected (Latency %s)", time.Since(t0)) - //connected - b.Reset() - c.sshConn = sshConn - go ssh.DiscardRequests(reqs) - go c.connectStreams(chans) - err = sshConn.Wait() - //disconnected - c.sshConn = nil - if err != nil && err != io.EOF { - connerr = err - continue - } - c.Infof("Disconnected\n") } - close(c.runningc) + c.Close() + return nil +} + +//connectionOnce returning nil error means retry +func (c *Client) connectionOnce(ctx context.Context) (retry bool, err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + select { + case <-ctx.Done(): + return false, io.EOF + default: + //still open + } + //prepare dialer + d := websocket.Dialer{ + HandshakeTimeout: 45 * time.Second, + Subprotocols: []string{chshare.ProtocolVersion}, + } + //optional proxy + if p := c.proxyURL; p != nil { + if err := c.setProxy(p, &d); err != nil { + return false, err + } + } + wsConn, _, err := d.DialContext(ctx, c.server, c.config.Headers) + if err != nil { + return true, err + } + conn := cnet.NewWebSocketConn(wsConn) + // perform SSH handshake on net.Conn + c.Debugf("Handshaking...") + sshConn, chans, reqs, err := ssh.NewClientConn(conn, "", c.sshConfig) + if err != nil { + if strings.Contains(err.Error(), "unable to authenticate") { + c.Infof("Authentication failed") + c.Debugf(err.Error()) + retry = false + } else if n, ok := err.(net.Error); ok && !n.Temporary() { + c.Infof(err.Error()) + retry = false + } else { + c.Infof("retriable: %s", err.Error()) + retry = true + } + return retry, err + } + defer sshConn.Close() + // chisel client handshake (reverse of server handshake) + // send configuration + c.Debugf("Sending config") + t0 := time.Now() + _, configerr, err := sshConn.SendRequest( + "config", + true, + settings.EncodeConfig(c.computed), + ) + if err != nil { + c.Infof("Config verification failed") + return false, err + } + if len(configerr) > 0 { + return false, errors.New(string(configerr)) + } + c.Infof("Connected (Latency %s)", time.Since(t0)) + defer c.Infof("Disconnected") + //connected, handover ssh connection for tunnel to use, and block + retry = true + err = c.tunnel.BindSSH(ctx, sshConn, reqs, chans) + if n, ok := err.(net.Error); ok && !n.Temporary() { + retry = false + } + return retry, err +} + +func (c *Client) setProxy(u *url.URL, d *websocket.Dialer) error { + // CONNECT proxy + if !strings.HasPrefix(u.Scheme, "socks") { + d.Proxy = func(*http.Request) (*url.URL, error) { + return u, nil + } + return nil + } + // SOCKS5 proxy + if u.Scheme != "socks" && u.Scheme != "socks5h" { + return fmt.Errorf( + "unsupported socks proxy type: %s:// (only socks5h:// or socks:// is supported)", + u.Scheme, + ) + } + var auth *proxy.Auth + if u.User != nil { + pass, _ := u.User.Password() + auth = &proxy.Auth{ + User: u.User.Username(), + Password: pass, + } + } + socksDialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct) + if err != nil { + return err + } + d.NetDial = socksDialer.Dial + return nil } //Wait blocks while the client is running. -//Can only be called once. func (c *Client) Wait() error { - return <-c.runningc + return c.eg.Wait() } //Close manually stops the client func (c *Client) Close() error { - c.running = false - if c.sshConn == nil { - return nil - } - return c.sshConn.Close() -} - -func (c *Client) connectStreams(chans <-chan ssh.NewChannel) { - for ch := range chans { - remote := string(ch.ExtraData()) - socks := remote == "socks" - if socks && c.socksServer == nil { - c.Debugf("Denied socks request, please enable client socks remote.") - ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled on the client") - continue - } - stream, reqs, err := ch.Accept() - if err != nil { - c.Debugf("Failed to accept stream: %s", err) - continue - } - go ssh.DiscardRequests(reqs) - l := c.Logger.Fork("conn#%d", c.connStats.New()) - if socks { - go chshare.HandleSocksStream(l, c.socksServer, &c.connStats, stream) - } else { - go chshare.HandleTCPStream(l, &c.connStats, stream, remote) - } + if c.stop != nil { + c.stop() } + return nil } diff --git a/client/client_test.go b/client/client_test.go index 0121f35..390f772 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -4,35 +4,38 @@ import ( "log" "net/http" "net/http/httptest" + "sync" "testing" "time" ) func TestCustomHeaders(t *testing.T) { + //fake server + wg := sync.WaitGroup{} + wg.Add(1) server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { if req.Header.Get("Foo") != "Bar" { t.Fatal("expected header Foo to be 'Bar'") } + wg.Done() })) - // Close the server when test finishes defer server.Close() + //client headers := http.Header{} headers.Set("Foo", "Bar") config := Config{ - Fingerprint: "", - Auth: "", KeepAlive: time.Second, - MaxRetryCount: 0, MaxRetryInterval: time.Second, Server: server.URL, - Remotes: []string{"socks"}, + Remotes: []string{"9000"}, Headers: headers, } c, err := NewClient(&config) if err != nil { log.Fatal(err) } - if err = c.Run(); err != nil { - log.Fatal(err) - } + go c.Run() + //wait for test to complete + wg.Wait() + c.Close() } diff --git a/go.mod b/go.mod index ac9b8f1..a27541f 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,8 @@ require ( github.com/jpillora/requestlog v1.0.0 github.com/jpillora/sizestr v1.0.0 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect - golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 - golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 - golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect + golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 + golang.org/x/net v0.0.0-20200707034311-ab3426394381 + golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 + golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect ) diff --git a/go.sum b/go.sum index cb5d510..6f22865 100644 --- a/go.sum +++ b/go.sum @@ -17,15 +17,18 @@ github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mz github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= -golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index 93c4938..a42dc8b 100644 --- a/main.go +++ b/main.go @@ -10,16 +10,18 @@ import ( "runtime" "strconv" "strings" + "time" chclient "github.com/jpillora/chisel/client" chserver "github.com/jpillora/chisel/server" chshare "github.com/jpillora/chisel/share" + "github.com/jpillora/chisel/share/cos" ) var help = ` Usage: chisel [command] [--help] - Version: ` + chshare.BuildVersion + ` + Version: ` + chshare.BuildVersion + ` (` + runtime.Version() + `) Commands: server - runs chisel in server mode @@ -120,8 +122,15 @@ var serverHelp = ` remotes. This file will be automatically reloaded on change. --auth, An optional string representing a single user with full - access, in the form of . This is equivalent to creating an - authfile with {"": [""]}. + access, in the form of . It is equivalent to creating an + authfile with {"": [""]}. If unset, it will use the + environment variable AUTH. + + --keepalive, An optional keepalive interval. Since the underlying + transport is HTTP, in many instances we'll be traversing through + proxies, often these proxies will close idle connections. You must + specify a time with a unit, for example '5s' or '2m'. Defaults + to '25s' (set to 0s to disable). --proxy, Specifies another HTTP server to proxy requests to when chisel receives a normal HTTP request. Useful for hiding chisel in @@ -138,15 +147,18 @@ func server(args []string) { flags := flag.NewFlagSet("server", flag.ContinueOnError) + config := &chserver.Config{} + flags.StringVar(&config.KeySeed, "key", "", "") + flags.StringVar(&config.AuthFile, "authfile", "", "") + flags.StringVar(&config.Auth, "auth", "", "") + flags.DurationVar(&config.KeepAlive, "keepalive", 25*time.Second, "") + flags.StringVar(&config.Proxy, "proxy", "", "") + flags.BoolVar(&config.Socks5, "socks5", false, "") + flags.BoolVar(&config.Reverse, "reverse", false, "") + host := flags.String("host", "", "") p := flags.String("p", "", "") port := flags.String("port", "", "") - key := flags.String("key", "", "") - authfile := flags.String("authfile", "", "") - auth := flags.String("auth", "", "") - proxy := flags.String("proxy", "", "") - socks5 := flags.Bool("socks5", false, "") - reverse := flags.Bool("reverse", false, "") pid := flags.Bool("pid", false, "") verbose := flags.Bool("v", false, "") @@ -171,17 +183,10 @@ func server(args []string) { if *port == "" { *port = "8080" } - if *key == "" { - *key = os.Getenv("CHISEL_KEY") + if config.KeySeed == "" { + config.KeySeed = os.Getenv("CHISEL_KEY") } - s, err := chserver.NewServer(&chserver.Config{ - KeySeed: *key, - AuthFile: *authfile, - Auth: *auth, - Proxy: *proxy, - Socks5: *socks5, - Reverse: *reverse, - }) + s, err := chserver.NewServer(config) if err != nil { log.Fatal(err) } @@ -189,10 +194,14 @@ func server(args []string) { if *pid { generatePidFile() } - go chshare.GoStats() - if err = s.Run(*host, *port); err != nil { + go cos.GoStats() + ctx := cos.InterruptContext() + if err := s.StartContext(ctx, *host, *port); err != nil { log.Fatal(err) } + if err := s.Wait(); err != nil { + log.Fatal() + } } type headerFlags struct { @@ -293,8 +302,8 @@ var clientHelp = ` --keepalive, An optional keepalive interval. Since the underlying transport is HTTP, in many instances we'll be traversing through proxies, often these proxies will close idle connections. You must - specify a time with a unit, for example '30s' or '2m'. Defaults - to '0s' (disabled). + specify a time with a unit, for example '5s' or '2m'. Defaults + to '25s' (set to 0s to disable). --max-retry-count, Maximum number of times to retry before exiting. Defaults to unlimited. @@ -306,7 +315,7 @@ var clientHelp = ` used to reach the chisel server. Authentication can be specified inside the URL. For example, http://admin:password@my-server.com:8081 - or: socks://admin:password@my-server.com:1080 + or: socks://admin:password@my-server.com:1080 --header, Set a custom header in the form "HeaderName: HeaderContent". Can be used multiple times. (e.g --header "Foo: Bar" --header "Hello: World") @@ -320,7 +329,7 @@ func client(args []string) { config := chclient.Config{Headers: http.Header{}} flags.StringVar(&config.Fingerprint, "fingerprint", "", "") flags.StringVar(&config.Auth, "auth", "", "") - flags.DurationVar(&config.KeepAlive, "keepalive", 0, "") + flags.DurationVar(&config.KeepAlive, "keepalive", 25*time.Second, "") flags.IntVar(&config.MaxRetryCount, "max-retry-count", -1, "") flags.DurationVar(&config.MaxRetryInterval, "max-retry-interval", 0, "") flags.StringVar(&config.Proxy, "proxy", "", "") @@ -357,8 +366,12 @@ func client(args []string) { if *pid { generatePidFile() } - go chshare.GoStats() - if err = c.Run(); err != nil { + go cos.GoStats() + ctx := cos.InterruptContext() + if err := c.Start(ctx); err != nil { log.Fatal(err) } + if err := c.Wait(); err != nil { + log.Fatal() + } } diff --git a/server/server.go b/server/server.go index 074c94a..9353795 100644 --- a/server/server.go +++ b/server/server.go @@ -1,139 +1,135 @@ package chserver import ( + "context" "errors" - "io/ioutil" "log" "net/http" "net/http/httputil" "net/url" - "os" "regexp" + "time" - socks5 "github.com/armon/go-socks5" "github.com/gorilla/websocket" chshare "github.com/jpillora/chisel/share" + "github.com/jpillora/chisel/share/ccrypto" + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/settings" "github.com/jpillora/requestlog" "golang.org/x/crypto/ssh" ) // Config is the configuration for the chisel service type Config struct { - KeySeed string - AuthFile string - Auth string - Proxy string - Socks5 bool - Reverse bool + KeySeed string + AuthFile string + Auth string + Proxy string + Socks5 bool + Reverse bool + KeepAlive time.Duration } // Server respresent a chisel service type Server struct { - *chshare.Logger - connStats chshare.ConnStats + *cio.Logger + config *Config fingerprint string - httpServer *chshare.HTTPServer + httpServer *cnet.HTTPServer reverseProxy *httputil.ReverseProxy sessCount int32 - sessions *chshare.Users - socksServer *socks5.Server + sessions *settings.Users sshConfig *ssh.ServerConfig - users *chshare.UserIndex - reverseOk bool + users *settings.UserIndex } var upgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { return true }, } // NewServer creates and returns a new chisel server -func NewServer(config *Config) (*Server, error) { - s := &Server{ - httpServer: chshare.NewHTTPServer(), - Logger: chshare.NewLogger("server"), - sessions: chshare.NewUsers(), - reverseOk: config.Reverse, +func NewServer(c *Config) (*Server, error) { + server := &Server{ + config: c, + httpServer: cnet.NewHTTPServer(), + Logger: cio.NewLogger("server"), + sessions: settings.NewUsers(), } - s.Info = true - s.users = chshare.NewUserIndex(s.Logger) - if config.AuthFile != "" { - if err := s.users.LoadUsers(config.AuthFile); err != nil { + server.Info = true + server.users = settings.NewUserIndex(server.Logger) + if c.AuthFile != "" { + if err := server.users.LoadUsers(c.AuthFile); err != nil { return nil, err } } - if config.Auth != "" { - u := &chshare.User{Addrs: []*regexp.Regexp{chshare.UserAllowAll}} - u.Name, u.Pass = chshare.ParseAuth(config.Auth) + if c.Auth != "" { + u := &settings.User{Addrs: []*regexp.Regexp{settings.UserAllowAll}} + u.Name, u.Pass = settings.ParseAuth(c.Auth) if u.Name != "" { - s.users.AddUser(u) + server.users.AddUser(u) } } //generate private key (optionally using seed) - key, _ := chshare.GenerateKey(config.KeySeed) + key, err := ccrypto.GenerateKey(c.KeySeed) + if err != nil { + log.Fatal("Failed to generate key") + } //convert into ssh.PrivateKey private, err := ssh.ParsePrivateKey(key) if err != nil { log.Fatal("Failed to parse key") } //fingerprint this key - s.fingerprint = chshare.FingerprintKey(private.PublicKey()) + server.fingerprint = ccrypto.FingerprintKey(private.PublicKey()) //create ssh config - s.sshConfig = &ssh.ServerConfig{ + server.sshConfig = &ssh.ServerConfig{ ServerVersion: "SSH-" + chshare.ProtocolVersion + "-server", - PasswordCallback: s.authUser, + PasswordCallback: server.authUser, } - s.sshConfig.AddHostKey(private) + server.sshConfig.AddHostKey(private) //setup reverse proxy - if config.Proxy != "" { - u, err := url.Parse(config.Proxy) + if c.Proxy != "" { + u, err := url.Parse(c.Proxy) if err != nil { return nil, err } if u.Host == "" { - return nil, s.Errorf("Missing protocol (%s)", u) + return nil, server.Errorf("Missing protocol (%s)", u) } - s.reverseProxy = httputil.NewSingleHostReverseProxy(u) + server.reverseProxy = httputil.NewSingleHostReverseProxy(u) //always use proxy host - s.reverseProxy.Director = func(r *http.Request) { + server.reverseProxy.Director = func(r *http.Request) { + //enforce origin, keep path r.URL.Scheme = u.Scheme r.URL.Host = u.Host r.Host = u.Host } } - //setup socks server (not listening on any port!) - if config.Socks5 { - socksConfig := &socks5.Config{} - if s.Debug { - socksConfig.Logger = log.New(os.Stdout, "[socks]", log.Ldate|log.Ltime) - } else { - socksConfig.Logger = log.New(ioutil.Discard, "", 0) - } - s.socksServer, err = socks5.New(socksConfig) - if err != nil { - return nil, err - } - s.Infof("SOCKS5 server enabled") - } //print when reverse tunnelling is enabled - if config.Reverse { - s.Infof("Reverse tunnelling enabled") + if c.Reverse { + server.Infof("Reverse tunnelling enabled") } - return s, nil + return server, nil } -// Run is responsible for starting the chisel service +// Run is responsible for starting the chisel service. +// Internally this calls Start then Wait. func (s *Server) Run(host, port string) error { if err := s.Start(host, port); err != nil { return err } - return s.Wait() } // Start is responsible for kicking off the http server func (s *Server) Start(host, port string) error { + return s.StartContext(nil, host, port) +} + +// StartContext is responsible for kicking off the http server, +// and can be closed by cancelling the provided context +func (s *Server) StartContext(ctx context.Context, host, port string) error { s.Infof("Fingerprint %s", s.fingerprint) if s.users.Len() > 0 { s.Infof("User authenication enabled") @@ -148,7 +144,7 @@ func (s *Server) Start(host, port string) error { o.TrustProxy = true h = requestlog.WrapWith(h, o) } - return s.httpServer.GoListenAndServe(host+":"+port, h) + return s.httpServer.GoListenAndServeContext(ctx, host+":"+port, h) } // Wait waits for the http server to close @@ -180,26 +176,26 @@ func (s *Server) authUser(c ssh.ConnMetadata, password []byte) (*ssh.Permissions return nil, errors.New("Invalid authentication for username: %s") } // insert the user session map - // @note: this should probably have a lock on it given the map isn't thread-safe?? + // TODO this should probably have a lock on it given the map isn't thread-safe s.sessions.Set(string(c.SessionID()), user) return nil, nil } // AddUser adds a new user into the server user index func (s *Server) AddUser(user, pass string, addrs ...string) error { - authorizedAddrs := make([]*regexp.Regexp, 0) - + authorizedAddrs := []*regexp.Regexp{} for _, addr := range addrs { authorizedAddr, err := regexp.Compile(addr) if err != nil { return err } - authorizedAddrs = append(authorizedAddrs, authorizedAddr) } - - u := &chshare.User{Name: user, Pass: pass, Addrs: authorizedAddrs} - s.users.AddUser(u) + s.users.AddUser(&settings.User{ + Name: user, + Pass: pass, + Addrs: authorizedAddrs, + }) return nil } diff --git a/server/handler.go b/server/server_handler.go similarity index 52% rename from server/handler.go rename to server/server_handler.go index 31c0982..d36308f 100644 --- a/server/handler.go +++ b/server/server_handler.go @@ -1,14 +1,17 @@ package chserver import ( - "context" "net/http" "strings" "sync/atomic" "time" chshare "github.com/jpillora/chisel/share" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/settings" + "github.com/jpillora/chisel/share/tunnel" "golang.org/x/crypto/ssh" + "golang.org/x/sync/errgroup" ) // handleClientHandler is the main http websocket handler for the chisel server @@ -47,46 +50,52 @@ func (s *Server) handleClientHandler(w http.ResponseWriter, r *http.Request) { // handleWebsocket is responsible for handling the websocket connection func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) { id := atomic.AddInt32(&s.sessCount, 1) - clog := s.Fork("session#%d", id) + l := s.Fork("session#%d", id) wsConn, err := upgrader.Upgrade(w, req, nil) if err != nil { - clog.Debugf("Failed to upgrade (%s)", err) + l.Debugf("Failed to upgrade (%s)", err) return } - conn := chshare.NewWebSocketConn(wsConn) + conn := cnet.NewWebSocketConn(wsConn) // perform SSH handshake on net.Conn - clog.Debugf("Handshaking...") + l.Debugf("Handshaking...") sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig) if err != nil { s.Debugf("Failed to handshake (%s)", err) return } // pull the users from the session map - var user *chshare.User + var user *settings.User if s.users.Len() > 0 { sid := string(sshConn.SessionID()) - user, _ = s.sessions.Get(sid) + u, ok := s.sessions.Get(sid) + if !ok { + panic("bug in ssh auth handler") + } + user = u s.sessions.Del(sid) } - //verify configuration - clog.Debugf("Verifying configuration") - //wait for request, with timeout + // chisel server handshake (reverse of client handshake) + // verify configuration + l.Debugf("Verifying configuration") + // wait for request, with timeout var r *ssh.Request select { case r = <-reqs: case <-time.After(10 * time.Second): + l.Debugf("Timeout waiting for configuration") sshConn.Close() return } failed := func(err error) { - clog.Debugf("Failed: %s", err) + l.Debugf("Failed: %s", err) r.Reply(false, []byte(err.Error())) } if r.Type != "config" { failed(s.Errorf("expecting config request")) return } - c, err := chshare.DecodeConfig(r.Payload) + c, err := settings.DecodeConfig(r.Payload) if err != nil { failed(s.Errorf("invalid config")) return @@ -97,13 +106,13 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) { if v == "" { v = "" } - clog.Infof("Client version (%s) differs from server version (%s)", + l.Infof("Client version (%s) differs from server version (%s)", v, chshare.BuildVersion) } //confirm reverse tunnels are allowed for _, r := range c.Remotes { - if r.Reverse && !s.reverseOk { - clog.Debugf("Denied reverse port forwarding request, please enable --reverse") + if r.Reverse && !s.config.Reverse { + l.Debugf("Denied reverse port forwarding request, please enable --reverse") failed(s.Errorf("Reverse port forwaring not enabled on server")) return } @@ -112,74 +121,37 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) { //access to the desired remotes if user != nil { for _, r := range c.Remotes { - var addr string - if r.Reverse { - addr = "R:" + r.LocalHost + ":" + r.LocalPort - } else { - addr = r.RemoteHost + ":" + r.RemotePort - } + addr := r.UserAddr() if !user.HasAccess(addr) { failed(s.Errorf("access to '%s' denied", addr)) return } } } - //set up reverse port forwarding - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - for i, r := range c.Remotes { - if r.Reverse { - proxy := chshare.NewTCPProxy(s.Logger, func() ssh.Conn { return sshConn }, i, r) - if err := proxy.Start(ctx); err != nil { - failed(s.Errorf("%s", err)) - return - } - } - } - //success! + //successfuly validated config! r.Reply(true, nil) - //prepare connection logger - clog.Debugf("Open") - go s.handleSSHRequests(clog, reqs) - go s.handleSSHChannels(clog, chans) - sshConn.Wait() - clog.Debugf("Close") -} - -func (s *Server) handleSSHRequests(clientLog *chshare.Logger, reqs <-chan *ssh.Request) { - for r := range reqs { - switch r.Type { - case "ping": - r.Reply(true, nil) - default: - clientLog.Debugf("Unknown request: %s", r.Type) - } - } -} - -func (s *Server) handleSSHChannels(clientLog *chshare.Logger, chans <-chan ssh.NewChannel) { - for ch := range chans { - remote := string(ch.ExtraData()) - socks := remote == "socks" - //dont accept socks when --socks5 isn't enabled - if socks && s.socksServer == nil { - clientLog.Debugf("Denied socks request, please enable --socks5") - ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled on the server") - continue - } - //accept rest - stream, reqs, err := ch.Accept() - if err != nil { - clientLog.Debugf("Failed to accept stream: %s", err) - continue - } - go ssh.DiscardRequests(reqs) - //handle stream type - connID := s.connStats.New() - if socks { - go chshare.HandleSocksStream(clientLog.Fork("socksconn#%d", connID), s.socksServer, &s.connStats, stream) - } else { - go chshare.HandleTCPStream(clientLog.Fork("conn#%d", connID), &s.connStats, stream, remote) - } + //tunnel per ssh connection + tunnel := tunnel.New(tunnel.Config{ + Logger: l, + Inbound: s.config.Reverse, + Outbound: true, //server always accepts outbound + Socks: s.config.Socks5, + KeepAlive: s.config.KeepAlive, + }) + //bind + eg, ctx := errgroup.WithContext(req.Context()) + eg.Go(func() error { + //connected, handover ssh connection for tunnel to use, and block + return tunnel.BindSSH(ctx, sshConn, reqs, chans) + }) + eg.Go(func() error { + serverInbound := c.Remotes.Reversed(true) + return tunnel.BindRemotes(ctx, serverInbound) + }) + err = eg.Wait() + if err != nil && !strings.HasSuffix(err.Error(), "EOF") { + l.Debugf("Closed connection (%s)", err) + } else { + l.Debugf("Closed connection") } } diff --git a/share/determ_rand.go b/share/ccrypto/determ_rand.go similarity index 85% rename from share/determ_rand.go rename to share/ccrypto/determ_rand.go index 42c47b7..24ff444 100644 --- a/share/determ_rand.go +++ b/share/ccrypto/determ_rand.go @@ -1,4 +1,4 @@ -package chshare +package ccrypto // Deterministic crypto.Reader // overview: half the result is used as the output @@ -18,17 +18,17 @@ func NewDetermRand(seed []byte) io.Reader { for i := 0; i < DetermRandIter; i++ { next, out = hash(next) } - return &DetermRand{ + return &determRand{ next: next, out: out, } } -type DetermRand struct { +type determRand struct { next, out []byte } -func (d *DetermRand) Read(b []byte) (int, error) { +func (d *determRand) Read(b []byte) (int, error) { n := 0 l := len(b) for n < l { diff --git a/share/ccrypto/keys.go b/share/ccrypto/keys.go new file mode 100644 index 0000000..31a10db --- /dev/null +++ b/share/ccrypto/keys.go @@ -0,0 +1,41 @@ +package ccrypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/md5" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" +) + +//GenerateKey for use as an SSH private key +func GenerateKey(seed string) ([]byte, error) { + r := rand.Reader + if seed != "" { + r = NewDetermRand([]byte(seed)) + } + priv, err := ecdsa.GenerateKey(elliptic.P256(), r) + if err != nil { + return nil, err + } + b, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("Unable to marshal ECDSA private key: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b}), nil +} + +//FingerprintKey calculates the MD5 of an SSH public key +func FingerprintKey(k ssh.PublicKey) string { + bytes := md5.Sum(k.Marshal()) + strbytes := make([]string, len(bytes)) + for i, b := range bytes { + strbytes[i] = fmt.Sprintf("%02x", b) + } + return strings.Join(strbytes, ":") +} diff --git a/share/logger.go b/share/cio/logger.go similarity index 98% rename from share/logger.go rename to share/cio/logger.go index 93514b5..63ac0c3 100644 --- a/share/logger.go +++ b/share/cio/logger.go @@ -1,4 +1,4 @@ -package chshare +package cio import ( "fmt" diff --git a/share/cio/pipe.go b/share/cio/pipe.go new file mode 100644 index 0000000..5cc314d --- /dev/null +++ b/share/cio/pipe.go @@ -0,0 +1,48 @@ +package cio + +import ( + "io" + "log" + "sync" +) + +func Pipe(src io.ReadWriteCloser, dst io.ReadWriteCloser) (int64, int64) { + var sent, received int64 + var wg sync.WaitGroup + var o sync.Once + close := func() { + src.Close() + dst.Close() + } + wg.Add(2) + go func() { + received, _ = io.Copy(src, pipeVis("send", dst)) + o.Do(close) + wg.Done() + }() + go func() { + sent, _ = io.Copy(dst, pipeVis("recv", src)) + o.Do(close) + wg.Done() + }() + wg.Wait() + return sent, received +} + +const vis = false + +type pipeVisPrinter struct { + name string +} + +func (p pipeVisPrinter) Write(b []byte) (int, error) { + log.Printf(">>> %s: %x", p.name, b) + return len(b), nil +} + +func pipeVis(name string, r io.Reader) io.Reader { + if vis { + return io.TeeReader(r, pipeVisPrinter{name}) + } + return r +} diff --git a/share/stdio.go b/share/cio/stdio.go similarity index 90% rename from share/stdio.go rename to share/cio/stdio.go index 5cf97e8..24798de 100644 --- a/share/stdio.go +++ b/share/cio/stdio.go @@ -1,4 +1,4 @@ -package chshare +package cio import ( "io" diff --git a/share/conn_rwc.go b/share/cnet/conn_rwc.go similarity index 97% rename from share/conn_rwc.go rename to share/cnet/conn_rwc.go index 680baad..dd31f80 100644 --- a/share/conn_rwc.go +++ b/share/cnet/conn_rwc.go @@ -1,4 +1,4 @@ -package chshare +package cnet import ( "io" diff --git a/share/conn_ws.go b/share/cnet/conn_ws.go similarity index 84% rename from share/conn_ws.go rename to share/cnet/conn_ws.go index 16bd410..840c767 100644 --- a/share/conn_ws.go +++ b/share/cnet/conn_ws.go @@ -1,7 +1,6 @@ -package chshare +package cnet import ( - "log" "net" "time" @@ -26,17 +25,13 @@ func (c *wsConn) Read(dst []byte) (int, error) { ldst := len(dst) //use buffer or read new message var src []byte - if l := len(c.buff); l > 0 { + if len(c.buff) > 0 { src = c.buff c.buff = nil - } else { - t, msg, err := c.Conn.ReadMessage() - if err != nil { - return 0, err - } else if t != websocket.BinaryMessage { - log.Printf(" non-binary msg") - } + } else if _, msg, err := c.Conn.ReadMessage(); err == nil { src = msg + } else { + return 0, err } //copy src->dest var n int diff --git a/share/connstats.go b/share/cnet/connstats.go similarity index 96% rename from share/connstats.go rename to share/cnet/connstats.go index 89fb9e1..2211d94 100644 --- a/share/connstats.go +++ b/share/cnet/connstats.go @@ -1,4 +1,4 @@ -package chshare +package cnet import ( "fmt" diff --git a/share/cnet/http_server.go b/share/cnet/http_server.go new file mode 100644 index 0000000..b7000f6 --- /dev/null +++ b/share/cnet/http_server.go @@ -0,0 +1,71 @@ +package cnet + +import ( + "context" + "errors" + "net" + "net/http" + + "golang.org/x/sync/errgroup" +) + +//HTTPServer extends net/http Server and +//adds graceful shutdowns +type HTTPServer struct { + *http.Server + serving bool + waiter *errgroup.Group + listenErr error +} + +//NewHTTPServer creates a new HTTPServer +func NewHTTPServer() *HTTPServer { + return &HTTPServer{ + Server: &http.Server{}, + serving: false, + } + +} + +func (h *HTTPServer) GoListenAndServe(addr string, handler http.Handler) error { + return h.GoListenAndServeContext(context.Background(), addr, handler) +} + +func (h *HTTPServer) GoListenAndServeContext(ctx context.Context, addr string, handler http.Handler) error { + if ctx == nil { + return errors.New("ctx must be set") + } + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + h.Handler = handler + h.serving = true + h.waiter, ctx = errgroup.WithContext(ctx) + h.waiter.Go(func() error { + return h.Serve(l) + }) + go func() { + <-ctx.Done() + h.Close() + }() + return nil +} + +func (h *HTTPServer) Close() error { + if !h.serving { + return errors.New("not started yet") + } + return h.Server.Close() +} + +func (h *HTTPServer) Wait() error { + if !h.serving { + return errors.New("not started yet") + } + err := h.waiter.Wait() + if err == http.ErrServerClosed { + err = nil //success + } + return err +} diff --git a/share/compat.go b/share/compat.go new file mode 100644 index 0000000..eb6c85a --- /dev/null +++ b/share/compat.go @@ -0,0 +1,59 @@ +package chshare + +//this file exists to maintain backwards compatibility + +import ( + "github.com/jpillora/chisel/share/ccrypto" + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/cos" + "github.com/jpillora/chisel/share/settings" +) + +const ( + DetermRandIter = ccrypto.DetermRandIter +) + +type ( + Config = settings.Config + Remote = settings.Remote + Remotes = settings.Remotes + User = settings.User + Users = settings.Users + UserIndex = settings.UserIndex + HTTPServer = cnet.HTTPServer + ConnStats = cnet.ConnStats + Logger = cio.Logger +) + +var ( + NewDetermRand = ccrypto.NewDetermRand + GenerateKey = ccrypto.GenerateKey + FingerprintKey = ccrypto.FingerprintKey + Pipe = cio.Pipe + NewLoggerFlag = cio.NewLoggerFlag + NewLogger = cio.NewLogger + Stdio = cio.Stdio + DecodeConfig = settings.DecodeConfig + DecodeRemote = settings.DecodeRemote + NewUsers = settings.NewUsers + NewUserIndex = settings.NewUserIndex + UserAllowAll = settings.UserAllowAll + ParseAuth = settings.ParseAuth + NewRWCConn = cnet.NewRWCConn + NewWebSocketConn = cnet.NewWebSocketConn + NewHTTPServer = cnet.NewHTTPServer + GoStats = cos.GoStats + SleepSignal = cos.SleepSignal +) + +//EncodeConfig old version +func EncodeConfig(c *settings.Config) ([]byte, error) { + return settings.EncodeConfig(*c), nil +} + +// //TCPProxy makes this package backward compatible +// type TCPProxy = Proxy + +// //NewTCPProxy makes this package backward compatible +// var NewTCPProxy = NewProxy diff --git a/share/gostats.go b/share/cos/gostats.go similarity index 97% rename from share/gostats.go rename to share/cos/gostats.go index 01c4ed9..fe55f8e 100644 --- a/share/gostats.go +++ b/share/cos/gostats.go @@ -1,4 +1,4 @@ -package chshare +package cos import ( "log" diff --git a/share/cos/interupt.go b/share/cos/interupt.go new file mode 100644 index 0000000..03ff068 --- /dev/null +++ b/share/cos/interupt.go @@ -0,0 +1,21 @@ +package cos + +import ( + "context" + "os" + "os/signal" +) + +//InterruptContext returns a context which is +//cancelled on OS Interrupt +func InterruptContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + signal.Stop(sig) + cancel() + }() + return ctx +} diff --git a/share/cos/sleep.go b/share/cos/sleep.go new file mode 100644 index 0000000..87301ed --- /dev/null +++ b/share/cos/sleep.go @@ -0,0 +1,33 @@ +//+build !windows + +package cos + +import ( + "os" + "os/signal" + "syscall" + "time" +) + +//SleepSignal sleeps for the given duration, +//or until a SIGHUP is received +func SleepSignal(d time.Duration) { + <-AfterSignal(d) +} + +//AfterSignal returns a channel which will be closed +//after the given duration or until a SIGHUP is received +func AfterSignal(d time.Duration) <-chan struct{} { + ch := make(chan struct{}) + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGHUP) + select { + case <-time.After(d): + case <-sig: + } + signal.Stop(sig) + close(ch) + }() + return ch +} diff --git a/share/signal_windows.go b/share/cos/sleep_windows.go similarity index 88% rename from share/signal_windows.go rename to share/cos/sleep_windows.go index b554903..13be18c 100644 --- a/share/signal_windows.go +++ b/share/cos/sleep_windows.go @@ -1,6 +1,6 @@ //+build windows -package chshare +package cos import "time" diff --git a/share/http_server.go b/share/http_server.go deleted file mode 100644 index c1bc6bb..0000000 --- a/share/http_server.go +++ /dev/null @@ -1,61 +0,0 @@ -package chshare - -import ( - "errors" - "net" - "net/http" - "sync" -) - -//HTTPServer extends net/http Server and -//adds graceful shutdowns -type HTTPServer struct { - *http.Server - listener net.Listener - running chan error - isRunning bool - closer sync.Once -} - -//NewHTTPServer creates a new HTTPServer -func NewHTTPServer() *HTTPServer { - return &HTTPServer{ - Server: &http.Server{}, - listener: nil, - running: make(chan error, 1), - } -} - -func (h *HTTPServer) GoListenAndServe(addr string, handler http.Handler) error { - l, err := net.Listen("tcp", addr) - if err != nil { - return err - } - h.isRunning = true - h.Handler = handler - h.listener = l - go func() { - h.closeWith(h.Serve(l)) - }() - return nil -} - -func (h *HTTPServer) closeWith(err error) { - if !h.isRunning { - return - } - h.isRunning = false - h.running <- err -} - -func (h *HTTPServer) Close() error { - h.closeWith(nil) - return h.listener.Close() -} - -func (h *HTTPServer) Wait() error { - if !h.isRunning { - return errors.New("Already closed") - } - return <-h.running -} diff --git a/share/pipe.go b/share/pipe.go deleted file mode 100644 index 30fdf60..0000000 --- a/share/pipe.go +++ /dev/null @@ -1,29 +0,0 @@ -package chshare - -import ( - "io" - "sync" -) - -func Pipe(src io.ReadWriteCloser, dst io.ReadWriteCloser) (int64, int64) { - var sent, received int64 - var wg sync.WaitGroup - var o sync.Once - close := func() { - src.Close() - dst.Close() - } - wg.Add(2) - go func() { - received, _ = io.Copy(src, dst) - o.Do(close) - wg.Done() - }() - go func() { - sent, _ = io.Copy(dst, src) - o.Do(close) - wg.Done() - }() - wg.Wait() - return sent, received -} diff --git a/share/proxy.go b/share/proxy.go deleted file mode 100644 index d60b2af..0000000 --- a/share/proxy.go +++ /dev/null @@ -1,106 +0,0 @@ -package chshare - -import ( - "context" - "fmt" - "io" - "net" - - "github.com/jpillora/sizestr" - "golang.org/x/crypto/ssh" -) - -type GetSSHConn func() ssh.Conn - -type TCPProxy struct { - *Logger - ssh GetSSHConn - id int - count int - remote *Remote -} - -func NewTCPProxy(logger *Logger, ssh GetSSHConn, index int, remote *Remote) *TCPProxy { - id := index + 1 - return &TCPProxy{ - Logger: logger.Fork("proxy#%d:%s", id, remote), - ssh: ssh, - id: id, - remote: remote, - } -} - -func (p *TCPProxy) Start(ctx context.Context) error { - if p.remote.Stdio { - go p.listenStdio(ctx) - return nil - } - l, err := net.Listen("tcp4", p.remote.LocalHost+":"+p.remote.LocalPort) - if err != nil { - return fmt.Errorf("%s: %s", p.Logger.Prefix(), err) - } - go p.listenNet(ctx, l) - return nil -} - -func (p *TCPProxy) listenStdio(ctx context.Context) { - for { - p.accept(Stdio) - select { - case <-ctx.Done(): - return - default: - // the connection is not ready yet, keep waiting - } - } -} - -func (p *TCPProxy) listenNet(ctx context.Context, l net.Listener) { - p.Infof("Listening") - done := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - l.Close() - p.Infof("Closed") - case <-done: - } - }() - for { - src, err := l.Accept() - if err != nil { - select { - case <-ctx.Done(): - //listener closed - default: - p.Infof("Accept error: %s", err) - } - close(done) - return - } - go p.accept(src) - } -} - -func (p *TCPProxy) accept(src io.ReadWriteCloser) { - defer src.Close() - p.count++ - cid := p.count - l := p.Fork("conn#%d", cid) - l.Debugf("Open") - sshConn := p.ssh() - if sshConn == nil { - l.Debugf("No remote connection") - return - } - //ssh request for tcp connection for this proxy's remote - dst, reqs, err := sshConn.OpenChannel("chisel", []byte(p.remote.Remote())) - if err != nil { - l.Infof("Stream error: %s", err) - return - } - go ssh.DiscardRequests(reqs) - //then pipe - s, r := Pipe(src, dst) - l.Debugf("Close (sent %s received %s)", sizestr.ToString(s), sizestr.ToString(r)) -} diff --git a/share/remote.go b/share/remote.go deleted file mode 100644 index 0e75817..0000000 --- a/share/remote.go +++ /dev/null @@ -1,138 +0,0 @@ -package chshare - -import ( - "errors" - "net/url" - "regexp" - "strings" -) - -// short-hand conversions -// 3000 -> -// local 127.0.0.1:3000 -// remote 127.0.0.1:3000 -// foobar.com:3000 -> -// local 127.0.0.1:3000 -// remote foobar.com:3000 -// 3000:google.com:80 -> -// local 127.0.0.1:3000 -// remote google.com:80 -// 192.168.0.1:3000:google.com:80 -> -// local 192.168.0.1:3000 -// remote google.com:80 -// 127.0.0.1:1080:socks -// local 127.0.0.1:1080 -// remote socks -// stdio:example.com:22 -// local stdio -// remote example.com:22 - -type Remote struct { - LocalHost, LocalPort, RemoteHost, RemotePort string - Socks, Reverse, Stdio bool -} - -const revPrefix = "R:" - -func DecodeRemote(s string) (*Remote, error) { - reverse := false - if strings.HasPrefix(s, revPrefix) { - s = strings.TrimPrefix(s, revPrefix) - reverse = true - } - parts := strings.Split(s, ":") - if len(parts) <= 0 || len(parts) >= 5 { - return nil, errors.New("Invalid remote") - } - r := &Remote{Reverse: reverse} - for i := len(parts) - 1; i >= 0; i-- { - p := parts[i] - //remote portion is socks? - if i == len(parts)-1 && p == "socks" { - r.Socks = true - continue - } - //local portion is stdio? - if i == 0 && p == "stdio" { - r.Stdio = true - continue - } - if isPort(p) { - if !r.Socks && r.RemotePort == "" { - r.RemotePort = p - r.LocalPort = p - } else { - r.LocalPort = p - } - continue - } - if !r.Socks && (r.RemotePort == "" && r.LocalPort == "") { - return nil, errors.New("Missing ports") - } - if !isHost(p) { - return nil, errors.New("Invalid host") - } - if !r.Socks && r.RemoteHost == "" { - r.RemoteHost = p - } else { - r.LocalHost = p - } - } - if r.LocalHost == "" { - if r.Socks { - r.LocalHost = "127.0.0.1" - } else { - r.LocalHost = "0.0.0.0" - } - } - if r.LocalPort == "" && r.Socks { - r.LocalPort = "1080" - } - if !r.Socks && r.RemoteHost == "" { - r.RemoteHost = "0.0.0.0" - } - if r.Stdio && r.Reverse { - return nil, errors.New("stdio cannot be reversed") - } - return r, nil -} - -var isPortRegExp = regexp.MustCompile(`^\d+$`) - -func isPort(s string) bool { - if !isPortRegExp.MatchString(s) { - return false - } - return true -} - -func isHost(s string) bool { - _, err := url.Parse(s) - if err != nil { - return false - } - return true -} - -//implement Stringer -func (r *Remote) String() string { - tag := "" - if r.Reverse { - tag = revPrefix - } - return tag + r.Local() + "=>" + r.Remote() -} - -func (r *Remote) Local() string { - if r.Stdio { - return "stdio" - } - return r.LocalHost + ":" + r.LocalPort -} - -func (r *Remote) Remote() string { - if r.Socks { - return "socks" - } - return r.RemoteHost + ":" + r.RemotePort -} diff --git a/share/config.go b/share/settings/config.go similarity index 62% rename from share/config.go rename to share/settings/config.go index 0b64920..c1f5717 100644 --- a/share/config.go +++ b/share/settings/config.go @@ -1,4 +1,4 @@ -package chshare +package settings import ( "encoding/json" @@ -7,7 +7,7 @@ import ( type Config struct { Version string - Remotes []*Remote + Remotes } func DecodeConfig(b []byte) (*Config, error) { @@ -19,6 +19,8 @@ func DecodeConfig(b []byte) (*Config, error) { return c, nil } -func EncodeConfig(c *Config) ([]byte, error) { - return json.Marshal(c) +func EncodeConfig(c Config) []byte { + //Config doesn't have types that can fail to marshal + b, _ := json.Marshal(c) + return b } diff --git a/share/settings/remote.go b/share/settings/remote.go new file mode 100644 index 0000000..c6123db --- /dev/null +++ b/share/settings/remote.go @@ -0,0 +1,244 @@ +package settings + +import ( + "errors" + "net/url" + "regexp" + "strconv" + "strings" +) + +// short-hand conversions (see remote_test) +// 3000 -> +// local 127.0.0.1:3000 +// remote 127.0.0.1:3000 +// foobar.com:3000 -> +// local 127.0.0.1:3000 +// remote foobar.com:3000 +// 3000:google.com:80 -> +// local 127.0.0.1:3000 +// remote google.com:80 +// 192.168.0.1:3000:google.com:80 -> +// local 192.168.0.1:3000 +// remote google.com:80 +// 127.0.0.1:1080:socks +// local 127.0.0.1:1080 +// remote socks +// stdio:example.com:22 +// local stdio +// remote example.com:22 +// 1.1.1.1:53/udp +// local 127.0.0.1:53/udp +// remote 1.1.1.1:53/udp + +type Remote struct { + LocalHost, LocalPort, LocalProto string + RemoteHost, RemotePort, RemoteProto string + Socks, Reverse, Stdio bool +} + +const revPrefix = "R:" + +func DecodeRemote(s string) (*Remote, error) { + reverse := false + if strings.HasPrefix(s, revPrefix) { + s = strings.TrimPrefix(s, revPrefix) + reverse = true + } + parts := strings.Split(s, ":") + if len(parts) <= 0 || len(parts) >= 5 { + return nil, errors.New("Invalid remote") + } + r := &Remote{Reverse: reverse} + //parse from back to front, to set 'remote' fields first, + //then to set 'local' fields second (allows the 'remote' side + //to provide the defaults) + for i := len(parts) - 1; i >= 0; i-- { + p := parts[i] + //remote portion is socks? + if i == len(parts)-1 && p == "socks" { + r.Socks = true + continue + } + //local portion is stdio? + if i == 0 && p == "stdio" { + r.Stdio = true + continue + } + p, proto := L4Proto(p) + if proto != "" { + if r.RemotePort == "" { + r.RemoteProto = proto + } else if r.LocalProto == "" { + r.LocalProto = proto + } + } + if isPort(p) { + if !r.Socks && r.RemotePort == "" { + r.RemotePort = p + } + r.LocalPort = p + continue + } + if !r.Socks && (r.RemotePort == "" && r.LocalPort == "") { + return nil, errors.New("Missing ports") + } + if !isHost(p) { + return nil, errors.New("Invalid host") + } + if !r.Socks && r.RemoteHost == "" { + r.RemoteHost = p + } else { + r.LocalHost = p + } + } + //remote string parsed, apply defaults... + if r.Socks { + //socks defaults + if r.LocalHost == "" { + r.LocalHost = "127.0.0.1" + } + if r.LocalPort == "" { + r.LocalPort = "1080" + } + } else { + //non-socks defaults + if r.LocalHost == "" { + r.LocalHost = "0.0.0.0" + } + if r.RemoteHost == "" { + r.RemoteHost = "127.0.0.1" + } + } + if r.RemoteProto == "" { + r.RemoteProto = "tcp" + } + if r.LocalProto == "" { + r.LocalProto = r.RemoteProto + } + if r.LocalProto != r.RemoteProto { + //TODO support cross protocol + //tcp <-> udp, is faily straight forward + //udp <-> tcp, is trickier since udp is stateless and tcp is not + return nil, errors.New("currently, local and remote protocols must match") + } + if r.Stdio && r.Reverse { + return nil, errors.New("stdio cannot be reversed") + } + return r, nil +} + +func isPort(s string) bool { + n, err := strconv.Atoi(s) + if err != nil { + return false + } + if n <= 0 || n > 65535 { + return false + } + return true +} + +func isHost(s string) bool { + _, err := url.Parse(s) + if err != nil { + return false + } + return true +} + +var l4Proto = regexp.MustCompile(`(?i)\/(tcp|udp)$`) + +//L4Proto extacts the layer-4 protocol from the given string +func L4Proto(s string) (head, proto string) { + if l4Proto.MatchString(s) { + l := len(s) + return strings.ToLower(s[:l-4]), s[l-3:] + } + return s, "" +} + +//implement Stringer +func (r Remote) String() string { + sb := strings.Builder{} + if r.Reverse { + sb.WriteString(revPrefix) + } + sb.WriteString(strings.TrimPrefix(r.Local(), "0.0.0.0:")) + sb.WriteString("=>") + sb.WriteString(strings.TrimPrefix(r.Remote(), "127.0.0.1:")) + if r.RemoteProto == "udp" { + sb.WriteString("/udp") + } + return sb.String() +} + +//Encode remote to a string +func (r Remote) Encode() string { + if r.LocalPort == "" { + r.LocalPort = r.RemotePort + } + local := r.Local() + remote := r.Remote() + if r.RemoteProto == "udp" { + remote += "/udp" + } + if r.Reverse { + return "R:" + local + ":" + remote + } + return local + ":" + remote +} + +//Local is the decodable local portion +func (r Remote) Local() string { + if r.Stdio { + return "stdio" + } + if r.LocalHost == "" { + r.LocalHost = "0.0.0.0" + } + return r.LocalHost + ":" + r.LocalPort +} + +//Remote is the decodable remote portion +func (r Remote) Remote() string { + if r.Socks { + return "socks" + } + if r.RemoteHost == "" { + r.RemoteHost = "127.0.0.1" + } + return r.RemoteHost + ":" + r.RemotePort +} + +//UserAddr is checked when checking if a +//user has access to a given remote +func (r Remote) UserAddr() string { + if r.Reverse { + return "R:" + r.LocalHost + ":" + r.LocalPort + } + return r.RemoteHost + ":" + r.RemotePort +} + +type Remotes []*Remote + +//Filter out forward reversed/non-reversed remotes +func (rs Remotes) Reversed(reverse bool) Remotes { + subset := Remotes{} + for _, r := range rs { + match := r.Reverse == reverse + if match { + subset = append(subset, r) + } + } + return subset +} + +//Encode back into strings +func (rs Remotes) Encode() []string { + s := make([]string, len(rs)) + for i, r := range rs { + s[i] = r.Encode() + } + return s +} diff --git a/share/settings/remote_test.go b/share/settings/remote_test.go new file mode 100644 index 0000000..a0c7043 --- /dev/null +++ b/share/settings/remote_test.go @@ -0,0 +1,108 @@ +package settings + +import ( + "reflect" + "testing" +) + +func TestRemoteDecode(t *testing.T) { + //test table + for i, test := range []struct { + Input string + Output Remote + Encoded string + }{ + { + "3000", + Remote{ + LocalPort: "3000", + RemoteHost: "127.0.0.1", + RemotePort: "3000", + }, + "0.0.0.0:3000:127.0.0.1:3000", + }, + { + "google.com:80", + Remote{ + LocalPort: "80", + RemoteHost: "google.com", + RemotePort: "80", + }, + "0.0.0.0:80:google.com:80", + }, + { + "R:google.com:80", + Remote{ + LocalPort: "80", + RemoteHost: "google.com", + RemotePort: "80", + Reverse: true, + }, + "R:0.0.0.0:80:google.com:80", + }, + { + "socks", + Remote{ + LocalHost: "127.0.0.1", + LocalPort: "1080", + Socks: true, + }, + "127.0.0.1:1080:socks", + }, + { + "127.0.0.1:1081:socks", + Remote{ + LocalHost: "127.0.0.1", + LocalPort: "1081", + Socks: true, + }, + "127.0.0.1:1081:socks", + }, + { + "1.1.1.1:53/udp", + Remote{ + LocalPort: "53", + LocalProto: "udp", + RemoteHost: "1.1.1.1", + RemotePort: "53", + RemoteProto: "udp", + }, + "0.0.0.0:53:1.1.1.1:53/udp", + }, + { + "localhost:5353:1.1.1.1:53/udp", + Remote{ + LocalHost: "localhost", + LocalPort: "5353", + LocalProto: "udp", + RemoteHost: "1.1.1.1", + RemotePort: "53", + RemoteProto: "udp", + }, + "localhost:5353:1.1.1.1:53/udp", + }, + } { + //expected defaults + expected := test.Output + if expected.LocalHost == "" { + expected.LocalHost = "0.0.0.0" + } + if expected.RemoteProto == "" { + expected.RemoteProto = "tcp" + } + if expected.LocalProto == "" { + expected.LocalProto = "tcp" + } + //compare + got, err := DecodeRemote(test.Input) + if err != nil { + t.Fatalf("decode #%d '%s' failed: %s", i+1, test.Input, err) + } + if !reflect.DeepEqual(got, &expected) { + t.Fatalf("decode #%d '%s' expected\n %#v\ngot\n %#v", i+1, test.Input, expected, got) + } + if e := got.Encode(); test.Encoded != e { + t.Fatalf("encode #%d '%s' expected\n %#v\ngot\n %#v", i+1, test.Input, test.Encoded, e) + } + } +} diff --git a/share/user.go b/share/settings/user.go similarity index 96% rename from share/user.go rename to share/settings/user.go index 0557ba4..40b2c4e 100644 --- a/share/user.go +++ b/share/settings/user.go @@ -1,4 +1,4 @@ -package chshare +package settings import ( "regexp" diff --git a/share/users.go b/share/settings/users.go similarity index 91% rename from share/users.go rename to share/settings/users.go index 330bb8e..cb3353d 100644 --- a/share/users.go +++ b/share/settings/users.go @@ -1,4 +1,4 @@ -package chshare +package settings import ( "encoding/json" @@ -10,6 +10,7 @@ import ( "sync" "github.com/fsnotify/fsnotify" + "github.com/jpillora/chisel/share/cio" ) type Users struct { @@ -58,13 +59,13 @@ func (u *Users) AddUser(user *User) { // UserIndex is a reloadable user source type UserIndex struct { - *Logger + *cio.Logger *Users configFile string } // NewUserIndex creates a source for users -func NewUserIndex(logger *Logger) *UserIndex { +func NewUserIndex(logger *cio.Logger) *UserIndex { return &UserIndex{ Logger: logger.Fork("users"), Users: NewUsers(), @@ -125,6 +126,7 @@ func (u *UserIndex) loadUserIndex() error { if err := json.Unmarshal(b, &raw); err != nil { return errors.New("Invalid JSON: " + err.Error()) } + users := []*User{} for auth, remotes := range raw { user := &User{} user.Name, user.Pass = ParseAuth(auth) @@ -141,9 +143,15 @@ func (u *UserIndex) loadUserIndex() error { } user.Addrs = append(user.Addrs, re) } - } - u.Users.AddUser(user) + users = append(users, user) + } + //swap + u.RWMutex.Lock() + u.inner = map[string]*User{} + u.RWMutex.Unlock() + for _, user := range users { + u.AddUser(user) } return nil } diff --git a/share/signal.go b/share/signal.go deleted file mode 100644 index afb5b96..0000000 --- a/share/signal.go +++ /dev/null @@ -1,24 +0,0 @@ -//+build !windows - -package chshare - -import ( - "os" - "os/signal" - "syscall" - "time" -) - -//SleepSignal sleeps for the given duration, -//or until a SIGHUP is received -func SleepSignal(d time.Duration) { - //during this time, also listen for SIGHUP - //(this uses 0xc to allow windows to compile) - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGHUP) - select { - case <-time.After(d): - case <-sig: - } - signal.Stop(sig) -} diff --git a/share/ssh.go b/share/ssh.go deleted file mode 100644 index 85b28b3..0000000 --- a/share/ssh.go +++ /dev/null @@ -1,72 +0,0 @@ -package chshare - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/md5" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "fmt" - "io" - "net" - "strings" - - "github.com/armon/go-socks5" - "github.com/jpillora/sizestr" - "golang.org/x/crypto/ssh" -) - -func GenerateKey(seed string) ([]byte, error) { - var r io.Reader - if seed == "" { - r = rand.Reader - } else { - r = NewDetermRand([]byte(seed)) - } - priv, err := ecdsa.GenerateKey(elliptic.P256(), r) - if err != nil { - return nil, err - } - b, err := x509.MarshalECPrivateKey(priv) - if err != nil { - return nil, fmt.Errorf("Unable to marshal ECDSA private key: %v", err) - } - return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b}), nil -} - -func FingerprintKey(k ssh.PublicKey) string { - bytes := md5.Sum(k.Marshal()) - strbytes := make([]string, len(bytes)) - for i, b := range bytes { - strbytes[i] = fmt.Sprintf("%02x", b) - } - return strings.Join(strbytes, ":") -} - -func HandleTCPStream(l *Logger, connStats *ConnStats, src io.ReadWriteCloser, remote string) { - dst, err := net.Dial("tcp", remote) - if err != nil { - l.Debugf("Remote failed (%s)", err) - src.Close() - return - } - connStats.Open() - l.Debugf("%s: Open", connStats) - s, r := Pipe(src, dst) - connStats.Close() - l.Debugf("%s: Close (sent %s received %s)", connStats, sizestr.ToString(s), sizestr.ToString(r)) -} - -func HandleSocksStream(l *Logger, server *socks5.Server, connStats *ConnStats, src io.ReadWriteCloser) { - conn := NewRWCConn(src) - connStats.Open() - l.Debugf("%s Opening", connStats) - err := server.ServeConn(conn) - connStats.Close() - if err != nil && !strings.HasSuffix(err.Error(), "EOF") { - l.Debugf("%s: Closed (error: %s)", connStats, err) - } else { - l.Debugf("%s: Closed", connStats) - } -} diff --git a/share/tunnel/tunnel.go b/share/tunnel/tunnel.go new file mode 100644 index 0000000..d5b830d --- /dev/null +++ b/share/tunnel/tunnel.go @@ -0,0 +1,162 @@ +package tunnel + +import ( + "bytes" + "context" + "errors" + "io/ioutil" + "log" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/armon/go-socks5" + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/settings" + "golang.org/x/crypto/ssh" + "golang.org/x/sync/errgroup" +) + +//Config a Tunnel +type Config struct { + *cio.Logger + Inbound bool + Outbound bool + Socks bool + KeepAlive time.Duration +} + +//Tunnel represents an SSH tunnel with proxy capabilities. +//Both chisel client and server are Tunnels. +//chisel client has a single set of remotes, whereas +//chisel server has multiple sets of remotes (one set per client). +//Each remote has a 1:1 mapping to a proxy. +//Proxies listen, send data over ssh, and the other end of the ssh connection +//communicates with the endpoint and returns the response. +type Tunnel struct { + Config + //ssh connection + activeConnMut sync.RWMutex + activeConn ssh.Conn + wgConn sync.WaitGroup + //proxies + proxyCount int + //internals + connStats cnet.ConnStats + socksServer *socks5.Server +} + +var tid = uint64(0) + +//New Tunnel from the given Config +func New(c Config) *Tunnel { + if c.Logger.Debug { + c.Logger = c.Logger.Fork("tun%d", atomic.AddUint64(&tid, 1)) + } + t := &Tunnel{Config: c} + //block getters + t.wgConn.Add(1) + //setup socks server (not listening on any port!) + extra := "" + if c.Socks { + sl := log.New(ioutil.Discard, "", 0) + if t.Logger.Debug { + sl = log.New(os.Stdout, "[socks]", log.Ldate|log.Ltime) + } + t.socksServer, _ = socks5.New(&socks5.Config{Logger: sl}) + extra += " (SOCKS enabled)" + } + t.Debugf("Created%s", extra) + return t +} + +//BindSSH provides an active SSH for use for tunnelling +func (t *Tunnel) BindSSH(ctx context.Context, c ssh.Conn, reqs <-chan *ssh.Request, chans <-chan ssh.NewChannel) error { + //link ctx to ssh-conn + go func() { + <-ctx.Done() + c.Close() + }() + //mark active + t.activeConnMut.Lock() + if t.activeConn != nil { + panic("double bind ssh") + } + t.activeConn = c + t.activeConnMut.Unlock() + //unblock getters + t.wgConn.Done() + //optional keepalive loop against this connection + if t.Config.KeepAlive > 0 { + go t.keepAliveLoop(c) + } + //block until closed + go t.handleSSHRequests(reqs) + go t.handleSSHChannels(chans) + t.Debugf("Bound SSH") + err := c.Wait() + t.Debugf("Unbound SSH") + //reblock getters + t.wgConn.Add(1) + //mark inactive + t.activeConnMut.Lock() + t.activeConn = nil + t.activeConnMut.Unlock() + return err +} + +func (t *Tunnel) getSSH() ssh.Conn { + t.wgConn.Wait() //TODO timeout? + t.activeConnMut.RLock() + c := t.activeConn + t.activeConnMut.RUnlock() + return c +} + +//BindRemotes converts the given remotes into proxies, and blocks +//until the caller cancels the context or there is a proxy error. +func (t *Tunnel) BindRemotes(ctx context.Context, remotes []*settings.Remote) error { + if len(remotes) == 0 { + return nil + } + if !t.Inbound { + return errors.New("inbound connections blocked") + } + proxies := make([]*Proxy, len(remotes)) + for i, remote := range remotes { + p, err := NewProxy(t.Logger, t.getSSH, t.proxyCount, remote) + if err != nil { + return err + } + proxies[i] = p + t.proxyCount++ + } + //TODO: handle tunnel close + eg, ctx := errgroup.WithContext(ctx) + for _, proxy := range proxies { + p := proxy + eg.Go(func() error { + return p.Run(ctx) + }) + } + t.Debugf("Bound proxies") + err := eg.Wait() + t.Debugf("Unbound proxies") + return err +} + +func (t *Tunnel) keepAliveLoop(sshConn ssh.Conn) { + for { + time.Sleep(t.Config.KeepAlive) + _, b, err := sshConn.SendRequest("ping", true, nil) + if err != nil { + break + } + if len(b) > 0 && !bytes.Equal(b, []byte("pong")) { + t.Debugf("strange ping response") + break + } + } +} diff --git a/share/tunnel/tunnel_in_proxy.go b/share/tunnel/tunnel_in_proxy.go new file mode 100644 index 0000000..ebf6a19 --- /dev/null +++ b/share/tunnel/tunnel_in_proxy.go @@ -0,0 +1,142 @@ +package tunnel + +import ( + "context" + "io" + "net" + + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/settings" + "github.com/jpillora/sizestr" + "golang.org/x/crypto/ssh" +) + +//GetSSHConn blocks then returns once +//an active SSH connection is ready +type GetSSHConn func() ssh.Conn + +//Proxy is the inbound portion of a Tunnel +type Proxy struct { + *cio.Logger + ssh GetSSHConn + id int + count int + remote *settings.Remote + dialer net.Dialer + tcp *net.TCPListener + udp *udpListener +} + +//NewProxy creates a Proxy +func NewProxy(logger *cio.Logger, ssh GetSSHConn, index int, remote *settings.Remote) (*Proxy, error) { + id := index + 1 + p := &Proxy{ + Logger: logger.Fork("proxy#%s", remote.String()), + ssh: ssh, + id: id, + remote: remote, + } + return p, p.listen() +} + +func (p *Proxy) listen() error { + if p.remote.Stdio { + //TODO check if pipes active? + } else if p.remote.LocalProto == "tcp" { + addr, err := net.ResolveTCPAddr("tcp", p.remote.LocalHost+":"+p.remote.LocalPort) + if err != nil { + return p.Errorf("resolve: %s", err) + } + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return p.Errorf("tcp: %s", err) + } + p.Debugf("Listening") + p.tcp = l + } else if p.remote.LocalProto == "udp" { + l, err := listenUDP(p.Logger, p.ssh, p.remote) + if err != nil { + return err + } + p.Debugf("Listening") + p.udp = l + } else { + return p.Errorf("unknown local proto") + } + return nil +} + +//Run enables the proxy and blocks while its active, +//close the proxy by cancelling the context. +func (p *Proxy) Run(ctx context.Context) error { + defer p.Debugf("Closed") + if p.remote.Stdio { + return p.runStdio(ctx) + } else if p.remote.LocalProto == "tcp" { + return p.runTCP(ctx) + } else if p.remote.LocalProto == "udp" { + return p.udp.run(ctx) + } + panic("should not get here") +} + +func (p *Proxy) runStdio(ctx context.Context) error { + for { + p.pipeRemote(cio.Stdio) + select { + case <-ctx.Done(): + return nil + default: + // the connection is not ready yet, keep waiting + } + } +} + +func (p *Proxy) runTCP(ctx context.Context) error { + done := make(chan struct{}) + //implements missing net.ListenContext + go func() { + select { + case <-ctx.Done(): + p.tcp.Close() + case <-done: + } + }() + for { + src, err := p.tcp.Accept() + if err != nil { + select { + case <-ctx.Done(): + //listener closed + default: + p.Infof("Accept error: %s", err) + } + close(done) + return err + } + go p.pipeRemote(src) + } +} + +func (p *Proxy) pipeRemote(src io.ReadWriteCloser) { + defer src.Close() + p.count++ + cid := p.count + l := p.Fork("conn#%d", cid) + l.Debugf("Open") + sshConn := p.ssh() + if sshConn == nil { + l.Debugf("No remote connection") + return + } + //ssh request for tcp connection for this proxy's remote + dst, reqs, err := sshConn.OpenChannel("chisel", []byte(p.remote.Remote())) + if err != nil { + l.Infof("Stream error: %s", err) + return + } + go ssh.DiscardRequests(reqs) + //then pipe + s, r := cio.Pipe(src, dst) + l.Debugf("Close (sent %s received %s)", sizestr.ToString(s), sizestr.ToString(r)) +} diff --git a/share/tunnel/tunnel_in_proxy_udp.go b/share/tunnel/tunnel_in_proxy_udp.go new file mode 100644 index 0000000..2ffffb6 --- /dev/null +++ b/share/tunnel/tunnel_in_proxy_udp.go @@ -0,0 +1,157 @@ +package tunnel + +import ( + "context" + "encoding/gob" + "net" + "sync" + "sync/atomic" + + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/settings" + "golang.org/x/crypto/ssh" + "golang.org/x/sync/errgroup" +) + +//listenUDP is a special listener which forwards packets via +//the bound ssh connection. tricky part is multiplexing lots of +//udp clients through the entry node. each will listen on its +//own source-port for a response: +// (random) +// src-1 1111->... dst-1 6345->7777 +// src-2 2222->... <---> udp <---> udp <-> dst-1 7543->7777 +// src-3 3333->... listener handler dst-1 1444->7777 +// +//we must store these mappings (1111-6345, etc) in memory for a length +//of time, so that when the exit node receives a response on 6345, it +//knows to return it to 1111. +func listenUDP(l *cio.Logger, ssh GetSSHConn, remote *settings.Remote) (*udpListener, error) { + l = l.Fork("udp") + a, err := net.ResolveUDPAddr("udp", remote.Local()) + if err != nil { + return nil, l.Errorf("resolve: %s", err) + } + conn, err := net.ListenUDP("udp", a) + if err != nil { + return nil, l.Errorf("listen: %s", err) + } + //ready + u := &udpListener{ + Logger: l, + ssh: ssh, + remote: remote, + inbound: conn, + } + return u, nil +} + +type udpListener struct { + *cio.Logger + ssh GetSSHConn + remote *settings.Remote + inbound *net.UDPConn + outboundMut sync.Mutex + outbound *udpOutbound + sent, recv int64 +} + +func (u *udpListener) run(ctx context.Context) error { + //udp doesnt accept connections, + //udp simply forwards all packets + //and therefore only needs to listen + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + return u.runInbound(ctx) + }) + eg.Go(func() error { + return u.runOutbound(ctx) + }) + if err := eg.Wait(); err != nil { + u.Debugf("listen: %s", err) + return err + } + u.Debugf("sent %d, received %d", u.sent, u.recv) + return nil +} + +func (u *udpListener) runInbound(ctx context.Context) error { + dstAddr := u.remote.Remote() + const maxMTU = 9012 + buff := make([]byte, maxMTU) + for !isDone(ctx) { + //read from inbound udp + n, addr, err := u.inbound.ReadFromUDP(buff) + if err != nil { + return u.Errorf("read error: %w", err) + } + //upsert ssh channel + o, err := u.getOubound() + if err != nil { + return u.Errorf("ssh-chan error: %w", err) + } + //send over channel, including source address + b := buff[:n] + if err := o.encode(addr.String(), dstAddr, b); err != nil { + return u.Errorf("encode error: %w", err) + } + //stats + atomic.AddInt64(&u.sent, int64(n)) + } + return nil +} + +func (u *udpListener) runOutbound(ctx context.Context) error { + for !isDone(ctx) { + //upsert ssh channel + o, err := u.getOubound() + if err != nil { + return u.Errorf("ssh-chan error: %w", err) + } + //receive from channel, including source address + p := udpPacket{} + if err := o.decode(&p); err != nil { + return u.Errorf("decode error: %w", err) + } + //write back to inbound udp + addr, err := net.ResolveUDPAddr("udp", p.Src) + if err != nil { + return u.Errorf("resolve error: %w", err) + } + n, err := u.inbound.WriteToUDP(p.Payload, addr) + if err != nil { + return u.Errorf("write error: %w", err) + } + //stats + atomic.AddInt64(&u.recv, int64(n)) + } + return nil +} + +func (u *udpListener) getOubound() (*udpOutbound, error) { + u.outboundMut.Lock() + defer u.outboundMut.Unlock() + //cached + if u.outbound != nil { + return u.outbound, nil + } + //not cached, bind + sshConn := u.ssh() + if sshConn == nil { + return nil, u.Errorf("ssh-conn nil") + } + //ssh request for udp packets for this proxy's remote, + //just "udp" since the remote address is sent with each packet + rwc, reqs, err := sshConn.OpenChannel("chisel", []byte("udp")) + if err != nil { + return nil, u.Errorf("ssh-chan error: %s", err) + } + go ssh.DiscardRequests(reqs) + //ready + o := &udpOutbound{ + r: gob.NewDecoder(rwc), + w: gob.NewEncoder(rwc), + c: rwc, + } + u.outbound = o + return o, nil +} diff --git a/share/tunnel/tunnel_out_ssh.go b/share/tunnel/tunnel_out_ssh.go new file mode 100644 index 0000000..b8bfc41 --- /dev/null +++ b/share/tunnel/tunnel_out_ssh.go @@ -0,0 +1,84 @@ +package tunnel + +import ( + "fmt" + "io" + "net" + "strings" + + "github.com/jpillora/chisel/share/cio" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/sizestr" + "golang.org/x/crypto/ssh" +) + +func (t *Tunnel) handleSSHRequests(reqs <-chan *ssh.Request) { + for r := range reqs { + switch r.Type { + case "ping": + r.Reply(true, []byte("pong")) + default: + t.Debugf("Unknown request: %s", r.Type) + } + } +} + +func (t *Tunnel) handleSSHChannels(chans <-chan ssh.NewChannel) { + for ch := range chans { + go t.handleSSHChannel(ch) + } +} + +func (t *Tunnel) handleSSHChannel(ch ssh.NewChannel) { + if !t.Config.Outbound { + t.Debugf("Denied outbound connection") + ch.Reject(ssh.Prohibited, "Denied outbound connection") + return + } + remote := string(ch.ExtraData()) + udp := remote == "udp" + socks := remote == "socks" + if socks && t.socksServer == nil { + t.Debugf("Denied socks request, please enable socks") + ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled") + return + } + stream, reqs, err := ch.Accept() + if err != nil { + t.Debugf("Failed to accept stream: %s", err) + return + } + defer stream.Close() + go ssh.DiscardRequests(reqs) + l := t.Logger.Fork("conn#%d", t.connStats.New()) + //ready to handle + t.connStats.Open() + l.Debugf("Open %s", t.connStats.String()) + if socks { + err = t.handleSocks(stream) + } else if udp { + err = t.handleUDP(l, stream) + } else { + err = t.handleTCP(l, stream, remote) + } + t.connStats.Close() + errmsg := "" + if err != nil && !strings.HasSuffix(err.Error(), "EOF") { + errmsg = fmt.Sprintf(" (error %s)", err) + } + l.Debugf("Close %s%s", t.connStats.String(), errmsg) +} + +func (t *Tunnel) handleSocks(src io.ReadWriteCloser) error { + return t.socksServer.ServeConn(cnet.NewRWCConn(src)) +} + +func (t *Tunnel) handleTCP(l *cio.Logger, src io.ReadWriteCloser, remote string) error { + dst, err := net.Dial("tcp", remote) + if err != nil { + return err + } + s, r := cio.Pipe(src, dst) + l.Debugf("sent %s received %s", sizestr.ToString(s), sizestr.ToString(r)) + return nil +} diff --git a/share/tunnel/tunnel_out_ssh_udp.go b/share/tunnel/tunnel_out_ssh_udp.go new file mode 100644 index 0000000..52a880d --- /dev/null +++ b/share/tunnel/tunnel_out_ssh_udp.go @@ -0,0 +1,145 @@ +package tunnel + +import ( + "encoding/gob" + "io" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/jpillora/chisel/share/cio" +) + +func (t *Tunnel) handleUDP(l *cio.Logger, rwc io.ReadWriteCloser) error { + h := &udpHandler{ + Logger: l, + udpOutbound: &udpOutbound{ + r: gob.NewDecoder(rwc), + w: gob.NewEncoder(rwc), + c: rwc, + }, + udpConns: &udpConns{ + m: map[string]*udpConn{}, + }, + } + for { + p := udpPacket{} + if err := h.handleWrite(&p); err != nil { + return err + } + } +} + +type udpHandler struct { + *cio.Logger + *udpOutbound + *udpConns + sent, recv int64 +} + +func (h *udpHandler) handleWrite(p *udpPacket) error { + if err := h.r.Decode(&p); err != nil { + return err + } + //dial now, we know we must write + conn, exists, err := h.udpConns.dial(p.Src, p.Dst) + if err != nil { + return err + } + //however, we dont know if we must read... + //spawn up to go-routines to wait + //for a reply. + //TODO configurable + //TODO++ dont use go-routines, switch to pollable + // array of listeners where all listeners are + // sweeped periodically, removing the idle ones + const maxConns = 100 + if !exists { + if h.udpConns.len() <= maxConns { + go h.handleRead(p, conn) + } else { + h.Debugf("exceeded max udp connections (%d)", maxConns) + } + } + n, err := conn.Write(p.Payload) + if err != nil { + return err + } + //stats + atomic.AddInt64(&h.sent, int64(n)) + return nil +} + +func (h *udpHandler) handleRead(p *udpPacket, conn *udpConn) { + //ensure connection is cleaned up + defer h.udpConns.remove(conn.id) + const maxMTU = 9012 + buff := make([]byte, maxMTU) + for { + //response must arrive within 15 seconds + //TODO configurable + const deadline = 15 * time.Second + conn.SetReadDeadline(time.Now().Add(deadline)) + //read response + n, err := conn.Read(buff) + if err != nil { + if !os.IsTimeout(err) && err != io.EOF { + h.Debugf("read error: %s", err) + } + break + } + b := buff[:n] + //encode back over ssh connection + err = h.udpOutbound.encode(p.Src, p.Dst, b) + if err != nil { + h.Debugf("encode error: %s", err) + return + } + //stats + atomic.AddInt64(&h.recv, int64(n)) + } +} + +type udpConns struct { + sync.Mutex + m map[string]*udpConn +} + +func (cs *udpConns) dial(id, addr string) (*udpConn, bool, error) { + cs.Lock() + defer cs.Unlock() + conn, ok := cs.m[id] + if !ok { + c, err := net.Dial("udp", addr) + if err != nil { + return nil, false, err + } + conn = &udpConn{ + id: id, + Conn: c, + } + conn.Conn = c + cs.m[id] = conn + } + return conn, ok, nil +} + +func (cs *udpConns) len() int { + cs.Lock() + l := len(cs.m) + cs.Unlock() + return l +} + +func (cs *udpConns) remove(id string) { + cs.Lock() + delete(cs.m, id) + cs.Unlock() +} + +type udpConn struct { + id string + net.Conn +} diff --git a/share/tunnel/udp.go b/share/tunnel/udp.go new file mode 100644 index 0000000..43460b0 --- /dev/null +++ b/share/tunnel/udp.go @@ -0,0 +1,44 @@ +package tunnel + +import ( + "context" + "encoding/gob" + "io" +) + +type udpPacket struct { + Src string + Dst string + Payload []byte +} + +func init() { + gob.Register(&udpPacket{}) +} + +type udpOutbound struct { + r *gob.Decoder + w *gob.Encoder + c io.Closer +} + +func (o *udpOutbound) encode(src, dst string, b []byte) error { + return o.w.Encode(udpPacket{ + Src: src, + Dst: dst, + Payload: b, + }) +} + +func (o *udpOutbound) decode(p *udpPacket) error { + return o.r.Decode(p) +} + +func isDone(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} diff --git a/bench/main.go b/test/bench/main.go similarity index 96% rename from bench/main.go rename to test/bench/main.go index 30a867a..02ddc32 100644 --- a/bench/main.go +++ b/test/bench/main.go @@ -25,7 +25,7 @@ import ( "path" "strconv" - "github.com/jpillora/chisel/share" + "github.com/jpillora/chisel/share/cnet" "time" ) @@ -105,7 +105,7 @@ func requestFile(port string, size int) (*http.Response, error) { return http.Get(url) } -func makeFileServer() *chshare.HTTPServer { +func makeFileServer() *cnet.HTTPServer { bsize := 3 * MB bytes := make([]byte, bsize) //filling huge buffer @@ -113,8 +113,8 @@ func makeFileServer() *chshare.HTTPServer { bytes[i] = byte(i) } - s := chshare.NewHTTPServer() - s.SetKeepAlivesEnabled(false) + s := cnet.NewHTTPServer() + s.Server.SetKeepAlivesEnabled(false) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rsize, _ := strconv.Atoi(r.URL.Path[1:]) for rsize >= bsize { diff --git a/test/bench/perf.md b/test/bench/perf.md new file mode 100644 index 0000000..2ed16fb --- /dev/null +++ b/test/bench/perf.md @@ -0,0 +1,66 @@ + +### Performance + +With [crowbar](https://github.com/q3k/crowbar), a connection is tunneled by repeatedly querying the server with updates. This results in a large amount of HTTP and TCP connection overhead. Chisel overcomes this using WebSockets combined with [crypto/ssh](https://golang.org/x/crypto/ssh) to create hundreds of logical connections, resulting in **one** TCP connection per client. + +In this simple benchmark, we have: + +``` + (direct) + .--------------->----------------. + / chisel chisel \ +request--->client:2001--->server:2002---->fileserver:3000 + \ / + '--> crowbar:4001--->crowbar:4002' + client server +``` + +Note, we're using an in-memory "file" server on localhost for these tests + +_direct_ + +``` +:3000 => 1 bytes in 1.291417ms +:3000 => 10 bytes in 713.525µs +:3000 => 100 bytes in 562.48µs +:3000 => 1000 bytes in 595.445µs +:3000 => 10000 bytes in 1.053298ms +:3000 => 100000 bytes in 741.351µs +:3000 => 1000000 bytes in 1.367143ms +:3000 => 10000000 bytes in 8.601549ms +:3000 => 100000000 bytes in 76.3939ms +``` + +`chisel` + +``` +:2001 => 1 bytes in 1.351976ms +:2001 => 10 bytes in 1.106086ms +:2001 => 100 bytes in 1.005729ms +:2001 => 1000 bytes in 1.254396ms +:2001 => 10000 bytes in 1.139777ms +:2001 => 100000 bytes in 2.35437ms +:2001 => 1000000 bytes in 11.502673ms +:2001 => 10000000 bytes in 123.130246ms +:2001 => 100000000 bytes in 966.48636ms +``` + +~100MB in **~1 second** + +`crowbar` + +``` +:4001 => 1 bytes in 3.335797ms +:4001 => 10 bytes in 1.453007ms +:4001 => 100 bytes in 1.811727ms +:4001 => 1000 bytes in 1.621525ms +:4001 => 10000 bytes in 5.20729ms +:4001 => 100000 bytes in 38.461926ms +:4001 => 1000000 bytes in 358.784864ms +:4001 => 10000000 bytes in 3.603206487s +:4001 => 100000000 bytes in 36.332395213s +``` + +~100MB in **36 seconds** + +See `test/bench/main.go` \ No newline at end of file diff --git a/bench/userfile b/test/bench/userfile similarity index 100% rename from bench/userfile rename to test/bench/userfile diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go new file mode 100644 index 0000000..cd758c5 --- /dev/null +++ b/test/e2e/auth_test.go @@ -0,0 +1,48 @@ +package e2e_test + +import ( + "testing" + + chclient "github.com/jpillora/chisel/client" + chserver "github.com/jpillora/chisel/server" +) + +//TODO tests for: +// - failed auth +// - dynamic auth (server add/remove user) +// - watch auth file + +func TestAuth(t *testing.T) { + tmpPort1 := availablePort() + tmpPort2 := availablePort() + //setup server, client, fileserver + teardown := simpleSetup(t, + &chserver.Config{ + KeySeed: "foobar", + Auth: "../bench/userfile", + }, + &chclient.Config{ + Remotes: []string{ + "0.0.0.0:" + tmpPort1 + ":127.0.0.1:$FILEPORT", + "0.0.0.0:" + tmpPort2 + ":localhost:$FILEPORT", + }, + Auth: "foo:bar", + }) + defer teardown() + //test first remote + result, err := post("http://localhost:"+tmpPort1, "foo") + if err != nil { + t.Fatal(err) + } + if result != "foo!" { + t.Fatalf("expected exclamation mark added") + } + //test second remote + result, err = post("http://localhost:"+tmpPort2, "bar") + if err != nil { + t.Fatal(err) + } + if result != "bar!" { + t.Fatalf("expected exclamation mark added again") + } +} diff --git a/test/e2e/base_test.go b/test/e2e/base_test.go new file mode 100644 index 0000000..bb344cb --- /dev/null +++ b/test/e2e/base_test.go @@ -0,0 +1,48 @@ +package e2e_test + +import ( + "testing" + + chclient "github.com/jpillora/chisel/client" + chserver "github.com/jpillora/chisel/server" +) + +func TestBase(t *testing.T) { + tmpPort := availablePort() + //setup server, client, fileserver + teardown := simpleSetup(t, + &chserver.Config{}, + &chclient.Config{ + Remotes: []string{tmpPort + ":$FILEPORT"}, + }) + defer teardown() + //test remote + result, err := post("http://localhost:"+tmpPort, "foo") + if err != nil { + t.Fatal(err) + } + if result != "foo!" { + t.Fatalf("expected exclamation mark added") + } +} + +func TestReverse(t *testing.T) { + tmpPort := availablePort() + //setup server, client, fileserver + teardown := simpleSetup(t, + &chserver.Config{ + Reverse: true, + }, + &chclient.Config{ + Remotes: []string{"R:" + tmpPort + ":$FILEPORT"}, + }) + defer teardown() + //test remote (this goes through the server and out the client) + result, err := post("http://localhost:"+tmpPort, "foo") + if err != nil { + t.Fatal(err) + } + if result != "foo!" { + t.Fatalf("expected exclamation mark added") + } +} diff --git a/test/e2e/proxy_test.go b/test/e2e/proxy_test.go new file mode 100644 index 0000000..266cc45 --- /dev/null +++ b/test/e2e/proxy_test.go @@ -0,0 +1,5 @@ +package e2e_test + +//TODO tests for: +// client -> CONNECT proxy -> server -> endpoint +// client -> SOCKS proxy -> server -> endpoint diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go new file mode 100644 index 0000000..af2077e --- /dev/null +++ b/test/e2e/setup_test.go @@ -0,0 +1,132 @@ +package e2e_test + +import ( + "context" + "io/ioutil" + "log" + "net" + "net/http" + "strings" + "testing" + "time" + + chclient "github.com/jpillora/chisel/client" + chserver "github.com/jpillora/chisel/server" +) + +const debug = true + +//test layout configuration +type testLayout struct { + server *chserver.Config + client *chclient.Config + fileServer bool + udpEcho bool + udpServer bool +} + +func (tl *testLayout) setup(t *testing.T) (server *chserver.Server, client *chclient.Client, teardown context.CancelFunc) { + ctx, teardown := context.WithCancel(context.Background()) + //fileserver (fake endpoint) + filePort := availablePort() + if tl.fileServer { + fileAddr := "127.0.0.1:" + filePort + f := http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := ioutil.ReadAll(r.Body) + w.Write(append(b, '!')) + }), + } + fl, err := net.Listen("tcp", fileAddr) + if err != nil { + t.Fatal(err) + } + log.Printf("fileserver: listening on %s", fileAddr) + go func() { + f.Serve(fl) + teardown() + }() + go func() { + <-ctx.Done() + f.Close() + }() + } + //server + server, err := chserver.NewServer(tl.server) + if err != nil { + t.Fatal(err) + } + server.Debug = debug + port := availablePort() + if err := server.StartContext(ctx, "127.0.0.1", port); err != nil { + t.Fatal(err) + } + go func() { + server.Wait() + server.Infof("Closed") + teardown() + }() + //client (with defaults) + tl.client.Fingerprint = server.GetFingerprint() + tl.client.Server = "http://127.0.0.1:" + port + for i, r := range tl.client.Remotes { + //convert $FILEPORT into the allocated port for this test case + if tl.fileServer { + tl.client.Remotes[i] = strings.Replace(r, "$FILEPORT", filePort, 1) + } + } + client, err = chclient.NewClient(tl.client) + if err != nil { + t.Fatal(err) + } + client.Debug = debug + if err := client.Start(ctx); err != nil { + t.Fatal(err) + } + go func() { + client.Wait() + client.Infof("Closed") + teardown() + }() + //wait a bit... + //TODO: client signal API, similar to os.Notify(signal) + // wait for client setup + time.Sleep(50 * time.Millisecond) + //ready + return server, client, teardown +} + +func simpleSetup(t *testing.T, s *chserver.Config, c *chclient.Config) context.CancelFunc { + conf := testLayout{ + server: s, + client: c, + fileServer: true, + } + _, _, teardown := conf.setup(t) + return teardown +} + +func post(url, body string) (string, error) { + resp, err := http.Post(url, "text/plain", strings.NewReader(body)) + if err != nil { + return "", err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +func availablePort() string { + l, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + log.Panic(err) + } + l.Close() + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + log.Panic(err) + } + return port +} diff --git a/test/e2e/socks_test.go b/test/e2e/socks_test.go new file mode 100644 index 0000000..420f433 --- /dev/null +++ b/test/e2e/socks_test.go @@ -0,0 +1,5 @@ +package e2e_test + +//TODO tests for: +// - SOCKS-client -> [client -> server SOCKS] -> endpoint +// - SOCKS-client -> [server -> client SOCKS] -> endpoint diff --git a/test/e2e/udp_test.go b/test/e2e/udp_test.go new file mode 100644 index 0000000..4ac9ec1 --- /dev/null +++ b/test/e2e/udp_test.go @@ -0,0 +1,88 @@ +package e2e_test + +import ( + "log" + "net" + "testing" + "time" + + chclient "github.com/jpillora/chisel/client" + chserver "github.com/jpillora/chisel/server" + "golang.org/x/sync/errgroup" +) + +func TestUDP(t *testing.T) { + //listen on random udp port + echoPort := availableUDPPort() + a, _ := net.ResolveUDPAddr("udp", ":"+echoPort) + l, err := net.ListenUDP("udp", a) + if err != nil { + t.Fatal(err) + } + //chisel client+server + inboundPort := availableUDPPort() + teardown := simpleSetup(t, + &chserver.Config{}, + &chclient.Config{ + Remotes: []string{ + inboundPort + ":" + echoPort + "/udp", + }, + }, + ) + defer teardown() + //fake udp server, read and echo back twice, close + eg := errgroup.Group{} + eg.Go(func() error { + defer l.Close() + b := make([]byte, 128) + n, a, err := l.ReadFrom(b) + if err != nil { + return err + } + if _, err = l.WriteTo(append(b[:n], b[:n]...), a); err != nil { + return err + } + return nil + }) + //fake udp client + conn, err := net.Dial("udp4", "localhost:"+inboundPort) + if err != nil { + t.Fatal(err) + } + //write bazz through the tunnel + if _, err := conn.Write([]byte("bazz")); err != nil { + t.Fatal(err) + } + //receive bazzbazz back + b := make([]byte, 128) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(b) + if err != nil { + t.Fatal(err) + return + } + //udp server should close correctly + if err := eg.Wait(); err != nil { + t.Fatal(err) + return + } + //ensure expected + s := string(b[:n]) + if s != "bazzbazz" { + t.Fatalf("expected double bazz") + } +} + +func availableUDPPort() string { + a, _ := net.ResolveUDPAddr("udp", ":0") + l, err := net.ListenUDP("udp", a) + if err != nil { + log.Panicf("availability listen: %s", err) + } + l.Close() + _, port, err := net.SplitHostPort(l.LocalAddr().String()) + if err != nil { + log.Panic(err) + } + return port +}