diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51811b1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: ci +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: fmt + uses: ./ci/image + with: + args: ./ci/fmt.sh + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: lint + uses: ./ci/image + with: + args: ./ci/lint.sh + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: test + uses: ./ci/image + with: + args: go test ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57f1cb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/ \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..6ee4976 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +The MIT License + +Copyright (c) 2020 Coder Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 5c4cea5..709ced0 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,74 @@ # wsep -`wsep` is a high performance, WebSocket-based command execution protocol. It can be thought of as SSH without -encryption. +`wsep` is a high performance command execution protocol over WebSocket. It can be thought of as SSH over WebSockets without encryption. -It's useful in cases where you want to provide a command exec interface into a remote environment. It's implemented -with WebSocket so it may be used directly by a browser frontend. +The package offers the `wsep.Execer` interface so that local, SSH, and WebSocket execution can be interchanged. This is particular useful when testing. -### Performance Goals +## Examples -Test command +Error handling is omitted for brevity. -```shell script -head -c 100000000 /dev/urandom > /tmp/random; cat /tmp/random | pv | time coder sh ammar sh -c "cat > /dev/null" -``` - -streams at - -```shell script -95.4MiB 0:00:08 [10.7MiB/s] [ <=> ] - 15.34 real 2.16 user 1.54 sys -``` - -The same command over wush stdout (which is high performance) streams at 17MB/s. This leads me to believe -there's a large gap we can close. +### Client -## Protocol +```golang +conn, _, _ := websocket.Dial(ctx, "ws://remote.exec.addr", nil) +defer conn.Close(websocket.StatusNormalClosure, "normal closure") -Each Message is represented as two WebSocket messages. The first one is a Text message that contains a -JSON header, the next message is the binary body. The advantage of this striping mechanism is +execer := wsep.RemoteExecer(conn) +process, _ := execer.Start(ctx, wsep.Command{ + Command: "cat", + Args: []string{"go.mod"}, + Stdin: false, +}) -- ease of debugging in the Chrome WebSocket message viewer -- human readable headers +go io.Copy(os.Stderr, process.Stderr()) +go io.Copy(os.Stdout, process.Stdout()) -Some messages may omit the body. - -The overhead of the additional frame is 2 to 6 bytes. In high throughput cases, messages contain ~32KB of data, -so this overhead is negligible. - -### Client Messages - -#### Start - -This must be the first Client message. - -```json -{ - "type": "start", - "command": "cat", - "args": ["/dev/urandom"], - "tty": false -} +process.Wait() ``` -#### Stdin +### Server -```json -{ "type": "stdin" } -``` +```golang +func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, _ := websocket.Accept(w, r, nil) + defer conn.Close(websocket.StatusNormalClosure, "normal closure") -and a body follows. - -#### Resize - -```json -{"type": "resize", "cols": 80, "rows": 80} + wsep.Serve(r.Context(), conn, wsep.LocalExecer{}) +} ``` -Only valid on tty messages. - -#### CloseStdin +### Development / Testing -No more Stdin messages may be sent after this. +Start a local executor: -```json -{ "type": "close_stdin" } +```sh +go run ./dev/server ``` -### Server Messages +Start a client: -#### Pid - -This is sent immediately after the command starts. - -```json -{"type": "pid", "pid": 0} +```sh +go run ./dev/client tty --id 1 -- bash +go run ./dev/client notty -- ls -la ``` -#### Stdout +### Benchmarks -```json -{ "type": "stdout" } -``` +Local `sh` through a local `wsep` connection -and a body follows. - -#### Stderr +```shell script +$ head -c 100000000 /dev/urandom > /tmp/random; cat /tmp/random | pv | time ./bin/client notty -- sh -c "cat > /dev/null" -```json -{ "type": "stderr" } +95.4MiB 0:00:00 [ 269MiB/s] [ <=> ] +./bin/client notty -- sh -c "cat > /dev/null" 0.32s user 0.31s system 31% cpu 2.019 total ``` -and a body follows. - -#### ExitCode +Local `sh` directly -This is the last message sent by the server. +```shell script +$ head -c 100000000 /dev/urandom > /tmp/random; cat /tmp/random | pv | time sh -c "cat > /dev/null" -```json -{ "type": "exit_code", "code": 255 } +95.4MiB 0:00:00 [1.73GiB/s] [ <=> ] +sh -c "cat > /dev/null" 0.00s user 0.02s system 32% cpu 0.057 total ``` - -A normal closure follows. - diff --git a/browser/client.ts b/browser/client.ts new file mode 100644 index 0000000..0a5322e --- /dev/null +++ b/browser/client.ts @@ -0,0 +1,105 @@ +// "wsep" browser client reference implementation + +const DELIMITER = '\n'.charCodeAt(0); + +// Command describes initialization parameters for a remote command +export interface Command { + command: string; + args?: string[]; + tty?: boolean; + uid?: number; + gid?: number; + env?: string[]; + working_dir?: string; +} + +export type ClientHeader = + | { type: 'start'; id: string; command: Command; cols: number; rows: number; } + | { type: 'stdin' } + | { type: 'close_stdin' } + | { type: 'resize'; cols: number; rows: number }; + +export type ServerHeader = + | { type: 'stdout' } + | { type: 'stderr' } + | { type: 'pid'; pid: number } + | { type: 'exit_code'; exit_code: number }; + +export type Header = ClientHeader | ServerHeader; + +export const setBinaryType = (ws: WebSocket) => { + ws.binaryType = 'arraybuffer'; +}; + +export const sendStdin = (ws: WebSocket, data: Uint8Array) => { + if (data.byteLength < 1) return; + const msg = joinMessage({ type: 'stdin' }, data); + ws.send(msg.buffer); +}; + +export const closeStdin = (ws: WebSocket) => { + const msg = joinMessage({ type: 'close_stdin' }); + ws.send(msg.buffer); +}; + +export const startCommand = ( + ws: WebSocket, + command: Command, + id: string, + rows: number, + cols: number +) => { + const msg = joinMessage({ type: 'start', command, id, rows, cols }); + ws.send(msg.buffer); +}; + +export const parseServerMessage = ( + ev: MessageEvent +): [ServerHeader, Uint8Array] => { + const [header, body] = splitMessage(ev.data); + return [header as ServerHeader, body]; +}; + +export const resizeTerminal = ( + ws: WebSocket, + rows: number, + cols: number +): void => { + const msg = joinMessage({ type: 'resize', cols, rows }); + ws.send(msg.buffer); +}; + +const joinMessage = (header: ClientHeader, body?: Uint8Array): Uint8Array => { + const encodedHeader = new TextEncoder().encode(JSON.stringify(header)); + if (body && body.length > 0) { + const tmp = new Uint8Array(encodedHeader.byteLength + 1 + body.byteLength); + tmp.set(encodedHeader, 0); + tmp.set([DELIMITER], encodedHeader.byteLength); + tmp.set(body, encodedHeader.byteLength + 1); + return tmp; + } + return encodedHeader; +}; + +const splitMessage = (message: ArrayBuffer): [Header, Uint8Array] => { + let array: Uint8Array; + if (typeof message === 'string') { + array = new TextEncoder().encode(message); + } else { + array = new Uint8Array(message); + } + + for (let i = 0; i < array.length; i++) { + if (array[i] === DELIMITER) { + const headerText = new TextDecoder().decode(array.slice(0, i)); + const header: ServerHeader = JSON.parse(headerText); + const body = + array.length > i + 1 + ? array.slice(i + 1, array.length) + : new Uint8Array(0); + return [header, body]; + } + } + + return [JSON.parse(new TextDecoder().decode(array)), new Uint8Array(0)]; +}; diff --git a/ci/alt.sh b/ci/alt.sh new file mode 100755 index 0000000..b92d60e --- /dev/null +++ b/ci/alt.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Script for testing the alt screen. + +# Enter alt screen. +tput smcup + +function display() { + # Clear the screen. + tput clear + # Move cursor to the top left. + tput cup 0 0 + # Display content. + echo "ALT SCREEN" +} + +function redraw() { + display + echo "redrawn" +} + +# Re-display on resize. +trap 'redraw' WINCH + +display + +# The trap will not run while waiting for a command so read input in a loop with +# a timeout. +while true ; do + if read -n 1 -t .1 ; then + # Clear the screen. + tput clear + # Exit alt screen. + tput rmcup + exit + fi +done diff --git a/ci/fmt.sh b/ci/fmt.sh new file mode 100755 index 0000000..a9522dd --- /dev/null +++ b/ci/fmt.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +echo "Formatting..." + +go mod tidy +gofmt -w -s . +goimports -w "-local=$$(go list -m)" . + +if [ "$CI" != "" ]; then + if [[ $(git ls-files --other --modified --exclude-standard) != "" ]]; then + echo "Files need generation or are formatted incorrectly:" + git -c color.ui=always status | grep --color=no '\e\[31m' + echo "Please run the following locally:" + echo " ./ci/fmt.sh" + exit 1 + fi +fi diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile new file mode 100644 index 0000000..e08d509 --- /dev/null +++ b/ci/image/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1 + +ENV GOFLAGS="-mod=readonly" +ENV CI=true + +RUN apt update && apt install -y screen + +RUN go install golang.org/x/tools/cmd/goimports@latest +RUN go install golang.org/x/lint/golint@latest +RUN go install github.com/mattn/goveralls@latest diff --git a/ci/lint.sh b/ci/lint.sh new file mode 100755 index 0000000..1e06b61 --- /dev/null +++ b/ci/lint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +echo "Linting..." + +go vet ./... +golint -set_exit_status ./... diff --git a/client.go b/client.go index 8e7b946..2df93e7 100644 --- a/client.go +++ b/client.go @@ -1,8 +1,355 @@ package wsep -import "nhooyr.io/websocket" +import ( + "bytes" + "context" + "encoding/json" + "io" + "net" + "strings" + + "cdr.dev/wsep/internal/proto" + "golang.org/x/xerrors" + "nhooyr.io/websocket" +) + +const maxMessageSize = 64000 + +type remoteExec struct { + conn *websocket.Conn +} // RemoteExecer creates an execution interface from a WebSocket connection. func RemoteExecer(conn *websocket.Conn) Execer { + conn.SetReadLimit(maxMessageSize) + return remoteExec{conn: conn} +} + +// Command represents an external command to be run +type Command struct { + // ID allows reconnecting commands that have a TTY. + ID string + Command string + Args []string + // Commands with a TTY also require Rows and Cols. + TTY bool + Rows uint16 + Cols uint16 + Stdin bool + UID uint32 + GID uint32 + Env []string + WorkingDir string +} + +// Start runs the command on the remote. Once a command is started, callers should +// not read from, write to, or close the websocket. Closing the returned Process will +// also close the websocket. +func (r remoteExec) Start(ctx context.Context, c Command) (Process, error) { + header := proto.ClientStartHeader{ + ID: c.ID, + Command: mapToProtoCmd(c), + Type: proto.TypeStart, + } + payload, err := json.Marshal(header) + if err != nil { + return nil, err + } + err = r.conn.Write(ctx, websocket.MessageBinary, payload) + if err != nil { + return nil, err + } + + _, payload, err = r.conn.Read(ctx) + if err != nil { + return nil, xerrors.Errorf("read pid message: %w", err) + } + var pidHeader proto.ServerPidHeader + err = json.Unmarshal(payload, &pidHeader) + if err != nil { + return nil, xerrors.Errorf("failed to parse pid message: %w", err) + } + + var stdin io.WriteCloser + if c.Stdin { + stdin = remoteStdin{ + conn: websocket.NetConn(ctx, r.conn, websocket.MessageBinary), + } + } else { + stdin = disabledStdinWriter{} + } + + listenCtx, cancelListen := context.WithCancel(ctx) + rp := &remoteProcess{ + ctx: ctx, + conn: r.conn, + cmd: c, + pid: pidHeader.Pid, + done: make(chan struct{}), + stderr: newPipe(), + stderrData: make(chan []byte), + stdout: newPipe(), + stdoutData: make(chan []byte), + stdin: stdin, + cancelListen: cancelListen, + } + + go rp.listen(listenCtx) + return rp, nil +} + +type remoteProcess struct { + ctx context.Context + cancelListen func() + cmd Command + conn *websocket.Conn + pid int + done chan struct{} + closeErr error + exitMsg *proto.ServerExitCodeHeader + readErr error + stdin io.WriteCloser + stdout pipe + stdoutErr error + stdoutData chan []byte + stderr pipe + stderrErr error + stderrData chan []byte +} + +type remoteStdin struct { + conn net.Conn +} + +func (r remoteStdin) Write(b []byte) (int, error) { + stdinHeader := proto.Header{ + Type: proto.TypeStdin, + } + + headerByt, err := json.Marshal(stdinHeader) + if err != nil { + return 0, err + } + stdinWriter := proto.WithHeader(r.conn, headerByt) + + maxBodySize := maxMessageSize - len(headerByt) - 1 + var nn int + for len(b) > maxMessageSize { + n, err := stdinWriter.Write(b[:maxBodySize]) + nn += n + if err != nil { + return nn, err + } + b = b[maxBodySize:] + } + + n, err := stdinWriter.Write(b) + nn += n + return nn, err +} + +func (r remoteStdin) Close() error { + closeHeader := proto.Header{ + Type: proto.TypeCloseStdin, + } + headerByt, err := json.Marshal(closeHeader) + if err != nil { + return err + } + _, err = proto.WithHeader(r.conn, headerByt).Write(nil) + return err +} + +type pipe struct { + r *io.PipeReader + w *io.PipeWriter + d chan []byte + e chan error + buf []byte +} + +func newPipe() pipe { + pr, pw := io.Pipe() + return pipe{ + r: pr, + w: pw, + d: make(chan []byte), + e: make(chan error), + buf: make([]byte, maxMessageSize), + } +} + +// writeCtx writes data to the pipe, or returns if the context is canceled. +func (p *pipe) writeCtx(ctx context.Context, data []byte) error { + // actually do the copy on another goroutine so that we can return if context + // is canceled + go func() { + var err error + select { + case <-ctx.Done(): + return + case body := <-p.d: + _, err = io.CopyBuffer(p.w, bytes.NewReader(body), p.buf) + } + select { + case <-ctx.Done(): + return + case p.e <- err: + return + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case p.d <- data: + // data being written. + } + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-p.e: + return err + } +} + +func (r *remoteProcess) listen(ctx context.Context) { + defer func() { + r.stdoutErr = r.stdout.w.Close() + r.stderrErr = r.stderr.w.Close() + + r.closeErr = r.conn.Close(websocket.StatusNormalClosure, "normal closure") + // If we were in r.conn.Read() we cancel the ctx, the websocket library closes + // the websocket before we have a chance to. Unfortunately there is a race in the + // the websocket library, where sometimes close frame has already been written before + // we even call r.conn.Close(), and sometimes it gets written during our call to + // r.conn.Close(), so we need to handle both those cases in examining the error that comes + // back. This is a normal closure, so report nil for the error. + readCtxCanceled := r.readErr != nil && strings.Contains(r.readErr.Error(), "context canceled") + alreadyClosed := r.closeErr != nil && + (strings.Contains(r.closeErr.Error(), "already wrote close") || + strings.Contains(r.closeErr.Error(), "WebSocket closed")) + if alreadyClosed && readCtxCanceled { + r.closeErr = nil + } + close(r.done) + }() + + for ctx.Err() == nil { + _, payload, err := r.conn.Read(ctx) + if err != nil { + r.readErr = err + return + } + headerByt, body := proto.SplitMessage(payload) + + var header proto.Header + err = json.Unmarshal(headerByt, &header) + if err != nil { + r.readErr = err + return + } + + switch header.Type { + case proto.TypeStderr: + err = r.stderr.writeCtx(ctx, body) + if err != nil { + r.readErr = err + return + } + case proto.TypeStdout: + err = r.stdout.writeCtx(ctx, body) + if err != nil { + r.readErr = err + return + } + case proto.TypeExitCode: + var exitMsg proto.ServerExitCodeHeader + err = json.Unmarshal(headerByt, &exitMsg) + if err != nil { + r.readErr = err + return + } + r.exitMsg = &exitMsg + return + } + } + // if we get here, the context is done, so use that as the read error + r.readErr = ctx.Err() +} + +func (r *remoteProcess) Pid() int { + return r.pid +} + +func (r *remoteProcess) Stdin() io.WriteCloser { + if !r.cmd.Stdin { + return disabledStdinWriter{} + } + return r.stdin +} + +// Stdout returns a reader for standard out from the process. You MUST read from +// this reader even if you don't care about the data to avoid blocking the +// websocket. +func (r *remoteProcess) Stdout() io.Reader { + return r.stdout.r +} + +// Stdout returns a reader for standard error from the process. You MUST read from +// this reader even if you don't care about the data to avoid blocking the +// websocket. +func (r *remoteProcess) Stderr() io.Reader { + return r.stderr.r +} + +func (r *remoteProcess) Resize(ctx context.Context, rows, cols uint16) error { + header := proto.ClientResizeHeader{ + Type: proto.TypeResize, + Cols: cols, + Rows: rows, + } + payload, err := json.Marshal(header) + if err != nil { + return err + } + return r.conn.Write(ctx, websocket.MessageBinary, payload) +} + +func (r *remoteProcess) Wait() error { + <-r.done + if r.readErr != nil { + return r.readErr + } + // when listen() closes r.done, either there must be a read error or exitMsg + // is set non-nil, so it's safe to access members here. + if r.exitMsg.ExitCode != 0 { + return ExitError{code: r.exitMsg.ExitCode, error: r.exitMsg.Error} + } + return nil +} + +func (r *remoteProcess) Close() error { + r.cancelListen() + <-r.done + closeErr := r.closeErr + return joinErrs(closeErr, r.stdoutErr, r.stderrErr) +} +func joinErrs(errs ...error) error { + var str string + foundErr := false + for _, e := range errs { + if e != nil { + foundErr = true + if str != "" { + str += ", " + } + str += e.Error() + } + } + if foundErr { + return xerrors.New(str) + } + return nil } diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..96c10b0 --- /dev/null +++ b/client_test.go @@ -0,0 +1,300 @@ +package wsep + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cdr.dev/slog/sloggers/slogtest/assert" + "cdr.dev/wsep/internal/proto" + "github.com/google/go-cmp/cmp" + "golang.org/x/xerrors" + "nhooyr.io/websocket" +) + +func TestRemoteStdin(t *testing.T) { + t.Parallel() + inputs := []string{ + "pwd", + "echo 123\n456", + "\necho 123456\n", + } + + for _, tcase := range inputs { + server, client := net.Pipe() + var stdin io.WriteCloser = remoteStdin{ + conn: client, + } + go func() { + defer client.Close() + _, err := stdin.Write([]byte(tcase)) + assert.Success(t, "write to stdin", err) + }() + + bytecmp := cmp.Comparer(bytes.Equal) + + msg, err := ioutil.ReadAll(server) + assert.Success(t, "read from server", err) + + header, body := proto.SplitMessage(msg) + + assert.Equal(t, "stdin body", []byte(tcase), body, bytecmp) + assert.Equal(t, "stdin header", []byte(`{"type":"stdin"}`), header, bytecmp) + } +} + +func mockConn(ctx context.Context, t *testing.T, wsepServer *Server, options *Options) (*websocket.Conn, *httptest.Server) { + mockServerHandler := func(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, nil) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if wsepServer != nil { + err = wsepServer.Serve(r.Context(), ws, LocalExecer{}, options) + } else { + err = Serve(r.Context(), ws, LocalExecer{}, options) + } + if err != nil { + // Max reason string length is 123. + errStr := err.Error() + if len(errStr) > 123 { + errStr = errStr[:123] + } + ws.Close(websocket.StatusInternalError, errStr) + return + } + ws.Close(websocket.StatusNormalClosure, "normal closure") + } + + server := httptest.NewServer(http.HandlerFunc(mockServerHandler)) + + ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + assert.Success(t, "dial websocket server", err) + return ws, server +} + +func TestRemoteExec(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + testExecer(ctx, t, execer) +} + +func TestRemoteClose(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + cmd := Command{ + Command: "sh", + TTY: true, + Stdin: true, + Cols: 100, + Rows: 100, + Env: []string{"TERM=linux"}, + } + + proc, err := execer.Start(ctx, cmd) + assert.Success(t, "execer Start", err) + + i := proc.Stdin() + o := proc.Stdout() + buf := make([]byte, 2048) + echoMsgBldr := strings.Builder{} + for i := 0; i < 512; i++ { + echoMsgBldr.WriteString("g") + } + _, err = fmt.Fprintf(i, "echo '%s'\r\n", echoMsgBldr.String()) + assert.Success(t, "echo", err) + bldr := strings.Builder{} + for !strings.Contains(bldr.String(), echoMsgBldr.String()) { + n, err := o.Read(buf) + if xerrors.Is(err, io.EOF) { + break + } + assert.Success(t, "read", err) + _, err = bldr.Write(buf[:n]) + assert.Success(t, "write to builder", err) + } + err = proc.Close() + assert.Success(t, "close proc", err) + // note that proc.Close() also closes the websocket. + assert.Success(t, "context", ctx.Err()) +} + +// TestRemoteCloseNoData tests we can close a remote process even when there is no new data. +func TestRemoteCloseNoData(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + cmd := Command{ + Command: "sh", + TTY: true, + Stdin: true, + Cols: 100, + Rows: 100, + Env: []string{"TERM=linux"}, + } + + proc, err := execer.Start(ctx, cmd) + assert.Success(t, "execer Start", err) + + go io.Copy(io.Discard, proc.Stdout()) + go io.Copy(io.Discard, proc.Stderr()) + + // give it some time to read and discard all data. + time.Sleep(100 * time.Millisecond) + + err = proc.Close() + assert.Success(t, "close proc", err) + // note that proc.Close() also closes the websocket. + assert.Success(t, "context", ctx.Err()) +} + +// TestRemoteCloseNoData tests we can close a remote process even when there is no new data. +func TestRemoteClosePartialRead(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + cmd := Command{ + Command: "sh", + TTY: true, + Stdin: true, + Cols: 100, + Rows: 100, + Env: []string{"TERM=linux"}, + } + + proc, err := execer.Start(ctx, cmd) + assert.Success(t, "execer Start", err) + + go io.Copy(io.Discard, proc.Stderr()) + + o := proc.Stdout() + // partially read the first output + buf := make([]byte, 2) + n, err := o.Read(buf) + assert.Success(t, "read", err) + assert.Equal(t, "read 2 bytes", 2, n) + + err = proc.Close() + assert.Success(t, "close proc", err) + // note that proc.Close() also closes the websocket. + assert.Success(t, "context", ctx.Err()) +} + +func TestRemoteExecFail(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + testExecerFail(ctx, t, execer) +} + +func testExecerFail(ctx context.Context, t *testing.T, execer Execer) { + process, err := execer.Start(ctx, Command{ + Command: "ls", + Args: []string{"/doesnotexist"}, + }) + assert.Success(t, "start local cmd", err) + + go io.Copy(ioutil.Discard, process.Stderr()) + go io.Copy(ioutil.Discard, process.Stdout()) + + err = process.Wait() + exitErr, ok := err.(ExitError) + assert.True(t, "is exit error", ok) + assert.True(t, "exit code is nonzero", exitErr.ExitCode() != 0) + assert.Equal(t, "exit error", exitErr.Error(), "exit status 2") + assert.Error(t, "wait for process to error", err) +} + +func TestStderrVsStdout(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + wsepServer := NewServer() + defer wsepServer.Close() + defer assert.Equal(t, "no leaked sessions", 0, wsepServer.SessionCount()) + + ws, server := mockConn(ctx, t, wsepServer, nil) + defer server.Close() + + execer := RemoteExecer(ws) + process, err := execer.Start(ctx, Command{ + Command: "sh", + Args: []string{"-c", "echo stdout-message; echo 1>&2 stderr-message"}, + Stdin: false, + }) + assert.Success(t, "start command", err) + + go io.Copy(&stdout, process.Stdout()) + go io.Copy(&stderr, process.Stderr()) + + err = process.Wait() + assert.Success(t, "wait for process to complete", err) + + assert.Equal(t, "stdout", "stdout-message", strings.TrimSpace(stdout.String())) + assert.Equal(t, "stderr", "stderr-message", strings.TrimSpace(stderr.String())) +} diff --git a/dev/client/main.go b/dev/client/main.go new file mode 100644 index 0000000..c270cdc --- /dev/null +++ b/dev/client/main.go @@ -0,0 +1,171 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "context" + "io" + "os" + "os/signal" + "syscall" + "time" + + "cdr.dev/wsep" + "github.com/spf13/pflag" + "golang.org/x/term" + "nhooyr.io/websocket" + + "go.coder.com/cli" + "go.coder.com/flog" +) + +type notty struct { + timeout time.Duration +} + +func (c *notty) Run(fl *pflag.FlagSet) { + do(fl, false, "", c.timeout) +} + +func (c *notty) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "notty", + Usage: "[flags] ", + Desc: `Run a command without tty enabled.`, + } +} + +func (c *notty) RegisterFlags(fl *pflag.FlagSet) { + fl.DurationVar(&c.timeout, "timeout", 0, "disconnect after specified timeout") +} + +type tty struct { + id string + timeout time.Duration +} + +func (c *tty) Run(fl *pflag.FlagSet) { + do(fl, true, c.id, c.timeout) +} + +func (c *tty) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "tty", + Usage: "[flags] ", + Desc: `Run a command with tty enabled. Use the same ID to reconnect.`, + } +} + +func (c *tty) RegisterFlags(fl *pflag.FlagSet) { + fl.StringVar(&c.id, "id", "", "sets id for reconnection") + fl.DurationVar(&c.timeout, "timeout", 0, "disconnect after the specified timeout") +} + +func do(fl *pflag.FlagSet, tty bool, id string, timeout time.Duration) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + conn, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil) + if err != nil { + flog.Fatal("failed to dial remote executor: %v", err) + } + defer conn.Close(websocket.StatusNormalClosure, "terminate process") + + executor := wsep.RemoteExecer(conn) + + var args []string + if len(fl.Args()) < 1 { + flog.Fatal("a command argument is required") + } + if len(fl.Args()) > 1 { + args = fl.Args()[1:] + } + width, height, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + flog.Fatal("unable to get term size") + } + process, err := executor.Start(ctx, wsep.Command{ + ID: id, + Command: fl.Arg(0), + Args: args, + TTY: tty, + Stdin: true, + Rows: uint16(height), + Cols: uint16(width), + Env: []string{"TERM=" + os.Getenv("TERM")}, + }) + if err != nil { + flog.Fatal("failed to start remote command: %v", err) + } + if tty { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + width, height, err := term.GetSize(int(os.Stdin.Fd())) + if err != nil { + continue + } + process.Resize(ctx, uint16(height), uint16(width)) + } + }() + ch <- syscall.SIGWINCH + + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + flog.Fatal("failed to make terminal raw for tty: %w", err) + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + } + + go io.Copy(os.Stdout, process.Stdout()) + go io.Copy(os.Stderr, process.Stderr()) + go func() { + stdin := process.Stdin() + defer stdin.Close() + io.Copy(stdin, os.Stdin) + }() + + if timeout != 0 { + timer := time.NewTimer(timeout) + defer timer.Stop() + go func() { + <-timer.C + conn.Close(websocket.StatusNormalClosure, "normal closure") + }() + } + + err = process.Wait() + if err != nil { + flog.Error("process failed: %v", err) + } + conn.Close(websocket.StatusNormalClosure, "normal closure") +} + +type cmd struct { +} + +func (c *cmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "wsep-client", + Usage: "[flags]", + Desc: `Run a simple wsep client for testing.`, + RawArgs: true, + } +} + +func (c *cmd) Run(fl *pflag.FlagSet) { + fl.Usage() + os.Exit(1) +} +func (c *cmd) Subcommands() []cli.Command { + return []cli.Command{ + ¬ty{}, + &tty{}, + } +} + +func main() { + cli.RunRoot(&cmd{}) +} diff --git a/dev/server/main.go b/dev/server/main.go new file mode 100644 index 0000000..1085d29 --- /dev/null +++ b/dev/server/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + "time" + + "cdr.dev/wsep" + "go.coder.com/flog" + "nhooyr.io/websocket" +) + +func main() { + server := http.Server{ + Addr: ":8080", + Handler: http.HandlerFunc(serve), + } + err := server.ListenAndServe() + flog.Fatal("failed to listen: %v", err) +} + +func serve(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + err = wsep.Serve(r.Context(), ws, wsep.LocalExecer{}, &wsep.Options{ + SessionTimeout: 30 * time.Second, + }) + if err != nil { + flog.Error("failed to serve execer: %v", err) + ws.Close(websocket.StatusInternalError, "failed to serve execer") + return + } + ws.Close(websocket.StatusNormalClosure, "normal closure") +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2a2b548 --- /dev/null +++ b/doc.go @@ -0,0 +1,6 @@ +// Package wsep provides server and client interfaces +// for transmitting Linux command execution over a Websocket connection. + +// It provides stdin, stdout, stderr, tty operations, and exit code information. + +package wsep diff --git a/exec.go b/exec.go index 050b6a3..0ba6def 100644 --- a/exec.go +++ b/exec.go @@ -3,32 +3,43 @@ package wsep import ( "context" "io" -) -// Command represents a runnable command. -type Command struct { - Command string - Args []string - TTY bool -} + "cdr.dev/wsep/internal/proto" +) // ExitError is sent when the command terminates. type ExitError struct { - Code int + code int + error string +} + +// ExitCode returns the exit code of the process. +func (e ExitError) ExitCode() int { + return e.code +} + +// Error returns a string describing why the process errored. +func (e ExitError) Error() string { + return e.error } // Process represents a started command. type Process interface { + // Pid is populated immediately during a successful start with the process ID. Pid() int + // Stdout returns an io.WriteCloser that will pipe writes to the remote command. + // Closure of stdin sends the corresponding close message. Stdin() io.WriteCloser + // Stdout returns an io.Reader that is connected to the command's standard output. Stdout() io.Reader + // Stderr returns an io.Reader that is connected to the command's standard error. Stderr() io.Reader // Resize resizes the TTY if a TTY is enabled. - Resize(rows, cols uint16) error - // Wait returns ExitError when the command terminates. + Resize(ctx context.Context, rows, cols uint16) error + // Wait returns ExitError when the command terminates with a non-zero exit code. Wait() error - // Close terminates the process and underlying connection(s). - // It must be called otherwise a connection or process may leak. + // Close sends a SIGTERM to the process. To force a shutdown cancel the + // context passed into the execer. Close() error } @@ -37,3 +48,33 @@ type Execer interface { Start(ctx context.Context, c Command) (Process, error) } +// theses maps are needed to prevent an import cycle +func mapToProtoCmd(c Command) proto.Command { + return proto.Command{ + Command: c.Command, + Args: c.Args, + Stdin: c.Stdin, + TTY: c.TTY, + Rows: c.Rows, + Cols: c.Cols, + UID: c.UID, + GID: c.GID, + Env: c.Env, + WorkingDir: c.WorkingDir, + } +} + +func mapToClientCmd(c proto.Command) *Command { + return &Command{ + Command: c.Command, + Args: c.Args, + Stdin: c.Stdin, + TTY: c.TTY, + Rows: c.Rows, + Cols: c.Cols, + UID: c.UID, + GID: c.GID, + Env: c.Env, + WorkingDir: c.WorkingDir, + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6066b3b --- /dev/null +++ b/flake.lock @@ -0,0 +1,41 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1663235518, + "narHash": "sha256-q8zLK6rK/CLXEguaPgm9yQJcY0VQtOBhAT9EV2UFK/A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2277e4c9010b0f27585eb0bed0a86d7cbc079354", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1b932a4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,19 @@ +{ + description = "wsep"; + + inputs.flake-utils.url = "github:numtide/flake-utils"; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + screen + ]; + }; + } + ); +} diff --git a/go.mod b/go.mod index d51d38d..2d51221 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,15 @@ module cdr.dev/wsep go 1.14 require ( + cdr.dev/slog v1.3.0 github.com/creack/pty v1.1.11 + github.com/google/go-cmp v0.4.0 + github.com/google/uuid v1.3.0 + github.com/spf13/pflag v1.0.5 + go.coder.com/cli v0.4.0 + go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 + golang.org/x/sync v0.0.0-20190423024810-112230192c58 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 nhooyr.io/websocket v1.8.6 ) diff --git a/go.sum b/go.sum index 36bb163..c9a9a09 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,293 @@ +cdr.dev/slog v1.3.0 h1:MYN1BChIaVEGxdS7I5cpdyMC0+WfJfK8BETAfzfLUGQ= +cdr.dev/slog v1.3.0/go.mod h1:C5OL99WyuOK8YHZdYY57dAPN1jK2WJlCdq2VP6xeQns= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.49.0 h1:CH+lkubJzcPYB1Ggupcq0+k8Ni2ILdG2lYjDIgavDBQ= +cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= +github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= +github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= +github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= +github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= +github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= +github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= +go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= +go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 h1:DjCS6dRQh+1PlfiBmnabxfdrzenb0tAwJqFxDEH/s9g= +go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1 h1:aQktFqmDE2yjveXJlVIfslDFmFnUXSqG0i6KRcJAeMc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k= nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/internal/proto/README.md b/internal/proto/README.md new file mode 100644 index 0000000..42cc39b --- /dev/null +++ b/internal/proto/README.md @@ -0,0 +1,86 @@ +# Protocol + +Each message is represented as a single WebSocket message. A newline character separates a JSON header from the binary body. + +Some messages may omit the body. + +The overhead of the additional frame is 2 to 6 bytes. In high throughput cases, messages contain ~32KB of data, +so this overhead is negligible. + +### Client Messages + +#### Start + +This must be the first Client message. + +```json +{ + "type": "start", + "command": { + "command": "cat", + "args": ["/dev/urandom"], + "tty": false, + "stdin": false + } +} +``` + +#### Stdin + +```json +{ "type": "stdin" } +``` + +and a body follows after a newline character. + +#### Resize + +```json +{ "type": "resize", "cols": 80, "rows": 80 } +``` + +Only valid on tty messages. + +#### CloseStdin + +No more Stdin messages may be sent after this. + +```json +{ "type": "close_stdin" } +``` + +### Server Messages + +#### Pid + +This is sent immediately after the command starts. + +```json +{ "type": "pid", "pid": 0 } +``` + +#### Stdout + +```json +{ "type": "stdout" } +``` + +and a body follows after a newline character. + +#### Stderr + +```json +{ "type": "stderr" } +``` + +and a body follows after a newline character. + +#### ExitCode + +This is the last message sent by the server. + +```json +{ "type": "exit_code", "exit_code": 255 } +``` + +A normal closure follows. diff --git a/internal/proto/clientmsg.go b/internal/proto/clientmsg.go index c76abfe..19c9add 100644 --- a/internal/proto/clientmsg.go +++ b/internal/proto/clientmsg.go @@ -1,19 +1,37 @@ package proto +// Client message header type const ( TypeStart = "start" - TypeResize = "resize_header" + TypeResize = "resize" TypeStdin = "stdin" TypeCloseStdin = "close_stdin" ) -type ClientStartHeader struct { - Command string `json:"command"` - Args []string `json:"args"` - TTY bool `json:"tty"` -} - +// ClientResizeHeader specifies a terminal window resize request type ClientResizeHeader struct { + Type string `json:"type"` Rows uint16 `json:"rows"` Cols uint16 `json:"cols"` } + +// ClientStartHeader specifies a request to start command +type ClientStartHeader struct { + Type string `json:"type"` + ID string `json:"id"` + Command Command `json:"command"` +} + +// Command represents a runnable command. +type Command struct { + Command string `json:"command"` + Args []string `json:"args"` + Stdin bool `json:"stdin"` + TTY bool `json:"tty"` + Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` + UID uint32 `json:"uid"` + GID uint32 `json:"gid"` + Env []string `json:"env"` + WorkingDir string `json:"working_dir"` +} diff --git a/internal/proto/protocol.go b/internal/proto/protocol.go index e2e455b..8a55274 100644 --- a/internal/proto/protocol.go +++ b/internal/proto/protocol.go @@ -1,7 +1,50 @@ package proto +import ( + "bytes" + "io" +) + // Header is a generic JSON header. type Header struct { Type string `json:"type"` } +// delimiter splits the message header from the body +const delimiter = '\n' + +// SplitMessage into header and body components +// all messages must have a header. body returns as nil if no delimiter is found +func SplitMessage(b []byte) (header []byte, body []byte) { + ix := bytes.IndexRune(b, delimiter) + if ix == -1 { + return b, nil + } + header = b[:ix] + if ix < len(b)-1 { + body = b[ix+1:] + } + return header, body +} + +type headerWriter struct { + w io.Writer + header []byte +} + +// WithHeader adds the given header to all writes +func WithHeader(w io.Writer, header []byte) io.Writer { + return headerWriter{ + header: header, + w: w, + } +} + +func (h headerWriter) Write(b []byte) (int, error) { + msg := append(append(h.header, delimiter), b...) + _, err := h.w.Write(msg) + if err != nil { + return 0, err + } + return len(b), nil +} diff --git a/internal/proto/protocol_test.go b/internal/proto/protocol_test.go new file mode 100644 index 0000000..b156928 --- /dev/null +++ b/internal/proto/protocol_test.go @@ -0,0 +1,44 @@ +package proto + +import ( + "bytes" + "io/ioutil" + "testing" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/google/go-cmp/cmp" +) + +func TestWithHeader(t *testing.T) { + tests := []struct { + inputheader, inputbody, header, body []byte + }{ + { + inputheader: []byte("header"), + inputbody: []byte("body"), + header: []byte("header"), + body: []byte("body"), + }, + { + inputheader: []byte("header"), + inputbody: []byte("b\nody\n"), + header: []byte("header"), + body: []byte("b\nody\n"), + }, + } + bytecmp := cmp.Comparer(bytes.Equal) + + for _, tcase := range tests { + b := bytes.NewBuffer(nil) + withheader := WithHeader(b, []byte(tcase.inputheader)) + _, err := withheader.Write([]byte(tcase.inputbody)) + assert.Success(t, "write to buffer with header", err) + + msg, err := ioutil.ReadAll(b) + assert.Success(t, "read buffer", err) + + header, body := SplitMessage(msg) + assert.Equal(t, "header is expected value", tcase.header, header, bytecmp) + assert.Equal(t, "body is expected value", tcase.body, body, bytecmp) + } +} diff --git a/internal/proto/servermsg.go b/internal/proto/servermsg.go index 629a468..2132a26 100644 --- a/internal/proto/servermsg.go +++ b/internal/proto/servermsg.go @@ -1,5 +1,6 @@ package proto +// Server message header type const ( TypePid = "pid" TypeStdout = "stdout" @@ -7,10 +8,15 @@ const ( TypeExitCode = "exit_code" ) +// ServerPidHeader specifies the message send immediately after the request command starts type ServerPidHeader struct { - Pid int `json:"pid"` + Type string `json:"type"` + Pid int `json:"pid"` } +// ServerExitCodeHeader specifies the final message from the server after the command exits type ServerExitCodeHeader struct { - ExitCode int `json:"exit_code"` + Type string `json:"type"` + ExitCode int `json:"exit_code"` + Error string `json:"error"` } diff --git a/localexec.go b/localexec.go index 061c7b7..31a1f2c 100644 --- a/localexec.go +++ b/localexec.go @@ -1,27 +1,17 @@ package wsep import ( - "context" "io" - "os" "os/exec" + "syscall" - "github.com/creack/pty" "golang.org/x/xerrors" ) // LocalExecer executes command on the local system. type LocalExecer struct { -} - -type localProcess struct { - // tty may be nil - tty *os.File - cmd *exec.Cmd - - stdin io.WriteCloser - stdout io.Reader - stderr io.Reader + // ChildProcessPriority overrides the default niceness of all child processes launch by LocalExecer. + ChildProcessPriority *int } func (l *localProcess) Stdin() io.WriteCloser { @@ -37,63 +27,30 @@ func (l *localProcess) Stderr() io.Reader { } func (l *localProcess) Wait() error { - return l.cmd.Wait() + err := l.cmd.Wait() + if exitErr, ok := err.(*exec.ExitError); ok { + return ExitError{ + code: exitErr.ExitCode(), + error: exitErr.Error(), + } + } + return err } func (l *localProcess) Close() error { - return l.cmd.Process.Kill() -} - -func (l *localProcess) Resize(rows, cols uint16) error { - if l.tty == nil { - return nil - } - return pty.Setsize(l.tty, &pty.Winsize{ - Rows: rows, - Cols: cols, - }) + return l.cmd.Process.Signal(syscall.SIGTERM) } func (l *localProcess) Pid() int { return l.cmd.Process.Pid } -func (l LocalExecer) Start(ctx context.Context, c Command) (Process, error) { - var ( - process localProcess - ) - process.cmd = exec.Command(c.Command, c.Args...) +type disabledStdinWriter struct{} - var ( - err error - ) - - process.stdin, err = process.cmd.StdinPipe() - if err != nil { - return nil, xerrors.Errorf("create pipe: %w", err) - } - - process.stdout, err = process.cmd.StdoutPipe() - if err != nil { - return nil, xerrors.Errorf("create pipe: %w", err) - } - - process.stderr, err = process.cmd.StderrPipe() - if err != nil { - return nil, xerrors.Errorf("create pipe: %w", err) - } - - if c.TTY { - process.tty, err = pty.Start(process.cmd) - if err != nil { - return nil, xerrors.Errorf("start command with pty: %w", err) - } - } else { - err = process.cmd.Start() - if err != nil { - return nil, xerrors.Errorf("start command: %w", err) - } - } +func (w disabledStdinWriter) Close() error { + return nil +} - return &process, nil +func (w disabledStdinWriter) Write(_ []byte) (written int, err error) { + return 0, xerrors.Errorf("stdin is not enabled for this command") } diff --git a/localexec_test.go b/localexec_test.go new file mode 100644 index 0000000..af60586 --- /dev/null +++ b/localexec_test.go @@ -0,0 +1,162 @@ +package wsep + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "os" + "strings" + "sync" + "testing" + "time" + + "cdr.dev/slog/sloggers/slogtest/assert" + "golang.org/x/sync/errgroup" +) + +func TestLocalExec(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + testExecer(ctx, t, LocalExecer{}) +} + +func testExecer(ctx context.Context, t *testing.T, execer Execer) { + process, err := execer.Start(ctx, Command{ + Command: "pwd", + }) + assert.Success(t, "start local cmd", err) + var ( + stderr = process.Stderr() + stdout = process.Stdout() + wg sync.WaitGroup + ) + + wg.Add(1) + go func() { + defer wg.Done() + + stdoutByt, err := ioutil.ReadAll(stdout) + assert.Success(t, "read stdout", err) + wd, err := os.Getwd() + assert.Success(t, "get real working dir", err) + + assert.Equal(t, "stdout", wd, strings.TrimSuffix(string(stdoutByt), "\n")) + }() + wg.Add(1) + go func() { + defer wg.Done() + + stderrByt, err := ioutil.ReadAll(stderr) + assert.Success(t, "read stderr", err) + assert.True(t, "len stderr", len(stderrByt) == 0) + }() + + wg.Wait() + err = process.Wait() + assert.Success(t, "wait for process to complete", err) +} + +func TestExitCode(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + process, err := LocalExecer{}.Start(ctx, Command{ + Command: "sh", + Args: []string{"-c", `"fakecommand"`}, + }) + assert.Success(t, "start local cmd", err) + + err = process.Wait() + exitErr, ok := err.(ExitError) + assert.True(t, "error is ExitError", ok) + assert.Equal(t, "exit error code", exitErr.ExitCode(), 127) + assert.Equal(t, "exit error", exitErr.Error(), "exit status 127") +} + +func TestStdin(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var execer LocalExecer + process, err := execer.Start(ctx, Command{ + Command: "cat", + Stdin: true, + }) + assert.Success(t, "start command", err) + + go func() { + stdin := process.Stdin() + defer stdin.Close() + _, err := io.Copy(stdin, strings.NewReader("testing value")) + assert.Success(t, "copy stdin", err) + }() + + io.Copy(os.Stdout, process.Stdout()) + err = process.Wait() + assert.Success(t, "process wait", err) +} + +func TestStdinFail(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var execer LocalExecer + process, err := execer.Start(ctx, Command{ + Command: "cat", + Stdin: false, + }) + assert.Success(t, "start command", err) + + go func() { + stdin := process.Stdin() + defer stdin.Close() + _, err := io.Copy(stdin, strings.NewReader("testing value")) + assert.Error(t, "copy stdin should fail", err) + }() + + io.Copy(os.Stdout, process.Stdout()) + err = process.Wait() + assert.Success(t, "process wait", err) +} + +func TestStdoutVsStderr(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var ( + execer LocalExecer + stdout bytes.Buffer + stderr bytes.Buffer + ) + process, err := execer.Start(ctx, Command{ + Command: "sh", + Args: []string{"-c", "echo stdout-message; echo 1>&2 stderr-message"}, + Stdin: false, + TTY: false, + }) + assert.Success(t, "start command", err) + + var outputgroup errgroup.Group + outputgroup.Go(func() error { + _, err := io.Copy(&stdout, process.Stdout()) + return err + }) + outputgroup.Go(func() error { + _, err := io.Copy(&stderr, process.Stderr()) + return err + }) + + err = outputgroup.Wait() + assert.Success(t, "wait for output to drain", err) + + err = process.Wait() + assert.Success(t, "wait for process to complete", err) + + assert.Equal(t, "stdout", "stdout-message", strings.TrimSpace(stdout.String())) + assert.Equal(t, "stderr", "stderr-message", strings.TrimSpace(stderr.String())) +} diff --git a/localexec_unix.go b/localexec_unix.go new file mode 100644 index 0000000..d118cd4 --- /dev/null +++ b/localexec_unix.go @@ -0,0 +1,109 @@ +//go:build !windows +// +build !windows + +package wsep + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "os" + "os/exec" + "syscall" + + "github.com/creack/pty" + "golang.org/x/xerrors" +) + +type localProcess struct { + // tty may be nil + tty *os.File + cmd *exec.Cmd + + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader +} + +func (l *localProcess) Resize(_ context.Context, rows, cols uint16) error { + if l.tty == nil { + return nil + } + return pty.Setsize(l.tty, &pty.Winsize{ + Rows: rows, + Cols: cols, + }) +} + +// Start executes the given command locally +func (l LocalExecer) Start(ctx context.Context, c Command) (Process, error) { + var ( + process localProcess + err error + ) + process.cmd = exec.CommandContext(ctx, c.Command, c.Args...) + process.cmd.Env = append(os.Environ(), c.Env...) + process.cmd.Dir = c.WorkingDir + + if c.GID != 0 || c.UID != 0 { + process.cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{}, + } + } + if c.GID != 0 { + process.cmd.SysProcAttr.Credential.Gid = c.GID + } + if c.UID != 0 { + process.cmd.SysProcAttr.Credential.Uid = c.UID + } + + if c.TTY { + // This special WSEP_TTY variable helps debug unexpected TTYs. + process.cmd.Env = append(process.cmd.Env, "WSEP_TTY=true") + process.tty, err = pty.StartWithSize(process.cmd, &pty.Winsize{ + Rows: c.Rows, + Cols: c.Cols, + }) + if err != nil { + return nil, xerrors.Errorf("start command with pty: %w", err) + } + process.stdout = process.tty + process.stderr = ioutil.NopCloser(bytes.NewReader(nil)) + process.stdin = process.tty + } else { + if c.Stdin { + process.stdin, err = process.cmd.StdinPipe() + if err != nil { + return nil, xerrors.Errorf("create pipe: %w", err) + } + } else { + process.stdin = disabledStdinWriter{} + } + + process.stdout, err = process.cmd.StdoutPipe() + if err != nil { + return nil, xerrors.Errorf("create pipe: %w", err) + } + + process.stderr, err = process.cmd.StderrPipe() + if err != nil { + return nil, xerrors.Errorf("create pipe: %w", err) + } + + err = process.cmd.Start() + if err != nil { + return nil, xerrors.Errorf("start command: %w", err) + } + } + + if l.ChildProcessPriority != nil { + pid := process.cmd.Process.Pid + niceness := *l.ChildProcessPriority + + // the environment may block the niceness syscall + _ = syscall.Setpriority(syscall.PRIO_PROCESS, pid, niceness) + } + + return &process, nil +} diff --git a/localexec_windows.go b/localexec_windows.go new file mode 100644 index 0000000..1ebeccd --- /dev/null +++ b/localexec_windows.go @@ -0,0 +1,31 @@ +//go:build windows +// +build windows + +package wsep + +import ( + "context" + "io" + "os/exec" + + "golang.org/x/xerrors" +) + +type localProcess struct { + // tty may be nil + tty uintptr + cmd *exec.Cmd + + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader +} + +func (l *localProcess) Resize(_ context.Context, rows, cols uint16) error { + return xerrors.Errorf("Windows local execution is not supported") +} + +// Start executes the given command locally +func (l LocalExecer) Start(ctx context.Context, c Command) (Process, error) { + return nil, xerrors.Errorf("Windows local execution is not supported") +} diff --git a/server.go b/server.go index 229b015..1274c72 100644 --- a/server.go +++ b/server.go @@ -1,103 +1,302 @@ package wsep import ( + "bytes" "context" "encoding/json" "errors" "io" - "io/ioutil" + "net" + "os/exec" + "sync" + "time" + "go.coder.com/flog" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "nhooyr.io/websocket" "cdr.dev/wsep/internal/proto" ) +const ( + defaultRows = 80 + defaultCols = 24 +) + +// Options allows configuring the server. +type Options struct { + SessionTimeout time.Duration +} + +// _sessions is a global map of sessions that exists for backwards +// compatibility. Server should be used instead which locally maintains the +// map. +var _sessions sync.Map + +// _sessionsMutex is a global mutex that exists for backwards compatibility. +// Server should be used instead which locally maintains the mutex. +var _sessionsMutex sync.Mutex + // Serve runs the server-side of wsep. -// The execer may be another wsep connection for chaining. -// Use LocalExecer for local command execution. -func Serve(ctx context.Context, c *websocket.Conn, execer Execer) error { +// Deprecated: Use Server.Serve() instead. +func Serve(ctx context.Context, c *websocket.Conn, execer Execer, options *Options) error { + srv := Server{sessions: &_sessions, sessionsMutex: &_sessionsMutex} + return srv.Serve(ctx, c, execer, options) +} + +// Server runs the server-side of wsep. The execer may be another wsep +// connection for chaining. Use LocalExecer for local command execution. +type Server struct { + sessions *sync.Map + sessionsMutex *sync.Mutex +} + +// NewServer returns as new wsep server. +func NewServer() *Server { + return &Server{ + sessions: &sync.Map{}, + sessionsMutex: &sync.Mutex{}, + } +} + +// SessionCount returns the number of sessions. +func (srv *Server) SessionCount() int { + var i int + srv.sessions.Range(func(k, rawSession interface{}) bool { + i++ + return true + }) + return i +} + +// Close closes all sessions. +func (srv *Server) Close() { + srv.sessions.Range(func(k, rawSession interface{}) bool { + if s, ok := rawSession.(*Session); ok { + s.Close("test cleanup") + } + return true + }) +} + +// Serve runs the server-side of wsep. The execer may be another wsep +// connection for chaining. Use LocalExecer for local command execution. The +// web socket will not be closed automatically; the caller must call Close() on +// the web socket (ideally with a reason) once Serve yields. +func (srv *Server) Serve(ctx context.Context, c *websocket.Conn, execer Execer, options *Options) error { + // The process will get killed when the connection context ends. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + if options == nil { + options = &Options{} + } + if options.SessionTimeout == 0 { + options.SessionTimeout = 5 * time.Minute + } + + c.SetReadLimit(maxMessageSize) var ( - header proto.Header - process Process - copyBuf = make([]byte, 32<<10) + header proto.Header + process Process + wsNetConn = websocket.NetConn(ctx, c, websocket.MessageBinary) ) - defer func() { - if process != nil { - process.Close() - } - }() + for { - typ, reader, err := c.Reader(ctx) - if err != nil { - return xerrors.Errorf("get reader: %w", err) + if err := ctx.Err(); err != nil { + return err } - if typ != websocket.MessageText { - return xerrors.Errorf("expected text message first: %w", err) + _, byt, err := c.Read(ctx) + if xerrors.Is(err, io.EOF) { + return nil } - // Allocate, because we have to read header twice. - byt, err := ioutil.ReadAll(reader) if err != nil { - return xerrors.Errorf("read header: %w", err) + status := websocket.CloseStatus(err) + if status == -1 { + return xerrors.Errorf("read message: %w", err) + } + if status != websocket.StatusNormalClosure { + return err + } + return nil } - err = json.Unmarshal(byt, &header) + headerByt, bodyByt := proto.SplitMessage(byt) + err = json.Unmarshal(headerByt, &header) if err != nil { return xerrors.Errorf("unmarshal header: %w", err) } switch header.Type { case proto.TypeStart: - header := proto.ClientStartHeader{} + if process != nil { + return errors.New("command already started") + } + + var header proto.ClientStartHeader err = json.Unmarshal(byt, &header) if err != nil { return xerrors.Errorf("unmarshal start header: %w", err) } - process, err = execer.Start(ctx, Command{ - Command: header.Command, - Args: header.Args, - TTY: header.TTY, - }) + + command := mapToClientCmd(header.Command) + + if command.TTY { + // If rows and cols are not provided, default to 80x24. + if command.Rows == 0 { + flog.Info("rows not provided, defaulting to 80") + command.Rows = defaultRows + } + if command.Cols == 0 { + flog.Info("cols not provided, defaulting to 24") + command.Cols = defaultCols + } + } + + // Only TTYs with IDs can be reconnected. + if command.TTY && header.ID != "" { + process, err = srv.withSession(ctx, header.ID, command, execer, options) + } else { + process, err = execer.Start(ctx, *command) + } if err != nil { return err } + + err = sendPID(ctx, process.Pid(), wsNetConn) + if err != nil { + return xerrors.Errorf("failed to send pid %d: %w", process.Pid(), err) + } + + var outputgroup errgroup.Group + outputgroup.Go(func() error { + return copyWithHeader(process.Stdout(), wsNetConn, proto.Header{Type: proto.TypeStdout}) + }) + outputgroup.Go(func() error { + return copyWithHeader(process.Stderr(), wsNetConn, proto.Header{Type: proto.TypeStderr}) + }) + go func() { - process.Stdout() + // Wait for the readers to close which happens when the connection + // closes or the process dies. + _ = outputgroup.Wait() + err := process.Wait() + _ = sendExitCode(ctx, err, wsNetConn) }() + case proto.TypeResize: if process == nil { return errors.New("resize sent before command started") } - header := proto.ClientResizeHeader{} + var header proto.ClientResizeHeader err = json.Unmarshal(byt, &header) if err != nil { return xerrors.Errorf("unmarshal resize header: %w", err) } - err = process.Resize(header.Rows, header.Cols) + err = process.Resize(ctx, header.Rows, header.Cols) if err != nil { return xerrors.Errorf("resize: %w", err) } case proto.TypeStdin: - _, reader, err := c.Reader(ctx) - if err != nil { - return xerrors.Errorf("get stdin reader: %w", err) - } - - _, err = io.CopyBuffer(process.Stdin(), reader, copyBuf) + _, err := io.Copy(process.Stdin(), bytes.NewReader(bodyByt)) if err != nil { return xerrors.Errorf("read stdin: %w", err) } case proto.TypeCloseStdin: - wr, err := c.Writer(ctx, websocket.MessageText) + err = process.Stdin().Close() if err != nil { - return xerrors.Errorf("get writer: %w", err) - } - err = json.NewEncoder(wr).Encode(&proto.Header{Type: "close_stdin"}) - if err != nil { - return xerrors.Errorf("encode close_stdin: %w", err) + return xerrors.Errorf("close stdin: %w", err) } + default: + flog.Error("unrecognized header type: %s", header.Type) } } } + +// withSession runs the command in a session if screen is available. +func (srv *Server) withSession(ctx context.Context, id string, command *Command, execer Execer, options *Options) (Process, error) { + // If screen is not installed spawn the command normally. + _, err := exec.LookPath("screen") + if err != nil { + flog.Info("`screen` could not be found; session %s will not persist", id) + return execer.Start(ctx, *command) + } + + var s *Session + srv.sessionsMutex.Lock() + if rawSession, ok := srv.sessions.Load(id); ok { + if s, ok = rawSession.(*Session); !ok { + return nil, xerrors.Errorf("found invalid type in session map for ID %s", id) + } + } + + // It is possible that the session has closed but the goroutine that waits for + // that state and deletes it from the map has not ran yet meaning the session + // is still in the map and we grabbed a closing session. Wait for any pending + // state changes and if it is closed create a new session instead. + if s != nil { + state, _ := s.WaitForState(StateReady) + if state > StateReady { + s = nil + } + } + + if s == nil { + s = NewSession(command, execer, options) + srv.sessions.Store(id, s) + go func() { // Remove the session from the map once it closes. + defer srv.sessions.Delete(id) + s.Wait() + }() + } + + srv.sessionsMutex.Unlock() + + return s.Attach(ctx) +} + +func sendExitCode(_ context.Context, err error, conn net.Conn) error { + exitCode := 0 + errorStr := "" + if err != nil { + errorStr = err.Error() + } + if exitErr, ok := err.(ExitError); ok { + exitCode = exitErr.ExitCode() + } + header, err := json.Marshal(proto.ServerExitCodeHeader{ + Type: proto.TypeExitCode, + ExitCode: exitCode, + Error: errorStr, + }) + if err != nil { + return err + } + _, err = proto.WithHeader(conn, header).Write(nil) + return err +} + +func sendPID(_ context.Context, pid int, conn net.Conn) error { + header, err := json.Marshal(proto.ServerPidHeader{Type: proto.TypePid, Pid: pid}) + if err != nil { + return err + } + _, err = proto.WithHeader(conn, header).Write(nil) + return err +} + +func copyWithHeader(r io.Reader, w io.Writer, header proto.Header) error { + headerByt, err := json.Marshal(header) + if err != nil { + return err + } + wr := proto.WithHeader(w, headerByt) + _, err = io.Copy(wr, r) + if err != nil { + return err + } + return nil +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..ec5c0bd --- /dev/null +++ b/session.go @@ -0,0 +1,383 @@ +package wsep + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "go.coder.com/flog" + "golang.org/x/xerrors" +) + +// State represents the current state of the session. States are sequential and +// will only move forward. +type State int + +const ( + // StateStarting is the default/start state. + StateStarting = iota + // StateReady means the session is ready to be attached. + StateReady + // StateClosing means the session has begun closing. The underlying process + // may still be exiting. + StateClosing + // StateDone means the session has completely shut down and the process has + // exited. + StateDone +) + +// Session represents a `screen` session. +type Session struct { + // command is the original command used to spawn the session. + command *Command + // cond broadcasts session changes and any accompanying errors. + cond *sync.Cond + // configFile is the location of the screen configuration file. + configFile string + // error hold any error that occurred during a state change. It is not safe + // to access outside of cond.L. + error error + // execer is used to spawn the session and ready commands. + execer Execer + // id holds the id of the session for both creating and attaching. This is + // generated uniquely for each session (rather than using the ID provided by + // the client) because without control of the daemon we do not have its PID + // and without the PID screen will do partial matching. Enforcing a UUID + // should guarantee we match on the right session. + id string + // mutex prevents concurrent attaches to the session. This is necessary since + // screen will happily spawn two separate sessions with the same name if + // multiple attaches happen in a close enough interval. We are not able to + // control the daemon ourselves to prevent this because the daemon will spawn + // with a hardcoded 24x80 size which results in confusing padding above the + // prompt once the attach comes in and resizes. + mutex sync.Mutex + // options holds options for configuring the session. + options *Options + // socketsDir is the location of the directory where screen should put its + // sockets. + socketsDir string + // state holds the current session state. It is not safe to access this + // outside of cond.L. + state State + // timer will close the session when it expires. The timer will be reset as + // long as there are active connections. + timer *time.Timer +} + +const attachTimeout = 30 * time.Second + +// NewSession sets up a new session. Any errors with starting are returned on +// Attach(). The session will close itself if nothing is attached for the +// duration of the session timeout. +func NewSession(command *Command, execer Execer, options *Options) *Session { + tempdir := filepath.Join(os.TempDir(), "coder-screen") + s := &Session{ + command: command, + cond: sync.NewCond(&sync.Mutex{}), + configFile: filepath.Join(tempdir, "config"), + execer: execer, + id: uuid.NewString(), + options: options, + state: StateStarting, + socketsDir: filepath.Join(tempdir, "sockets"), + } + go s.lifecycle() + return s +} + +// lifecycle manages the lifecycle of the session. +func (s *Session) lifecycle() { + err := s.ensureSettings() + if err != nil { + s.setState(StateDone, xerrors.Errorf("ensure settings: %w", err)) + return + } + + // The initial timeout for starting up is set here and will probably be far + // shorter than the session timeout in most cases. It should be at least long + // enough for the first screen attach to be able to start up the daemon. + s.timer = time.AfterFunc(attachTimeout, func() { + s.Close("session timeout") + }) + + s.setState(StateReady, nil) + + // Handle the close event by asking screen to quit the session. We have no + // way of knowing when the daemon process dies so the Go side will not get + // cleaned up until the timeout if the process gets killed externally (for + // example via `exit`). + s.WaitForState(StateClosing) + s.timer.Stop() + // If the command errors that the session is already gone that is fine. + err = s.sendCommand(context.Background(), "quit", []string{"No screen session found"}) + if err != nil { + flog.Error("failed to kill session %s: %v", s.id, err) + } else { + err = xerrors.Errorf(fmt.Sprintf("session is done")) + } + s.setState(StateDone, err) +} + +// sendCommand runs a screen command against a session. If the command fails +// with an error matching anything in successErrors it will be considered a +// success state (for example "no session" when quitting). The command will be +// retried until successful, the timeout is reached, or the context ends (in +// which case the context error is returned). +func (s *Session) sendCommand(ctx context.Context, command string, successErrors []string) error { + ctx, cancel := context.WithTimeout(ctx, attachTimeout) + defer cancel() + run := func() (bool, error) { + process, err := s.execer.Start(ctx, Command{ + Command: "screen", + Args: []string{"-S", s.id, "-X", command}, + UID: s.command.UID, + GID: s.command.GID, + Env: append(s.command.Env, "SCREENDIR="+s.socketsDir), + }) + if err != nil { + return true, err + } + stdout := captureStdout(process) + err = process.Wait() + // Try the context error in case it canceled while we waited. + if ctx.Err() != nil { + return true, ctx.Err() + } + if err != nil { + details := <-stdout + for _, se := range successErrors { + if strings.Contains(details, se) { + return true, nil + } + } + } + // Sometimes a command will fail without any error output whatsoever but + // will succeed later so all we can do is keep trying. + return err == nil, nil + } + + // Run immediately. + if done, err := run(); done { + return err + } + + // Then run on a timer. + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if done, err := run(); done { + return err + } + } + } +} + +// Attach attaches to the session, waits for the attach to complete, then +// returns the attached process. +func (s *Session) Attach(ctx context.Context) (Process, error) { + // We need to do this while behind the mutex to ensure another attach does not + // come in and spawn a duplicate session. + s.mutex.Lock() + defer s.mutex.Unlock() + + state, err := s.WaitForState(StateReady) + switch state { + case StateClosing: + return nil, err + case StateDone: + return nil, err + } + + // Abort the heartbeat when the session closes. + ctx, cancel := context.WithCancel(ctx) + go func() { + defer cancel() + s.waitForStateOrContext(ctx, StateClosing) + }() + + go s.heartbeat(ctx) + + // -S is for setting the session's name. + // -x allows attaching to an already attached session. + // -RR reattaches to the daemon or creates the session daemon if missing. + // -q disables the "New screen..." message that appears for five seconds when + // creating a new session with -RR. + // -c is the flag for the config file. + process, err := s.execer.Start(ctx, Command{ + Command: "screen", + Args: append([]string{"-S", s.id, "-xRRqc", s.configFile, s.command.Command}, s.command.Args...), + TTY: s.command.TTY, + Rows: s.command.Rows, + Cols: s.command.Cols, + Stdin: s.command.Stdin, + UID: s.command.UID, + GID: s.command.GID, + Env: append(s.command.Env, "SCREENDIR="+s.socketsDir), + WorkingDir: s.command.WorkingDir, + }) + if err != nil { + cancel() + return nil, err + } + + // Version seems to be the only command without a side effect so use it to + // wait for the session to come up. + err = s.sendCommand(ctx, "version", nil) + if err != nil { + cancel() + return nil, err + } + + return process, err +} + +// heartbeat keeps the session alive while the provided context is not done. +func (s *Session) heartbeat(ctx context.Context) { + // We just connected so reset the timer now in case it is near the end. + s.timer.Reset(s.options.SessionTimeout) + + // Reset when the connection closes to ensure the session stays up for the + // full timeout. + defer s.timer.Reset(s.options.SessionTimeout) + + heartbeat := time.NewTicker(s.options.SessionTimeout / 2) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + } + // The goroutine that cancels the heartbeat on a close state change might + // not run before the next heartbeat which means the heartbeat will start + // the timer again. + state, _ := s.WaitForState(StateReady) + if state > StateReady { + return + } + s.timer.Reset(s.options.SessionTimeout) + } +} + +// Wait waits for the session to close. The underlying process might still be +// exiting. +func (s *Session) Wait() { + s.WaitForState(StateClosing) +} + +// Close attempts to gracefully kill the session's underlying process then waits +// for the process to exit. If the session does not exit in a timely manner it +// forcefully kills the process. +func (s *Session) Close(reason string) { + s.setState(StateClosing, xerrors.Errorf(fmt.Sprintf("session is closing: %s", reason))) + s.WaitForState(StateDone) +} + +// ensureSettings writes config settings and creates the socket directory. +func (s *Session) ensureSettings() error { + settings := []string{ + // Tell screen not to handle motion for xterm* terminals which allows + // scrolling the terminal via the mouse wheel or scroll bar (by default + // screen uses it to cycle through the command history). There does not + // seem to be a way to make screen itself scroll on mouse wheel. tmux can + // do it but then there is no scroll bar and it kicks you into copy mode + // where keys stop working until you exit copy mode which seems like it + // could be confusing. + "termcapinfo xterm* ti@:te@", + // Enable alternate screen emulation otherwise applications get rendered in + // the current window which wipes out visible output resulting in missing + // output when scrolling back with the mouse wheel (copy mode still works + // since that is screen itself scrolling). + "altscreen on", + // Remap the control key to C-s since C-a may be used in applications. C-s + // cannot actually be used anyway since by default it will pause and C-q to + // resume will just kill the browser window. We may not want people using + // the control key anyway since it will not be obvious they are in screen + // and doing things like switching windows makes mouse wheel scroll wonky + // due to the terminal doing the scrolling rather than screen itself (but + // again copy mode will work just fine). + "escape ^Ss", + } + + dir := filepath.Join(os.TempDir(), "coder-screen") + config := filepath.Join(dir, "config") + socketdir := filepath.Join(dir, "sockets") + + err := os.MkdirAll(socketdir, 0o700) + if err != nil { + return err + } + + return os.WriteFile(config, []byte(strings.Join(settings, "\n")), 0o644) +} + +// setState sets and broadcasts the provided state if it is greater than the +// current state and the error if one has not already been set. +func (s *Session) setState(state State, err error) { + s.cond.L.Lock() + defer s.cond.L.Unlock() + // Cannot regress states (for example trying to close after the process is + // done should leave us in the done state and not the closing state). + if state <= s.state { + return + } + // Keep the first error we get. + if s.error == nil { + s.error = err + } + s.state = state + s.cond.Broadcast() +} + +// WaitForState blocks until the state or a greater one is reached. +func (s *Session) WaitForState(state State) (State, error) { + s.cond.L.Lock() + defer s.cond.L.Unlock() + for state > s.state { + s.cond.Wait() + } + return s.state, s.error +} + +// waitForStateOrContext blocks until the state or a greater one is reached or +// the provided context ends. If the context ends all goroutines will be woken. +func (s *Session) waitForStateOrContext(ctx context.Context, state State) { + go func() { + // Wake up when the context ends. + defer s.cond.Broadcast() + <-ctx.Done() + }() + s.cond.L.Lock() + defer s.cond.L.Unlock() + for ctx.Err() == nil && state > s.state { + s.cond.Wait() + } +} + +// captureStdout captures the first line of stdout. Screen emits errors to +// stdout so this allows logging extra context beyond the exit code. +func captureStdout(process Process) <-chan string { + stdout := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(process.Stdout()) + if scanner.Scan() { + stdout <- scanner.Text() + } else { + stdout <- "no further details" + } + }() + return stdout +} diff --git a/tty_test.go b/tty_test.go new file mode 100644 index 0000000..684f280 --- /dev/null +++ b/tty_test.go @@ -0,0 +1,275 @@ +package wsep + +import ( + "bufio" + "context" + "fmt" + "math/rand" + "regexp" + "strings" + "sync" + "testing" + "time" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/google/uuid" +) + +func TestTTY(t *testing.T) { + t.Parallel() + + // Run some output in a new session. + server := newServer(t) + ctx, command := newSession(t) + command.ID = "" // No ID so we do not start a reconnectable session. + process1, _ := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, process1) + assert.True(t, "find initial output", checkStdout(t, process1, expected, []string{})) + + // Connect to the same session. There should not be shared output since + // these end up being separate sessions due to the lack of an ID. + process2, _ := connect(ctx, t, command, server, nil, "") + unexpected := expected + expected = writeUnique(t, process2) + assert.True(t, "find new session output", checkStdout(t, process2, expected, unexpected)) +} + +func TestReconnectTTY(t *testing.T) { + t.Run("NoSize", func(t *testing.T) { + server := newServer(t) + ctx, command := newSession(t) + command.Rows = 0 + command.Cols = 0 + ps1, _ := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, ps1) + assert.True(t, "find initial output", checkStdout(t, ps1, expected, []string{})) + + ps2, _ := connect(ctx, t, command, server, nil, "") + assert.True(t, "find reconnected output", checkStdout(t, ps2, expected, []string{})) + }) + + t.Run("DeprecatedServe", func(t *testing.T) { + // Do something in the first session. + ctx, command := newSession(t) + process1, _ := connect(ctx, t, command, nil, nil, "") + expected := writeUnique(t, process1) + assert.True(t, "find initial output", checkStdout(t, process1, expected, []string{})) + + // Connect to the same session. Should see the same output. + process2, _ := connect(ctx, t, command, nil, nil, "") + assert.True(t, "find reconnected output", checkStdout(t, process2, expected, []string{})) + }) + + t.Run("NoScreen", func(t *testing.T) { + t.Setenv("PATH", "/bin") + + // Run some output in a new session. + server := newServer(t) + ctx, command := newSession(t) + process1, _ := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, process1) + assert.True(t, "find initial output", checkStdout(t, process1, expected, []string{})) + + // Connect to the same session. There should not be shared output since + // these end up being separate sessions due to the lack of screen. + process2, _ := connect(ctx, t, command, server, nil, "") + unexpected := expected + expected = writeUnique(t, process2) + assert.True(t, "find new session output", checkStdout(t, process2, expected, unexpected)) + }) + + t.Run("Regular", func(t *testing.T) { + t.Parallel() + + // Run some output in a new session. + server := newServer(t) + ctx, command := newSession(t) + process1, disconnect1 := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, process1) + assert.True(t, "find initial output", checkStdout(t, process1, expected, []string{})) + + // Reconnect and sleep; the inactivity timeout should not trigger since we + // were not disconnected during the timeout. + disconnect1() + process2, disconnect2 := connect(ctx, t, command, server, nil, "") + time.Sleep(time.Second) + expected = append(expected, writeUnique(t, process2)...) + assert.True(t, "find reconnected output", checkStdout(t, process2, expected, []string{})) + + // Make a simultaneously active connection. + process3, disconnect3 := connect(ctx, t, command, server, &Options{ + // Divide the time to test that the heartbeat keeps it open through + // multiple intervals. + SessionTimeout: time.Second / 4, + }, "") + + // Disconnect the previous connection and wait for inactivity. The session + // should stay up because of the second connection. + disconnect2() + time.Sleep(time.Second) + expected = append(expected, writeUnique(t, process3)...) + assert.True(t, "find second connection output", checkStdout(t, process3, expected, []string{})) + + // Disconnect the last connection and wait for inactivity. The next + // connection should start a new session so we should only see new output + // and not any output from the old session. + disconnect3() + time.Sleep(time.Second) + process4, _ := connect(ctx, t, command, server, nil, "") + unexpected := expected + expected = writeUnique(t, process4) + assert.True(t, "find new session output", checkStdout(t, process4, expected, unexpected)) + }) + + t.Run("Alternate", func(t *testing.T) { + t.Parallel() + + // Run an application that enters the alternate screen. + server := newServer(t) + ctx, command := newSession(t) + process1, disconnect1 := connect(ctx, t, command, server, nil, "") + write(t, process1, "./ci/alt.sh") + assert.True(t, "find alt screen", checkStdout(t, process1, []string{"./ci/alt.sh", "ALT SCREEN"}, []string{})) + + // Reconnect; the application should redraw. We should have only the + // application output and not the command that spawned the application. + disconnect1() + process2, disconnect2 := connect(ctx, t, command, server, nil, "") + assert.True(t, "find reconnected alt screen", checkStdout(t, process2, []string{"ALT SCREEN"}, []string{"./ci/alt.sh"})) + + // Exit the application and reconnect. Should now be in a regular shell. + write(t, process2, "q") + disconnect2() + process3, _ := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, process3) + assert.True(t, "find shell output", checkStdout(t, process3, expected, []string{})) + }) + + t.Run("Simultaneous", func(t *testing.T) { + t.Parallel() + + server := newServer(t) + + // Try connecting a bunch of sessions at once. + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, command := newSession(t) + process1, disconnect1 := connect(ctx, t, command, server, nil, "") + expected := writeUnique(t, process1) + assert.True(t, "find initial output", checkStdout(t, process1, expected, []string{})) + + n := rand.Intn(1000) + time.Sleep(time.Duration(n) * time.Millisecond) + disconnect1() + process2, _ := connect(ctx, t, command, server, nil, "") + expected = append(expected, writeUnique(t, process2)...) + assert.True(t, "find reconnected output", checkStdout(t, process2, expected, []string{})) + }() + } + wg.Wait() + }) +} + +// newServer returns a new wsep server. +func newServer(t *testing.T) *Server { + server := NewServer() + t.Cleanup(func() { + server.Close() + assert.Equal(t, "no leaked sessions", 0, server.SessionCount()) + }) + return server +} + +// newSession returns a command for starting/attaching to a session with a +// context for timing out. +func newSession(t *testing.T) (context.Context, Command) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + command := Command{ + ID: uuid.NewString(), + Command: "sh", + TTY: true, + Stdin: true, + Cols: defaultCols, + Rows: defaultRows, + Env: []string{"TERM=xterm"}, + } + + return ctx, command +} + +// connect connects to a wsep server and runs the provided command. +func connect(ctx context.Context, t *testing.T, command Command, wsepServer *Server, options *Options, error string) (Process, func()) { + if options == nil { + options = &Options{SessionTimeout: time.Second} + } + ws, server := mockConn(ctx, t, wsepServer, options) + t.Cleanup(func() { + server.Close() + }) + + process, err := RemoteExecer(ws).Start(ctx, command) + if error != "" { + assert.True(t, fmt.Sprintf("%s contains %s", err.Error(), error), strings.Contains(err.Error(), error)) + } else { + assert.Success(t, "start sh", err) + } + + return process, func() { + process.Close() + server.Close() + } +} + +// writeUnique writes some unique output to the shell process and returns the +// expected output. +func writeUnique(t *testing.T, process Process) []string { + n := rand.Intn(1000000) + echoCmd := fmt.Sprintf("echo test:$((%d+%d))", n, n) + write(t, process, echoCmd) + return []string{echoCmd, fmt.Sprintf("test:%d", n+n)} +} + +// write writes the provided input followed by a newline to the shell process. +func write(t *testing.T, process Process, input string) { + _, err := process.Stdin().Write([]byte(input + "\n")) + assert.Success(t, "write to stdin", err) +} + +const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var re = regexp.MustCompile(ansi) + +// checkStdout ensures that expected is in the stdout in the specified order. +// On the way if anything in unexpected comes up return false. Return once +// everything in expected has been found or EOF. +func checkStdout(t *testing.T, process Process, expected, unexpected []string) bool { + t.Helper() + i := 0 + t.Logf("expected: %s unexpected: %s", expected, unexpected) + scanner := bufio.NewScanner(process.Stdout()) + for scanner.Scan() { + line := scanner.Text() + t.Logf("bash tty stdout = %s", re.ReplaceAllString(line, "")) + for _, str := range unexpected { + if strings.Contains(line, str) { + t.Logf("contains unexpected line %s", line) + return false + } + } + if strings.Contains(line, expected[i]) { + t.Logf("contains expected line %s", line) + i = i + 1 + } + if i == len(expected) { + t.Logf("got all expected values from stdout") + return true + } + } + t.Logf("reached end of stdout without seeing all expected values") + return false +}