diff --git a/extensions/composer/go.mod b/extensions/composer/go.mod index 234d1ceb..4eed2165 100644 --- a/extensions/composer/go.mod +++ b/extensions/composer/go.mod @@ -30,6 +30,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/creack/pty v1.1.24 + require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/extensions/composer/go.sum b/extensions/composer/go.sum index 77a6dc74..3c2236de 100644 --- a/extensions/composer/go.sum +++ b/extensions/composer/go.sum @@ -20,6 +20,8 @@ github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+Q github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo= github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/extensions/composer/plugins.go b/extensions/composer/plugins.go index 7441580c..eea5d269 100644 --- a/extensions/composer/plugins.go +++ b/extensions/composer/plugins.go @@ -25,4 +25,5 @@ import ( _ "github.com/tetratelabs/built-on-envoy/extensions/composer/saml/embedded" // SAML SP plugin. _ "github.com/tetratelabs/built-on-envoy/extensions/composer/token-exchange/embedded" // OAuth2 Token Exchange plugin. _ "github.com/tetratelabs/built-on-envoy/extensions/composer/waf/embedded" // WAF plugin. + _ "github.com/tetratelabs/built-on-envoy/extensions/composer/web-terminal/embedded" // Web Terminal plugin. ) diff --git a/extensions/composer/web-terminal/config.schema.json b/extensions/composer/web-terminal/config.schema.json new file mode 100644 index 00000000..b3ade8cc --- /dev/null +++ b/extensions/composer/web-terminal/config.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/tetratelabs/built-on-envoy/extensions/composer/web-terminal/config.schema.json", + "title": "Web Terminal Configuration", + "description": "Configuration for the web-terminal extension. Streams a PTY to the browser over SSE and takes input via POST. A browser terminal is remote code execution by design: set a token unless the listener is otherwise protected.", + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "Executable to run inside the PTY (e.g. \"/bin/bash\").", + "minLength": 1, + "default": "/bin/bash" + }, + "args": { + "type": "array", + "description": "Arguments passed to the command.", + "items": { "type": "string" } + }, + "writable": { + "type": "boolean", + "description": "Whether client keystrokes are written to the PTY. Set to false for a read-only terminal.", + "default": true + }, + "serve_frontend": { + "type": "boolean", + "description": "Whether to serve the bundled xterm.js client at \"/\". Disabled by default; the /stream, /input and /resize endpoints are the primary interface. Set to true to serve the bundled client.", + "default": false + } + } +} diff --git a/extensions/composer/web-terminal/config_schema_test.go b/extensions/composer/web-terminal/config_schema_test.go new file mode 100644 index 00000000..9af0ce54 --- /dev/null +++ b/extensions/composer/web-terminal/config_schema_test.go @@ -0,0 +1,32 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package webterminal + +import ( + "testing" + + internaltesting "github.com/tetratelabs/built-on-envoy/extensions/composer/internal/testing" +) + +func TestConfigSchema(t *testing.T) { + t.Run("empty config is valid (defaults apply)", func(t *testing.T) { + internaltesting.AssertSchemaValid(t, "config.schema.json", `{}`) + }) + t.Run("full config", func(t *testing.T) { + internaltesting.AssertSchemaValid(t, "config.schema.json", `{ + "command": "/bin/bash", + "args": ["-l"], + "writable": false, + "serve_frontend": true + }`) + }) + t.Run("empty command is invalid", func(t *testing.T) { + internaltesting.AssertSchemaInvalid(t, "config.schema.json", `{"command": ""}`) + }) + t.Run("unknown property is invalid", func(t *testing.T) { + internaltesting.AssertSchemaInvalid(t, "config.schema.json", `{"nope": true}`) + }) +} diff --git a/extensions/composer/web-terminal/embedded/host.go b/extensions/composer/web-terminal/embedded/host.go new file mode 100644 index 00000000..c5f33d7c --- /dev/null +++ b/extensions/composer/web-terminal/embedded/host.go @@ -0,0 +1,18 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Package host contains the code to register the plugin with the host binary. +package host + +import ( + sdk "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go" + + impl "github.com/tetratelabs/built-on-envoy/extensions/composer/web-terminal" +) + +// Register this plugin to the host registry if this is built into the host binary. +func init() { + sdk.RegisterHttpFilterConfigFactories(impl.WellKnownHttpFilterConfigFactories()) +} diff --git a/extensions/composer/web-terminal/frontend.go b/extensions/composer/web-terminal/frontend.go new file mode 100644 index 00000000..f1f1416a --- /dev/null +++ b/extensions/composer/web-terminal/frontend.go @@ -0,0 +1,19 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package webterminal + +import _ "embed" + +//go:embed www/index.html +var indexHTML []byte + +func (f *terminalFilter) serveFrontend() { + f.handle.SendResponseHeaders([][2]string{ + {":status", "200"}, + {"content-type", "text/html; charset=utf-8"}, + }, false) + f.handle.SendResponseData(indexHTML, true) +} diff --git a/extensions/composer/web-terminal/manifest.yaml b/extensions/composer/web-terminal/manifest.yaml new file mode 100644 index 00000000..c1e2e116 --- /dev/null +++ b/extensions/composer/web-terminal/manifest.yaml @@ -0,0 +1,70 @@ +# Copyright Built On Envoy +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +name: web-terminal +parent: composer +categories: + - Misc +author: Anton Kanugalawattage +description: Host an interactive terminal inside Envoy, streamed over SSE. +longDescription: | + Hosts an interactive shell (default `/bin/bash`) inside Envoy and exposes it to + the browser without any backend server. The PTY runs in the Envoy process; its + output is streamed to the client over a never-closing HTTP response as + Server-Sent Events, and input and resize events come back as short POST + requests correlated by a session id. Because it runs as a composer HTTP filter, + no upstream cluster or extra port is required — Envoy hosts the session directly. + + ## Protocol + + The transport is plain HTTP and client-agnostic — any client can implement it: + + - `GET /stream?sid=&cols=&rows=` + A `text/event-stream` where each `data:` event is a base64-encoded chunk of + PTY output. Opening the stream spawns the PTY for `sid`; closing it (client + disconnect) tears the PTY down. A final `event: exit` is sent on shell exit. + - `POST /input?sid=` + The request body is written to the PTY. + - `POST /resize?sid=&cols=&rows=` + Resizes the PTY. + + ## Frontend + + A small `xterm.js` client is bundled but is not served by default — the + endpoints above are the primary interface, so bring your own client (e.g. an + application's own terminal view). Set `serve_frontend: true` to serve the + bundled client at `/`. + + ## Security + + A browser terminal is remote code execution by design, and this extension has + no built-in authentication — restrict access to the listener (network policy or + an auth filter earlier in the chain) before exposing it. +type: go +tags: + - go + - http + - terminal + - pty + - sse +license: Apache-2.0 +examples: + - title: API only (default), bring your own client + description: | + Drive the /stream, /input and /resize endpoints from your own client. The + bundled frontend is not served. + code: | + boe run --extension web-terminal --config '{ + "command": "/bin/bash" + }' + - title: Serve the bundled xterm.js client + description: | + Enable the bundled frontend, then open it in the browser at + http://localhost:10000/ + code: | + boe run --extension web-terminal --config '{ + "command": "/bin/bash", + "serve_frontend": true + }' diff --git a/extensions/composer/web-terminal/plugin.go b/extensions/composer/web-terminal/plugin.go new file mode 100644 index 00000000..78d6b178 --- /dev/null +++ b/extensions/composer/web-terminal/plugin.go @@ -0,0 +1,247 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Package webterminal hosts an interactive terminal inside Envoy: it streams a +// PTY to the browser over Server-Sent Events and takes input via short POSTs. +// See the extension manifest for the protocol and usage. +package webterminal + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strconv" + "sync/atomic" + + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared" +) + +const extensionName = "web-terminal" + +type terminalConfig struct { + Command string `json:"command"` + Args []string `json:"args"` + Writable bool `json:"writable"` + ServeFrontend bool `json:"serve_frontend"` +} + +func parseConfig(raw []byte) (*terminalConfig, error) { + cfg := &terminalConfig{Command: "/bin/bash", Writable: true} + if len(raw) > 0 { + if err := json.Unmarshal(raw, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + } + if cfg.Command == "" { + return nil, fmt.Errorf("invalid config: command must not be empty") + } + return cfg, nil +} + +type action int + +const ( + actionNone action = iota + actionInput +) + +type terminalFilter struct { + shared.EmptyHttpFilter + cfg *terminalConfig + handle shared.HttpFilterHandle + reg *registry + + act action + sid string + input []byte + session *ptySession + closed atomic.Bool +} + +func (f *terminalFilter) OnRequestHeaders(headers shared.HeaderMap, endOfStream bool) shared.HeadersStatus { + u, err := url.Parse(headers.GetOne(":path").ToUnsafeString()) + if err != nil { + f.sendPlain(400, "Bad Request", "web_terminal_bad_path") + return shared.HeadersStatusStopAllAndBuffer + } + method := headers.GetOne(":method").ToUnsafeString() + q := u.Query() + + switch u.Path { + case "/", "/index.html": + switch { + case !f.cfg.ServeFrontend: + f.sendPlain(404, "Not Found", "web_terminal_frontend_disabled") + case method != "GET": + f.sendPlain(405, "Method Not Allowed", "web_terminal_method") + default: + f.serveFrontend() + } + return shared.HeadersStatusStopAllAndBuffer + + case "/stream": + if method != "GET" { + f.sendPlain(405, "Method Not Allowed", "web_terminal_method") + return shared.HeadersStatusStopAllAndBuffer + } + return f.startStream(q) + + case "/input": + if method != "POST" { + f.sendPlain(405, "Method Not Allowed", "web_terminal_method") + return shared.HeadersStatusStopAllAndBuffer + } + f.sid = q.Get("sid") + f.act = actionInput + if endOfStream { + f.sendPlain(204, "", "web_terminal_input") + return shared.HeadersStatusStopAllAndBuffer + } + return shared.HeadersStatusStop + + case "/resize": + if method != "POST" { + f.sendPlain(405, "Method Not Allowed", "web_terminal_method") + return shared.HeadersStatusStopAllAndBuffer + } + if sess, ok := f.reg.get(q.Get("sid")); ok { + sess.resize(parseDim(q.Get("cols"), 0), parseDim(q.Get("rows"), 0)) + f.sendPlain(204, "", "web_terminal_resize") + } else { + f.sendPlain(404, "No such session", "web_terminal_no_session") + } + return shared.HeadersStatusStopAllAndBuffer + + default: + f.sendPlain(404, "Not Found", "web_terminal_not_found") + return shared.HeadersStatusStopAllAndBuffer + } +} + +func (f *terminalFilter) startStream(q url.Values) shared.HeadersStatus { + sid := q.Get("sid") + if sid == "" { + f.sendPlain(400, "missing sid", "web_terminal_missing_sid") + return shared.HeadersStatusStopAllAndBuffer + } + sess, err := newPTYSession(f.cfg, parseDim(q.Get("cols"), 80), parseDim(q.Get("rows"), 24)) + if err != nil { + f.handle.Log(shared.LogLevelError, "web-terminal: failed to start pty: %s", err.Error()) + f.sendPlain(500, "failed to start terminal", "web_terminal_pty_error") + return shared.HeadersStatusStopAllAndBuffer + } + f.reg.set(sid, sess) + f.sid = sid + f.session = sess + + f.handle.SendResponseHeaders([][2]string{ + {":status", "200"}, + {"content-type", "text/event-stream"}, + {"cache-control", "no-cache"}, + {"x-accel-buffering", "no"}, + }, false) + + sched := f.handle.GetScheduler() + go sess.pump( + func(chunk []byte) { + frame := []byte("data: " + base64.StdEncoding.EncodeToString(chunk) + "\n\n") + sched.Schedule(func() { + if !f.closed.Load() { + f.handle.SendResponseData(frame, false) + } + }) + }, + func() { + sched.Schedule(func() { + // closed guards against writing after OnStreamComplete releases the handle. + if !f.closed.Swap(true) { + f.handle.SendResponseData([]byte("event: exit\ndata: \n\n"), true) + } + }) + }, + ) + return shared.HeadersStatusStopAllAndBuffer +} + +func (f *terminalFilter) OnRequestBody(body shared.BodyBuffer, endOfStream bool) shared.BodyStatus { + if f.act != actionInput { + return shared.BodyStatusContinue + } + if body != nil { + for _, chunk := range body.GetChunks() { + f.input = append(f.input, chunk.ToUnsafeBytes()...) + } + } + if !endOfStream { + return shared.BodyStatusStopAndBuffer + } + + sess, ok := f.reg.get(f.sid) + if !ok { + f.sendPlain(404, "No such session", "web_terminal_no_session") + return shared.BodyStatusStopAndBuffer + } + if f.cfg.Writable { + if err := sess.write(f.input); err != nil { + f.sendPlain(500, "write failed", "web_terminal_write_error") + return shared.BodyStatusStopAndBuffer + } + } + f.sendPlain(204, "", "web_terminal_input") + return shared.BodyStatusStopAndBuffer +} + +func (f *terminalFilter) OnStreamComplete() { + f.closed.Store(true) + if f.session != nil { + f.session.close() + f.reg.remove(f.sid) + } +} + +func (f *terminalFilter) sendPlain(status uint32, body, detail string) { + f.handle.SendLocalResponse(status, [][2]string{{"content-type", "text/plain"}}, []byte(body), detail) +} + +func parseDim(s string, def uint16) uint16 { + n, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return def + } + return uint16(n) +} + +type filterFactory struct { + shared.EmptyHttpFilterFactory + cfg *terminalConfig + reg *registry +} + +func (f *filterFactory) Create(handle shared.HttpFilterHandle) shared.HttpFilter { + return &terminalFilter{cfg: f.cfg, handle: handle, reg: f.reg} +} + +type configFactory struct { + shared.EmptyHttpFilterConfigFactory +} + +func (f *configFactory) Create(handle shared.HttpFilterConfigHandle, unparsed []byte) (shared.HttpFilterFactory, error) { + cfg, err := parseConfig(unparsed) + if err != nil { + handle.Log(shared.LogLevelError, "web-terminal: %s", err.Error()) + return nil, err + } + return &filterFactory{cfg: cfg, reg: newRegistry()}, nil +} + +func (f *configFactory) CreatePerRoute(_ []byte) (any, error) { return nil, nil } + +// WellKnownHttpFilterConfigFactories returns the factories this plugin registers with the composer host. +func WellKnownHttpFilterConfigFactories() map[string]shared.HttpFilterConfigFactory { //nolint:revive + return map[string]shared.HttpFilterConfigFactory{ + extensionName: &configFactory{}, + } +} diff --git a/extensions/composer/web-terminal/plugin_test.go b/extensions/composer/web-terminal/plugin_test.go new file mode 100644 index 00000000..68cda05a --- /dev/null +++ b/extensions/composer/web-terminal/plugin_test.go @@ -0,0 +1,264 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package webterminal + +import ( + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared" + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared/fake" + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// syncScheduler runs scheduled functions inline so tests observe SendResponseData +// deterministically. +type syncScheduler struct{} + +func (syncScheduler) Schedule(f func()) { f() } + +type frame struct { + data []byte + end bool +} + +func newHandle(ctrl *gomock.Controller) *mocks.MockHttpFilterHandle { + h := mocks.NewMockHttpFilterHandle(ctrl) + h.EXPECT().Log(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + return h +} + +func headerMap(method, path string) shared.HeaderMap { + return fake.NewFakeHeaderMap(map[string][]string{":method": {method}, ":path": {path}}) +} + +func TestParseConfig(t *testing.T) { + cfg, err := parseConfig(nil) + require.NoError(t, err) + require.Equal(t, "/bin/bash", cfg.Command) + require.True(t, cfg.Writable) + require.False(t, cfg.ServeFrontend) // frontend off by default + + cfg, err = parseConfig([]byte(`{"command":"sh","args":["-c","x"],"writable":false,"serve_frontend":true}`)) + require.NoError(t, err) + require.Equal(t, "sh", cfg.Command) + require.Equal(t, []string{"-c", "x"}, cfg.Args) + require.False(t, cfg.Writable) + require.True(t, cfg.ServeFrontend) + + _, err = parseConfig([]byte(`{`)) + require.Error(t, err) + _, err = parseConfig([]byte(`{"command":""}`)) + require.Error(t, err) +} + +func TestFrontendServedWhenEnabled(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + var body []byte + h.EXPECT().SendResponseHeaders(gomock.Any(), false) + h.EXPECT().SendResponseData(gomock.Any(), true).Do(func(b []byte, _ bool) { body = b }) + + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true, ServeFrontend: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, f.OnRequestHeaders(headerMap("GET", "/"), true)) + require.Contains(t, string(body), "xterm") +} + +func TestFrontendDisabledByDefault(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + h.EXPECT().SendLocalResponse(uint32(404), gomock.Any(), gomock.Any(), "web_terminal_frontend_disabled") + + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, f.OnRequestHeaders(headerMap("GET", "/"), true)) +} + +func TestFrontendWrongMethod(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + h.EXPECT().SendLocalResponse(uint32(405), gomock.Any(), gomock.Any(), "web_terminal_method") + + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true, ServeFrontend: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, f.OnRequestHeaders(headerMap("POST", "/"), true)) +} + +func TestRoutingErrors(t *testing.T) { + tests := []struct { + name string + method string + path string + wantStatus uint32 + }{ + {"unknown path", "GET", "/nope", 404}, + {"stream wrong method", "POST", "/stream?sid=a", 405}, + {"stream missing sid", "GET", "/stream", 400}, + {"input wrong method", "GET", "/input?sid=a", 405}, + {"resize unknown session", "POST", "/resize?sid=ghost", 404}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + h.EXPECT().SendLocalResponse(tt.wantStatus, gomock.Any(), gomock.Any(), gomock.Any()) + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, f.OnRequestHeaders(headerMap(tt.method, tt.path), true)) + }) + } +} + +func TestStreamOutputAndExit(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + frames := make(chan frame, 128) + h.EXPECT().GetScheduler().Return(syncScheduler{}).AnyTimes() + h.EXPECT().SendResponseHeaders(gomock.Any(), false).AnyTimes() + h.EXPECT().SendResponseData(gomock.Any(), gomock.Any()).Do(func(b []byte, end bool) { + cp := make([]byte, len(b)) + copy(cp, b) + frames <- frame{cp, end} + }).AnyTimes() + + f := (&filterFactory{cfg: &terminalConfig{Command: "sh", Args: []string{"-c", "printf hello-world"}, Writable: true}, reg: newRegistry()}). + Create(h).(*terminalFilter) + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, + f.OnRequestHeaders(headerMap("GET", "/stream?sid=s1&cols=80&rows=24"), true)) + + var sawOutput, sawExit bool + deadline := time.After(5 * time.Second) + for !sawOutput || !sawExit { + select { + case fr := <-frames: + if fr.end { + sawExit = true + } else if strings.Contains(decodeSSE(fr.data), "hello-world") { + sawOutput = true + } + case <-deadline: + t.Fatalf("output=%v exit=%v", sawOutput, sawExit) + } + } + f.OnStreamComplete() +} + +func TestStreamInputEcho(t *testing.T) { + ctrl := gomock.NewController(t) + factory := &filterFactory{cfg: &terminalConfig{Command: "cat", Writable: true}, reg: newRegistry()} + + streamH := newHandle(ctrl) + frames := make(chan frame, 128) + streamH.EXPECT().GetScheduler().Return(syncScheduler{}).AnyTimes() + streamH.EXPECT().SendResponseHeaders(gomock.Any(), false).AnyTimes() + streamH.EXPECT().SendResponseData(gomock.Any(), gomock.Any()).Do(func(b []byte, end bool) { + cp := make([]byte, len(b)) + copy(cp, b) + frames <- frame{cp, end} + }).AnyTimes() + streamF := factory.Create(streamH).(*terminalFilter) + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, + streamF.OnRequestHeaders(headerMap("GET", "/stream?sid=s1&cols=80&rows=24"), true)) + + inputH := newHandle(ctrl) + inputH.EXPECT().SendLocalResponse(uint32(204), gomock.Any(), gomock.Any(), gomock.Any()) + inputF := factory.Create(inputH).(*terminalFilter) + require.Equal(t, shared.HeadersStatusStop, + inputF.OnRequestHeaders(headerMap("POST", "/input?sid=s1"), false)) + require.Equal(t, shared.BodyStatusStopAndBuffer, + inputF.OnRequestBody(fake.NewFakeBodyBuffer([]byte("echoed-input\n")), true)) + + deadline := time.After(5 * time.Second) + for { + select { + case fr := <-frames: + if !fr.end && strings.Contains(decodeSSE(fr.data), "echoed-input") { + streamF.OnStreamComplete() + time.Sleep(100 * time.Millisecond) + return + } + case <-deadline: + t.Fatal("did not observe echoed input on the stream") + } + } +} + +func TestInputUnknownSession(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + h.EXPECT().SendLocalResponse(uint32(404), gomock.Any(), gomock.Any(), gomock.Any()) + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStop, f.OnRequestHeaders(headerMap("POST", "/input?sid=ghost"), false)) + require.Equal(t, shared.BodyStatusStopAndBuffer, f.OnRequestBody(fake.NewFakeBodyBuffer([]byte("x")), true)) +} + +func TestResizeExistingSession(t *testing.T) { + ctrl := gomock.NewController(t) + factory := &filterFactory{cfg: &terminalConfig{Command: "cat", Writable: true}, reg: newRegistry()} + + streamH := newHandle(ctrl) + streamH.EXPECT().GetScheduler().Return(syncScheduler{}).AnyTimes() + streamH.EXPECT().SendResponseHeaders(gomock.Any(), false).AnyTimes() + streamH.EXPECT().SendResponseData(gomock.Any(), gomock.Any()).AnyTimes() + streamF := factory.Create(streamH).(*terminalFilter) + streamF.OnRequestHeaders(headerMap("GET", "/stream?sid=s1&cols=80&rows=24"), true) + t.Cleanup(streamF.OnStreamComplete) + + resizeH := newHandle(ctrl) + resizeH.EXPECT().SendLocalResponse(uint32(204), gomock.Any(), gomock.Any(), gomock.Any()) + resizeF := factory.Create(resizeH).(*terminalFilter) + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, + resizeF.OnRequestHeaders(headerMap("POST", "/resize?sid=s1&cols=120&rows=40"), true)) +} + +func TestInputEmptyBodyAcknowledged(t *testing.T) { + ctrl := gomock.NewController(t) + h := newHandle(ctrl) + h.EXPECT().SendLocalResponse(uint32(204), gomock.Any(), gomock.Any(), gomock.Any()) + f := &terminalFilter{cfg: &terminalConfig{Command: "cat", Writable: true}, handle: h, reg: newRegistry()} + require.Equal(t, shared.HeadersStatusStopAllAndBuffer, + f.OnRequestHeaders(headerMap("POST", "/input?sid=s1"), true)) +} + +func TestConfigFactory(t *testing.T) { + ctrl := gomock.NewController(t) + h := mocks.NewMockHttpFilterConfigHandle(ctrl) + h.EXPECT().Log(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + factory := &configFactory{} + ff, err := factory.Create(h, []byte(`{"command":"cat"}`)) + require.NoError(t, err) + require.NotNil(t, ff) + + _, err = factory.Create(h, []byte(`{"command":""}`)) + require.Error(t, err) + + perRoute, err := factory.CreatePerRoute([]byte(`{}`)) + require.NoError(t, err) + require.Nil(t, perRoute) +} + +func TestWellKnownHttpFilterConfigFactories(t *testing.T) { + factories := WellKnownHttpFilterConfigFactories() + require.Len(t, factories, 1) + require.Contains(t, factories, "web-terminal") +} + +func TestParseDim(t *testing.T) { + require.Equal(t, uint16(24), parseDim("", 24)) + require.Equal(t, uint16(24), parseDim("bogus", 24)) + require.Equal(t, uint16(120), parseDim("120", 24)) +} + +func decodeSSE(frame []byte) string { + s := strings.TrimSpace(strings.TrimPrefix(string(frame), "data: ")) + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(b) +} diff --git a/extensions/composer/web-terminal/session.go b/extensions/composer/web-terminal/session.go new file mode 100644 index 00000000..6349e473 --- /dev/null +++ b/extensions/composer/web-terminal/session.go @@ -0,0 +1,104 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package webterminal + +import ( + "os" + "os/exec" + "sync" + + "github.com/creack/pty" +) + +// ptySession owns a shell process running inside a PTY for one client stream. +type ptySession struct { + ptmx *os.File + cmd *exec.Cmd + once sync.Once +} + +func newPTYSession(cfg *terminalConfig, cols, rows uint16) (*ptySession, error) { + cmd := exec.Command(cfg.Command, cfg.Args...) //nolint:gosec // operator-configured, not attacker-controlled + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, err + } + if cols > 0 && rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows}) + } + return &ptySession{ptmx: ptmx, cmd: cmd}, nil +} + +func (s *ptySession) write(b []byte) error { + _, err := s.ptmx.Write(b) + return err +} + +func (s *ptySession) resize(cols, rows uint16) { + if cols > 0 && rows > 0 { + _ = pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) + } +} + +// pump forwards PTY output to onOutput until the PTY closes, then calls onClose +// once. Each chunk is a fresh copy safe to retain. +func (s *ptySession) pump(onOutput func([]byte), onClose func()) { + buf := make([]byte, 4096) + for { + n, err := s.ptmx.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + onOutput(chunk) + } + if err != nil { + onClose() + return + } + } +} + +// close releases the PTY and reaps the shell. Safe to call more than once. +func (s *ptySession) close() { + s.once.Do(func() { + _ = s.ptmx.Close() + if s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + _ = s.cmd.Wait() + }) +} + +// registry maps session id -> ptySession, shared across the stream, input, and +// resize requests (separate HTTP requests correlated by that id). +type registry struct { + mu sync.Mutex + m map[string]*ptySession +} + +func newRegistry() *registry { + return ®istry{m: make(map[string]*ptySession)} +} + +func (r *registry) set(id string, s *ptySession) { + r.mu.Lock() + defer r.mu.Unlock() + r.m[id] = s +} + +func (r *registry) get(id string) (*ptySession, bool) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.m[id] + return s, ok +} + +func (r *registry) remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.m, id) +} diff --git a/extensions/composer/web-terminal/session_test.go b/extensions/composer/web-terminal/session_test.go new file mode 100644 index 00000000..2409a9d7 --- /dev/null +++ b/extensions/composer/web-terminal/session_test.go @@ -0,0 +1,78 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package webterminal + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPTYSessionEchoAndResize(t *testing.T) { + sess, err := newPTYSession(&terminalConfig{Command: "cat", Writable: true}, 80, 24) + require.NoError(t, err) + t.Cleanup(sess.close) + + out := make(chan []byte, 64) + var mu sync.Mutex + var buf strings.Builder + go sess.pump(func(chunk []byte) { + mu.Lock() + buf.Write(chunk) + mu.Unlock() + select { + case out <- chunk: + default: + } + }, func() { close(out) }) + + sess.resize(100, 40) // must not panic + require.NoError(t, sess.write([]byte("echo-me\n"))) + + deadline := time.After(5 * time.Second) + for { + select { + case <-out: + mu.Lock() + got := buf.String() + mu.Unlock() + if strings.Contains(got, "echo-me") { + return + } + case <-deadline: + t.Fatalf("did not observe echoed input") + } + } +} + +func TestPTYSessionCloseIdempotent(t *testing.T) { + sess, err := newPTYSession(&terminalConfig{Command: "cat"}, 80, 24) + require.NoError(t, err) + sess.close() + sess.close() // must be safe to call again +} + +func TestRegistry(t *testing.T) { + r := newRegistry() + sess, err := newPTYSession(&terminalConfig{Command: "cat"}, 80, 24) + require.NoError(t, err) + t.Cleanup(sess.close) + + _, ok := r.get("a") + require.False(t, ok) + + r.set("a", sess) + got, ok := r.get("a") + require.True(t, ok) + require.Same(t, sess, got) + + r.remove("a") + _, ok = r.get("a") + require.False(t, ok) +} diff --git a/extensions/composer/web-terminal/standalone/main.go b/extensions/composer/web-terminal/standalone/main.go new file mode 100644 index 00000000..cd3084d4 --- /dev/null +++ b/extensions/composer/web-terminal/standalone/main.go @@ -0,0 +1,19 @@ +// Copyright Built On Envoy +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Package main is the entry point for the standalone version of the web-terminal extension. +package main + +import ( + shared "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared" + + impl "github.com/tetratelabs/built-on-envoy/extensions/composer/web-terminal" +) + +// WellKnownHttpFilterConfigFactories is the plugin entry point when running it as an +// independently loaded composer plugin. +func WellKnownHttpFilterConfigFactories() map[string]shared.HttpFilterConfigFactory { //nolint:revive + return impl.WellKnownHttpFilterConfigFactories() +} diff --git a/extensions/composer/web-terminal/www/index.html b/extensions/composer/web-terminal/www/index.html new file mode 100644 index 00000000..d7edc03f --- /dev/null +++ b/extensions/composer/web-terminal/www/index.html @@ -0,0 +1,72 @@ + + + + + + + web-terminal + + + + +
+ + + + + diff --git a/ui/schemas/web-terminal.json b/ui/schemas/web-terminal.json new file mode 100644 index 00000000..b3ade8cc --- /dev/null +++ b/ui/schemas/web-terminal.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/tetratelabs/built-on-envoy/extensions/composer/web-terminal/config.schema.json", + "title": "Web Terminal Configuration", + "description": "Configuration for the web-terminal extension. Streams a PTY to the browser over SSE and takes input via POST. A browser terminal is remote code execution by design: set a token unless the listener is otherwise protected.", + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "Executable to run inside the PTY (e.g. \"/bin/bash\").", + "minLength": 1, + "default": "/bin/bash" + }, + "args": { + "type": "array", + "description": "Arguments passed to the command.", + "items": { "type": "string" } + }, + "writable": { + "type": "boolean", + "description": "Whether client keystrokes are written to the PTY. Set to false for a read-only terminal.", + "default": true + }, + "serve_frontend": { + "type": "boolean", + "description": "Whether to serve the bundled xterm.js client at \"/\". Disabled by default; the /stream, /input and /resize endpoints are the primary interface. Set to true to serve the bundled client.", + "default": false + } + } +} diff --git a/website/public/extensions.json b/website/public/extensions.json index 5ef85858..aef7ae26 100644 --- a/website/public/extensions.json +++ b/website/public/extensions.json @@ -1129,5 +1129,45 @@ "composerVersion": "0.10.0-dev", "sourcePath": "composer/token-exchange", "configReferencePath": "/docs/reference/extensions#token-exchange" + }, + { + "name": "web-terminal", + "version": "0.10.0-dev", + "parent": "composer", + "categories": [ + "Misc" + ], + "author": "Anton Kanugalawattage", + "description": "Host an interactive terminal inside Envoy, streamed over SSE.", + "longDescription": "Hosts an interactive shell (default `/bin/bash`) inside Envoy and exposes it to\nthe browser without any backend server. The PTY runs in the Envoy process; its\noutput is streamed to the client over a never-closing HTTP response as\nServer-Sent Events, and input and resize events come back as short POST\nrequests correlated by a session id. Because it runs as a composer HTTP filter,\nno upstream cluster or extra port is required — Envoy hosts the session directly.\n\n## Protocol\n\nThe transport is plain HTTP and client-agnostic — any client can implement it:\n\n- `GET /stream?sid=\u003cid\u003e\u0026cols=\u003cn\u003e\u0026rows=\u003cn\u003e`\n A `text/event-stream` where each `data:` event is a base64-encoded chunk of\n PTY output. Opening the stream spawns the PTY for `sid`; closing it (client\n disconnect) tears the PTY down. A final `event: exit` is sent on shell exit.\n- `POST /input?sid=\u003cid\u003e`\n The request body is written to the PTY.\n- `POST /resize?sid=\u003cid\u003e\u0026cols=\u003cn\u003e\u0026rows=\u003cn\u003e`\n Resizes the PTY.\n\n## Frontend\n\nA small `xterm.js` client is bundled but is not served by default — the\nendpoints above are the primary interface, so bring your own client (e.g. an\napplication's own terminal view). Set `serve_frontend: true` to serve the\nbundled client at `/`.\n\n## Security\n\nA browser terminal is remote code execution by design, and this extension has\nno built-in authentication — restrict access to the listener (network policy or\nan auth filter earlier in the chain) before exposing it.\n", + "type": "go", + "filterType": [ + "http" + ], + "tags": [ + "go", + "http", + "terminal", + "pty", + "sse" + ], + "license": "Apache-2.0", + "examples": [ + { + "title": "API only (default), bring your own client", + "description": "Drive the /stream, /input and /resize endpoints from your own client. The\nbundled frontend is not served.\n", + "code": "boe run --extension web-terminal --config '{\n \"command\": \"/bin/bash\"\n}'\n" + }, + { + "title": "Serve the bundled xterm.js client", + "description": "Enable the bundled frontend, then open it in the browser at\nhttp://localhost:10000/\n", + "code": "boe run --extension web-terminal --config '{\n \"command\": \"/bin/bash\",\n \"serve_frontend\": true\n}'\n" + } + ], + "minEnvoyVersion": "1.38.0", + "maxEnvoyVersion": "1.39.0", + "composerVersion": "0.10.0-dev", + "sourcePath": "composer/web-terminal", + "configReferencePath": "/docs/reference/extensions#web-terminal" } ] \ No newline at end of file diff --git a/website/src/pages/docs/reference/extensions.mdx b/website/src/pages/docs/reference/extensions.mdx index 71475182..e309d9bb 100644 --- a/website/src/pages/docs/reference/extensions.mdx +++ b/website/src/pages/docs/reference/extensions.mdx @@ -2275,4 +2275,57 @@ Token exchange endpoint URL. Must contain a host and path (e.g. "https://idp.exa | **Type** | `string` | | **Required** | Yes | -[↑ Top](#) | [↑ Token Exchange Configuration](#token-exchange) {/* if .SubProperties */} {/* range .Properties */} {/* if .CustomTypes */} {/* range . */} +[↑ Top](#) | [↑ Token Exchange Configuration](#token-exchange) {/* if .SubProperties */} {/* range .Properties */} {/* if .CustomTypes */} + + + + + +## Web Terminal Configuration + +Configuration for the web-terminal extension. Streams a PTY to the browser over SSE and takes input via POST. A browser terminal is remote code execution by design: set a token unless the listener is otherwise protected. + +### args + +Arguments passed to the command. + +| | | +|---|---| +| **Type** | `[]string` | +| **Required** | No | + +[↑ Top](#) | [↑ Web Terminal Configuration](#web-terminal) {/* if .SubProperties */} + +### command + +Executable to run inside the PTY (e.g. "/bin/bash"). + +| | | +|---|---| +| **Type** | `string` | +| **Required** | No | +| **Min length** | 1 | + +[↑ Top](#) | [↑ Web Terminal Configuration](#web-terminal) {/* if .SubProperties */} + +### serve_frontend + +Whether to serve the bundled xterm.js client at "/". Disabled by default; the /stream, /input and /resize endpoints are the primary interface. Set to true to serve the bundled client. + +| | | +|---|---| +| **Type** | `boolean` | +| **Required** | No | + +[↑ Top](#) | [↑ Web Terminal Configuration](#web-terminal) {/* if .SubProperties */} + +### writable + +Whether client keystrokes are written to the PTY. Set to false for a read-only terminal. + +| | | +|---|---| +| **Type** | `boolean` | +| **Required** | No | + +[↑ Top](#) | [↑ Web Terminal Configuration](#web-terminal) {/* if .SubProperties */} {/* range .Properties */} {/* if .CustomTypes */} {/* range . */}