diff --git a/.gitignore b/.gitignore index 6096017..0f26fab 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ bin/ cache/ config.yaml tlsproxy.env +/sshterm-server +go/sshterm-server/internal/docroot/ diff --git a/README.md b/README.md index f73716b..f34c48f 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,13 @@ cd sshterm ./build.sh ``` -After the build completes, the `docroot/` directory will contain all the required application files. +After the build completes, the `docroot/` directory will contain all the required application files, and the standalone `./sshterm-server` binary will be built with `docroot/` embedded. ### Configuration -SSH Term connects to SSH servers via a WebSocket proxy. You will need to configure `tlsproxy` to forward connections. +SSH Term connects to SSH servers via a WebSocket proxy. For full production deployments (with ACME/Let's Encrypt, authentication, and SSH certificate issuance), we recommend [tlsproxy](https://github.com/c2FmZQ/tlsproxy). For local use or deployments behind an existing reverse proxy, you can also use the built-in lightweight `sshterm-server`. + +#### Option A: Using `tlsproxy` (Recommended for Production) 1. **Configure `tlsproxy`** @@ -72,6 +74,22 @@ SSH Term connects to SSH servers via a WebSocket proxy. You will need to configu The URL can also be a relative path, e.g. `./myserver`. Alternatively, you can provide this configuration in a `config.json` file in the `docroot` directory. +#### Option B: Using `sshterm-server` (Lightweight Standalone Server) + +`sshterm-server` is a self-contained binary that embeds the web application and proxies WebSocket connections to configured SSH servers. It automatically injects the configured endpoints into `/config.json` on the fly, so no `ep add` step is needed in the browser. + +```bash +./sshterm-server \ + --addr 127.0.0.1:8080 \ + --ep myserver=192.168.0.100:22 +``` + +Available flags: +* `--addr `: The TCP address to listen on (default `127.0.0.1:8080`). +* `--ep ` (or `--endpoint`, repeatable): Defines a WebSocket endpoint at `/_ws/` forwarding to `host:port` (defaults to port `22` if omitted) and adds it to `/config.json`. +* `--config `: Optional path to a base `config.json` file. Configured endpoints are merged into it on the fly. +* `--tls-cert ` and `--tls-key `: Optional TLS certificate and private key files to serve HTTPS instead of HTTP. + ### Using `config.json` As an alternative to configuring endpoints manually with the `ep` command, you can create a `config.json` file in the `docroot/` directory to pre-configure the application. This is useful for deploying SSH Term with a default set of connections and settings. @@ -324,6 +342,7 @@ The codebase is organized into the following main directories: * `go/internal/webauthnsk/`: Implements the `ecdsa-sk` (WebAuthn) key type. * `go/internal/tests/`: Contains internal end-to-end tests for the Go application, which are also compiled to WASM and run in a browser. * `go/internal/testserver/`: A backend server used for running the internal Go tests, providing a mock SSH server and other endpoints. + * `go/sshterm-server/`: A lightweight standalone HTTP/HTTPS and WebSocket proxy server with the `docroot/` assets embedded. * `docroot/`: The web root for the application. It contains the main `index.html`, the compiled `ssh.wasm` binary, and the necessary JavaScript and CSS assets. This is the directory you would serve to users. * `xterm/`: Contains the `xterm.js` frontend component and its dependencies, which provides the terminal UI. * `tests/`: Contains scripts and Docker configurations for running the end-to-end browser tests. diff --git a/go/build.sh b/go/build.sh index 47a8ae0..5003988 100755 --- a/go/build.sh +++ b/go/build.sh @@ -5,3 +5,11 @@ cd $(dirname $0) GOOS=js GOARCH=wasm go build -ldflags="-extldflags=-s -w" -o ../docroot/ssh.wasm . GOOS=js GOARCH=wasm go test -c -o ../docroot/tests.wasm ./internal/tests cp -f $(go env GOROOT)/lib/wasm/wasm_exec.js ../docroot/ + +rm -rf sshterm-server/internal/docroot +mkdir -p sshterm-server/internal/docroot +cp -r ../docroot/* sshterm-server/internal/docroot/ +rm -f sshterm-server/internal/docroot/tests.* sshterm-server/internal/docroot/config.json* + +go build -ldflags="-extldflags=-s -w" -o ../sshterm-server ./sshterm-server + diff --git a/go/sshterm-server/internal/server.go b/go/sshterm-server/internal/server.go new file mode 100644 index 0000000..d69b6cb --- /dev/null +++ b/go/sshterm-server/internal/server.go @@ -0,0 +1,455 @@ +// MIT License +// +// Copyright (c) 2026 TTBT Enterprises LLC +// Copyright (c) 2026 Robin Thellend +// +// 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. + +package internal + +import ( + "context" + "crypto/tls" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/c2FmZQ/sshterm/config" +) + +//go:embed docroot +var embeddedDocroot embed.FS + +const ( + defaultCSP = "default-src 'self'; style-src 'unsafe-inline' 'self'; script-src 'wasm-unsafe-eval' 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none';" + + dialTimeout = 10 * time.Second + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 +) + +// Endpoint maps a logical sshterm endpoint name to a TCP host:port address. +type Endpoint struct { + Name string + Address string +} + +// ParseEndpoint parses an endpoint specification in the form "name=host[:port]". +// If no port is specified, port 22 is used. +func ParseEndpoint(spec string) (Endpoint, error) { + name, addr, ok := strings.Cut(spec, "=") + if !ok { + return Endpoint{}, fmt.Errorf("invalid endpoint %q: expected name=host[:port]", spec) + } + name = strings.TrimSpace(name) + addr = strings.TrimSpace(addr) + if name == "" { + return Endpoint{}, fmt.Errorf("invalid endpoint %q: name cannot be empty", spec) + } + if strings.ContainsAny(name, ":/\\ \t\r\n") || name == "." || name == ".." { + return Endpoint{}, fmt.Errorf("invalid endpoint name %q", name) + } + if addr == "" { + return Endpoint{}, fmt.Errorf("invalid endpoint %q: address cannot be empty", spec) + } + normalized, err := normalizeHostPort(addr) + if err != nil { + return Endpoint{}, fmt.Errorf("invalid endpoint %q: %w", spec, err) + } + return Endpoint{ + Name: name, + Address: normalized, + }, nil +} + +func normalizeHostPort(addr string) (string, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + // If splitting failed, assume port 22 was omitted. + h := strings.TrimPrefix(strings.TrimSuffix(addr, "]"), "[") + if h == "" { + return "", errors.New("host cannot be empty") + } + candidate := net.JoinHostPort(h, "22") + host, port, err = net.SplitHostPort(candidate) + if err != nil { + return "", err + } + } + if host == "" { + return "", errors.New("host cannot be empty") + } + p, err := strconv.Atoi(port) + if err != nil || p < 1 || p > 65535 { + return "", fmt.Errorf("invalid port %q", port) + } + return net.JoinHostPort(host, port), nil +} + +// Options configures a Server instance. +type Options struct { + Addr string + ConfigFile string + TLSCertFile string + TLSKeyFile string + Endpoints []Endpoint + FS fs.FS + DialContext func(ctx context.Context, network, address string) (net.Conn, error) + Logger *log.Logger +} + +// Server serves the sshterm web application and proxies WebSocket connections +// to configured SSH endpoints. +type Server struct { + opts Options + endpoints map[string]string + configJSON []byte + upgrader websocket.Upgrader + mux *http.ServeMux + logger *log.Logger + + mu sync.Mutex + listener net.Listener +} + +// New creates and initializes a new Server. +func New(opts Options) (*Server, error) { + if (opts.TLSCertFile == "") != (opts.TLSKeyFile == "") { + return nil, errors.New("--tls-cert and --tls-key must be specified together") + } + for _, ep := range opts.Endpoints { + if ep.Name == "" || strings.ContainsAny(ep.Name, ":/\\ \t\r\n") || ep.Name == "." || ep.Name == ".." { + return nil, fmt.Errorf("invalid endpoint name %q", ep.Name) + } + if _, err := normalizeHostPort(ep.Address); err != nil { + return nil, fmt.Errorf("invalid address for endpoint %q: %w", ep.Name, err) + } + } + + docFS := opts.FS + if docFS == nil { + var err error + docFS, err = fs.Sub(embeddedDocroot, "docroot") + if err != nil { + return nil, fmt.Errorf("embedded docroot: %w", err) + } + } + + if opts.DialContext == nil { + dialer := &net.Dialer{ + Timeout: dialTimeout, + KeepAlive: 30 * time.Second, + } + opts.DialContext = dialer.DialContext + } + logger := opts.Logger + if logger == nil { + logger = log.Default() + } + + cfgBytes, epMap, err := buildConfigJSON(opts.ConfigFile, opts.Endpoints) + if err != nil { + return nil, err + } + + s := &Server{ + opts: opts, + endpoints: epMap, + configJSON: cfgBytes, + upgrader: websocket.Upgrader{ + ReadBufferSize: 8192, + WriteBufferSize: 8192, + }, + mux: http.NewServeMux(), + logger: logger, + } + + fileServer := http.FileServerFS(docFS) + s.mux.HandleFunc("GET /config.json", s.handleConfigJSON) + s.mux.HandleFunc("HEAD /config.json", s.handleConfigJSON) + s.mux.HandleFunc("GET /_ws/{name}", s.handleWebSocket) + s.mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + fileServer.ServeHTTP(w, req) + }) + + return s, nil +} + +func buildConfigJSON(configFile string, endpoints []Endpoint) ([]byte, map[string]string, error) { + var cfg config.Config + if configFile != "" { + b, err := os.ReadFile(configFile) + if err != nil { + return nil, nil, fmt.Errorf("reading config file: %w", err) + } + if err := json.Unmarshal(b, &cfg); err != nil { + return nil, nil, fmt.Errorf("parsing config file: %w", err) + } + } + + epMap := make(map[string]string, len(endpoints)) + for _, ep := range endpoints { + addr, err := normalizeHostPort(ep.Address) + if err != nil { + return nil, nil, err + } + epMap[ep.Name] = addr + wsURL := "./_ws/" + ep.Name + + updated := false + for i := range cfg.Endpoints { + if cfg.Endpoints[i].Name == ep.Name { + cfg.Endpoints[i].URL = wsURL + updated = true + break + } + } + if !updated { + cfg.Endpoints = append(cfg.Endpoints, struct { + Name string `json:"name"` + URL string `json:"url"` + Hostname string `json:"hostname,omitempty"` + }{ + Name: ep.Name, + URL: wsURL, + }) + } + } + + out, err := json.MarshalIndent(&cfg, "", " ") + if err != nil { + return nil, nil, fmt.Errorf("marshaling config.json: %w", err) + } + out = append(out, '\n') + return out, epMap, nil +} + +// Handler returns the HTTP handler for the server. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Security-Policy", defaultCSP) + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + s.mux.ServeHTTP(w, req) + }) +} + +func (s *Server) handleConfigJSON(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Length", strconv.Itoa(len(s.configJSON))) + if req.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + w.Write(s.configJSON) +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, req *http.Request) { + name := req.PathValue("name") + targetAddr, exists := s.endpoints[name] + if !exists { + http.NotFound(w, req) + return + } + + wsConn, err := s.upgrader.Upgrade(w, req, nil) + if err != nil { + s.logger.Printf("websocket upgrade failed for %q: %v", name, err) + return + } + defer wsConn.Close() + + dialCtx, cancel := context.WithTimeout(req.Context(), dialTimeout) + tcpConn, err := s.opts.DialContext(dialCtx, "tcp", targetAddr) + cancel() + if err != nil { + s.logger.Printf("dial %q (%s) failed: %v", name, targetAddr, err) + _ = wsConn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "upstream connection failed"), + time.Now().Add(writeWait), + ) + return + } + defer tcpConn.Close() + + s.proxyConn(wsConn, tcpConn) +} + +func (s *Server) proxyConn(wsConn *websocket.Conn, tcpConn net.Conn) { + done := make(chan struct{}) + var closeOnce sync.Once + closeBoth := func() { + closeOnce.Do(func() { + close(done) + _ = wsConn.Close() + _ = tcpConn.Close() + }) + } + + _ = wsConn.SetReadDeadline(time.Now().Add(pongWait)) + wsConn.SetPongHandler(func(string) error { + return wsConn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + var wg sync.WaitGroup + wg.Add(3) + + // Periodic WebSocket ping goroutine. + go func() { + defer wg.Done() + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + if err := wsConn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil { + closeBoth() + return + } + } + } + }() + + // WebSocket -> TCP + go func() { + defer wg.Done() + defer closeBoth() + for { + msgType, r, err := wsConn.NextReader() + if err != nil { + return + } + _ = wsConn.SetReadDeadline(time.Now().Add(pongWait)) + if msgType != websocket.BinaryMessage { + _ = wsConn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseUnsupportedData, "binary messages only"), + time.Now().Add(writeWait), + ) + return + } + if _, err := io.Copy(tcpConn, r); err != nil { + return + } + } + }() + + // TCP -> WebSocket + go func() { + defer wg.Done() + defer closeBoth() + buf := make([]byte, 16384) + for { + n, err := tcpConn.Read(buf) + if n > 0 { + _ = wsConn.SetWriteDeadline(time.Now().Add(writeWait)) + if wErr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); wErr != nil { + return + } + } + if err != nil { + return + } + } + }() + + wg.Wait() +} + +// Addr returns the network address the server is listening on, or nil if it has +// not started listening yet. +func (s *Server) Addr() net.Addr { + s.mu.Lock() + defer s.mu.Unlock() + if s.listener == nil { + return nil + } + return s.listener.Addr() +} + +// Run starts the HTTP/HTTPS server and blocks until ctx is canceled or an +// unrecoverable server error occurs. +func (s *Server) Run(ctx context.Context) error { + addr := s.opts.Addr + if addr == "" { + addr = "127.0.0.1:8080" + } + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + s.mu.Lock() + s.listener = l + s.mu.Unlock() + + httpServer := &http.Server{ + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + if s.opts.TLSCertFile != "" && s.opts.TLSKeyFile != "" { + httpServer.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + s.logger.Printf("HTTPS server listening on https://%s", l.Addr()) + errCh <- httpServer.ServeTLS(l, s.opts.TLSCertFile, s.opts.TLSKeyFile) + } else { + s.logger.Printf("HTTP server listening on http://%s", l.Addr()) + errCh <- httpServer.Serve(l) + } + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + err := <-errCh + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/go/sshterm-server/internal/server_test.go b/go/sshterm-server/internal/server_test.go new file mode 100644 index 0000000..652888d --- /dev/null +++ b/go/sshterm-server/internal/server_test.go @@ -0,0 +1,638 @@ +// MIT License +// +// Copyright (c) 2026 TTBT Enterprises LLC +// Copyright (c) 2026 Robin Thellend +// +// 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. + +package internal + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" + + "github.com/c2FmZQ/sshterm/config" +) + +func TestParseEndpoint(t *testing.T) { + tests := []struct { + spec string + want Endpoint + wantErr bool + }{ + { + spec: "myserver=192.168.1.100:2222", + want: Endpoint{Name: "myserver", Address: "192.168.1.100:2222"}, + }, + { + spec: "myserver=192.168.1.100", + want: Endpoint{Name: "myserver", Address: "192.168.1.100:22"}, + }, + { + spec: "host.example.com=ssh.internal.example.com", + want: Endpoint{Name: "host.example.com", Address: "ssh.internal.example.com:22"}, + }, + { + spec: "ipv6=[::1]:2222", + want: Endpoint{Name: "ipv6", Address: "[::1]:2222"}, + }, + { + spec: "ipv6=[::1]", + want: Endpoint{Name: "ipv6", Address: "[::1]:22"}, + }, + { + spec: "ipv6=::1", + want: Endpoint{Name: "ipv6", Address: "[::1]:22"}, + }, + { + spec: "missing-equals", + wantErr: true, + }, + { + spec: "=192.168.1.1:22", + wantErr: true, + }, + { + spec: "bad:name=192.168.1.1:22", + wantErr: true, + }, + { + spec: "bad/name=192.168.1.1:22", + wantErr: true, + }, + { + spec: "..=192.168.1.1:22", + wantErr: true, + }, + { + spec: "myserver=", + wantErr: true, + }, + { + spec: "myserver=:22", + wantErr: true, + }, + { + spec: "myserver=192.168.1.1:0", + wantErr: true, + }, + { + spec: "myserver=192.168.1.1:70000", + wantErr: true, + }, + { + spec: "myserver=192.168.1.1:notaport", + wantErr: true, + }, + } + + for _, tc := range tests { + got, err := ParseEndpoint(tc.spec) + if tc.wantErr { + if err == nil { + t.Errorf("ParseEndpoint(%q) = %+v, want error", tc.spec, got) + } + continue + } + if err != nil { + t.Errorf("ParseEndpoint(%q) unexpected error: %v", tc.spec, err) + continue + } + if got != tc.want { + t.Errorf("ParseEndpoint(%q) = %+v, want %+v", tc.spec, got, tc.want) + } + } +} + +func TestNewValidation(t *testing.T) { + if _, err := New(Options{TLSCertFile: "cert.pem"}); err == nil { + t.Error("expected error when only TLSCertFile is set") + } + if _, err := New(Options{TLSKeyFile: "key.pem"}); err == nil { + t.Error("expected error when only TLSKeyFile is set") + } + if _, err := New(Options{ConfigFile: filepath.Join(t.TempDir(), "nonexistent.json")}); err == nil { + t.Error("expected error for nonexistent config file") + } +} + +func TestEmbeddedStaticFilesAndHeaders(t *testing.T) { + srv, err := New(Options{ + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + for _, path := range []string{"/", "/index.html", "/ssh.wasm", "/xterm.mjs", "/stream-helper.js"} { + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("GET %s: status = %d, want 200", path, resp.StatusCode) + } + if got := resp.Header.Get("X-Frame-Options"); got != "DENY" { + t.Errorf("GET %s: X-Frame-Options = %q, want DENY", path, got) + } + if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("GET %s: X-Content-Type-Options = %q, want nosniff", path, got) + } + if got := resp.Header.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") { + t.Errorf("GET %s: Content-Security-Policy = %q, want frame-ancestors 'none'", path, got) + } + } + + // Ensure test artifacts and example config are not embedded. + for _, excluded := range []string{"/tests.html", "/tests.wasm", "/config.json.example"} { + resp, err := http.Get(ts.URL + excluded) + if err != nil { + t.Fatalf("GET %s: %v", excluded, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", excluded, resp.StatusCode) + } + } +} + +func TestConfigJSONSynthesis(t *testing.T) { + baseCfg := `{ + "persist": false, + "theme": "dark", + "endpoints": [ + { + "name": "srv1", + "url": "./old-url", + "hostname": "srv1.internal.example.com" + }, + { + "name": "external", + "url": "wss://other.example.com/ws" + } + ], + "hosts": [ + { + "name": "srv1", + "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINqyuT/sFvC37z1qMY0may2TMKqg2nxdjxBxyfXeieot" + } + ] +}` + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, []byte(baseCfg), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + srv, err := New(Options{ + ConfigFile: cfgPath, + Endpoints: []Endpoint{ + {Name: "srv1", Address: "10.0.0.10:22"}, + {Name: "srv2", Address: "10.0.0.20:2222"}, + }, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/config.json") + if err != nil { + t.Fatalf("GET /config.json: %v", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /config.json: status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + // Ensure internal forwarding addresses are never exposed in config.json. + if bytes.Contains(body, []byte("10.0.0.10")) || bytes.Contains(body, []byte("10.0.0.20")) { + t.Fatalf("config.json leaked target forwarding address:\n%s", body) + } + + var gotCfg config.Config + if err := json.Unmarshal(body, &gotCfg); err != nil { + t.Fatalf("Unmarshal config.json: %v", err) + } + if gotCfg.Theme != "dark" { + t.Errorf("Theme = %q, want dark", gotCfg.Theme) + } + if gotCfg.Persist == nil || *gotCfg.Persist != false { + t.Errorf("Persist = %v, want false", gotCfg.Persist) + } + if len(gotCfg.Endpoints) != 3 { + t.Fatalf("len(Endpoints) = %d, want 3: %+v", len(gotCfg.Endpoints), gotCfg.Endpoints) + } + // srv1 should have updated URL and preserved Hostname. + if gotCfg.Endpoints[0].Name != "srv1" || gotCfg.Endpoints[0].URL != "./_ws/srv1" || gotCfg.Endpoints[0].Hostname != "srv1.internal.example.com" { + t.Errorf("Endpoints[0] = %+v", gotCfg.Endpoints[0]) + } + // external should remain untouched. + if gotCfg.Endpoints[1].Name != "external" || gotCfg.Endpoints[1].URL != "wss://other.example.com/ws" { + t.Errorf("Endpoints[1] = %+v", gotCfg.Endpoints[1]) + } + // srv2 should be appended. + if gotCfg.Endpoints[2].Name != "srv2" || gotCfg.Endpoints[2].URL != "./_ws/srv2" { + t.Errorf("Endpoints[2] = %+v", gotCfg.Endpoints[2]) + } + + // Verify HEAD /config.json. + headResp, err := http.Head(ts.URL + "/config.json") + if err != nil { + t.Fatalf("HEAD /config.json: %v", err) + } + headResp.Body.Close() + if headResp.StatusCode != http.StatusOK { + t.Errorf("HEAD /config.json: status = %d, want 200", headResp.StatusCode) + } +} + +func TestWebSocketProxy(t *testing.T) { + // Start a mock TCP echo/handshake server. + tcpListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer tcpListener.Close() + + go func() { + for { + conn, err := tcpListener.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + // Send SSH-like banner first. + if _, err := c.Write([]byte("SSH-2.0-MockSSH\r\n")); err != nil { + return + } + _, _ = io.Copy(c, c) + }(conn) + } + }() + + srv, err := New(Options{ + Endpoints: []Endpoint{ + {Name: "mockssh", Address: tcpListener.Addr().String()}, + }, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + wsBaseURL := "ws" + strings.TrimPrefix(ts.URL, "http") + + // 1. Unknown endpoint should return 404. + _, resp, err := websocket.DefaultDialer.Dial(wsBaseURL+"/_ws/unknown", nil) + if err == nil { + t.Fatal("expected error dialing unknown endpoint") + } + if resp == nil || resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown endpoint status = %v, want 404", resp) + } + + // 2. Cross-origin request should be rejected with 403. + badHeader := http.Header{"Origin": []string{"https://evil.example.com"}} + _, resp, err = websocket.DefaultDialer.Dial(wsBaseURL+"/_ws/mockssh", badHeader) + if err == nil { + t.Fatal("expected error for cross-origin WebSocket") + } + if resp == nil || resp.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin status = %v, want 403", resp) + } + + // 3. Valid binary proxy session. + wsConn, _, err := websocket.DefaultDialer.Dial(wsBaseURL+"/_ws/mockssh", nil) + if err != nil { + t.Fatalf("Dial mockssh: %v", err) + } + defer wsConn.Close() + + msgType, banner, err := wsConn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage banner: %v", err) + } + if msgType != websocket.BinaryMessage || string(banner) != "SSH-2.0-MockSSH\r\n" { + t.Fatalf("unexpected banner: type=%d data=%q", msgType, banner) + } + + payload := []byte("hello over binary websocket") + if err := wsConn.WriteMessage(websocket.BinaryMessage, payload); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + msgType, echoed, err := wsConn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage echo: %v", err) + } + if msgType != websocket.BinaryMessage || !bytes.Equal(echoed, payload) { + t.Fatalf("unexpected echo: type=%d data=%q", msgType, echoed) + } + + // 4. Text message should be rejected by server. + if err := wsConn.WriteMessage(websocket.TextMessage, []byte("text not allowed")); err != nil { + t.Fatalf("WriteMessage text: %v", err) + } + _, _, err = wsConn.ReadMessage() + if err == nil { + t.Fatal("expected connection close after sending text frame") + } +} + +func TestRunTLSAndShutdown(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + generateSelfSignedCert(t, certFile, keyFile) + + srv, err := New(Options{ + Addr: "127.0.0.1:0", + TLSCertFile: certFile, + TLSKeyFile: keyFile, + Endpoints: []Endpoint{ + {Name: "local", Address: "127.0.0.1:22"}, + }, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- srv.Run(ctx) + }() + + // Wait for listener to be ready. + var addr net.Addr + for range 50 { + if addr = srv.Addr(); addr != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if addr == nil { + cancel() + t.Fatal("server did not start listening in time") + } + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + resp, err := client.Get("https://" + addr.String() + "/config.json") + if err != nil { + cancel() + t.Fatalf("GET https config.json: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + cancel() + if err := <-errCh; err != nil { + t.Errorf("srv.Run returned error after cancel: %v", err) + } +} + +func generateSelfSignedCert(t *testing.T, certFile, keyFile string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}), 0o600); err != nil { + t.Fatal(err) + } + b, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b}), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestSSHOverWebSocketProxy(t *testing.T) { + _, hostPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + hostSigner, err := ssh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatal(err) + } + userPub, userPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshUserPub, err := ssh.NewPublicKey(userPub) + if err != nil { + t.Fatal(err) + } + userSigner, err := ssh.NewSignerFromKey(userPriv) + if err != nil { + t.Fatal(err) + } + + sshCfg := &ssh.ServerConfig{ + PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if c.User() == "testuser" && bytes.Equal(key.Marshal(), sshUserPub.Marshal()) { + return nil, nil + } + return nil, fmt.Errorf("unauthorized") + }, + } + sshCfg.AddHostKey(hostSigner) + + tcpListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tcpListener.Close() + + go func() { + for { + conn, err := tcpListener.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + _, chans, reqs, err := ssh.NewServerConn(c, sshCfg) + if err != nil { + return + } + go ssh.DiscardRequests(reqs) + for newChan := range chans { + if newChan.ChannelType() != "session" { + newChan.Reject(ssh.UnknownChannelType, "unsupported") + continue + } + ch, chReqs, err := newChan.Accept() + if err != nil { + return + } + go func() { + defer ch.Close() + for req := range chReqs { + if req.Type == "exec" && len(req.Payload) >= 4 { + req.Reply(true, nil) + fmt.Fprintf(ch, "ran:%s", req.Payload[4:]) + ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) + return + } + req.Reply(false, nil) + } + }() + } + }(conn) + } + }() + + srv, err := New(Options{ + Endpoints: []Endpoint{ + {Name: "testssh", Address: tcpListener.Addr().String()}, + }, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + wsBaseURL := "ws" + strings.TrimPrefix(ts.URL, "http") + wsConn, _, err := websocket.DefaultDialer.Dial(wsBaseURL+"/_ws/testssh", nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer wsConn.Close() + + clientCfg := &ssh.ClientConfig{ + User: "testuser", + Auth: []ssh.AuthMethod{ssh.PublicKeys(userSigner)}, + HostKeyCallback: ssh.FixedHostKey(hostSigner.PublicKey()), + } + c, chans, reqs, err := ssh.NewClientConn(&wsNetConn{conn: wsConn}, "testssh", clientCfg) + if err != nil { + t.Fatalf("ssh.NewClientConn: %v", err) + } + sshClient := ssh.NewClient(c, chans, reqs) + defer sshClient.Close() + + sess, err := sshClient.NewSession() + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer sess.Close() + + out, err := sess.CombinedOutput("uname -a") + if err != nil { + t.Fatalf("CombinedOutput: %v", err) + } + if string(out) != "ran:uname -a" { + t.Errorf("CombinedOutput = %q, want %q", out, "ran:uname -a") + } +} + +type wsNetConn struct { + conn *websocket.Conn + buf []byte +} + +func (c *wsNetConn) Read(b []byte) (int, error) { + if len(c.buf) == 0 { + _, p, err := c.conn.ReadMessage() + if err != nil { + return 0, err + } + c.buf = p + } + n := copy(b, c.buf) + c.buf = c.buf[n:] + return n, nil +} + +func (c *wsNetConn) Write(b []byte) (int, error) { + return len(b), c.conn.WriteMessage(websocket.BinaryMessage, b) +} + +func (c *wsNetConn) Close() error { return c.conn.Close() } +func (c *wsNetConn) LocalAddr() net.Addr { return c.conn.LocalAddr() } +func (c *wsNetConn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() } +func (c *wsNetConn) SetDeadline(t time.Time) error { return nil } +func (c *wsNetConn) SetReadDeadline(t time.Time) error { return c.conn.SetReadDeadline(t) } +func (c *wsNetConn) SetWriteDeadline(t time.Time) error { return c.conn.SetWriteDeadline(t) } diff --git a/go/sshterm-server/main.go b/go/sshterm-server/main.go new file mode 100644 index 0000000..26c5bd0 --- /dev/null +++ b/go/sshterm-server/main.go @@ -0,0 +1,87 @@ +// MIT License +// +// Copyright (c) 2026 TTBT Enterprises LLC +// Copyright (c) 2026 Robin Thellend +// +// 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. + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/c2FmZQ/sshterm/sshterm-server/internal" +) + +type endpointFlags []internal.Endpoint + +func (f *endpointFlags) String() string { + parts := make([]string, 0, len(*f)) + for _, ep := range *f { + parts = append(parts, fmt.Sprintf("%s=%s", ep.Name, ep.Address)) + } + return strings.Join(parts, ",") +} + +func (f *endpointFlags) Set(value string) error { + ep, err := internal.ParseEndpoint(value) + if err != nil { + return err + } + *f = append(*f, ep) + return nil +} + +func main() { + var ( + addr = flag.String("addr", "127.0.0.1:8080", "The TCP address to listen on.") + cfgFile = flag.String("config", "", "Optional path to a base config.json file.") + tlsCert = flag.String("tls-cert", "", "Path to a TLS certificate file (requires --tls-key).") + tlsKey = flag.String("tls-key", "", "Path to a TLS private key file (requires --tls-cert).") + endpoints endpointFlags + ) + flag.Var(&endpoints, "ep", "SSH endpoint in the form name=host[:port] (repeatable).") + flag.Var(&endpoints, "endpoint", "Alias for -ep.") + flag.Parse() + + srv, err := internal.New(internal.Options{ + Addr: *addr, + ConfigFile: *cfgFile, + TLSCertFile: *tlsCert, + TLSKeyFile: *tlsKey, + Endpoints: endpoints, + }) + if err != nil { + log.Fatalf("Failed to initialize server: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := srv.Run(ctx); err != nil { + log.Fatalf("Server error: %v", err) + } +}