From 67b81c1f406900e9578e7e5eeca61892cfe36b99 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 18:03:40 -0700 Subject: [PATCH 01/18] Add browser gateway --- browsergateway/main.go | 305 ++++++++++++++++++++++++++++++++++ browsergateway/rdcleanpath.go | 88 ++++++++++ browsergateway/ssh.go | 225 +++++++++++++++++++++++++ browsergateway/ssh_native.go | 107 ++++++++++++ go.mod | 2 + go.sum | 4 + 6 files changed, 731 insertions(+) create mode 100644 browsergateway/main.go create mode 100644 browsergateway/rdcleanpath.go create mode 100644 browsergateway/ssh.go create mode 100644 browsergateway/ssh_native.go diff --git a/browsergateway/main.go b/browsergateway/main.go new file mode 100644 index 0000000..0317167 --- /dev/null +++ b/browsergateway/main.go @@ -0,0 +1,305 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" +) + +// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap +// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame +// without wasting memory per session. +const forwardBufSize = 64 * 1024 + +// Config holds the configuration for a Gateway. +type Config struct { + // AuthToken is the shared secret required by RDP clients in the RDCleanPath + // ProxyAuth field, and by SSH clients as the authToken query parameter. + AuthToken string + // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead + // of proxying to an external SSH server. + NativeSSH *NativeSSHConfig +} + +// Gateway is a browser-based RDP/SSH WebSocket proxy. +// Create one with New and mount it via RegisterHandlers or the individual +// HandleRDP / HandleSSH http.HandlerFunc methods. +type Gateway struct { + authToken string + nativeSSH *NativeSSHConfig +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + nativeSSH: cfg.NativeSSH, + } +} + +// RegisterHandlers registers the /jet/rdp and /jet/ssh routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/rdp", g.HandleRDP) + mux.HandleFunc("/ssh", g.HandleSSH) +} + +// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. +func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth + Subprotocols: []string{"binary"}, + }) + if err != nil { + log.Printf("websocket upgrade failed: %v", err) + return + } + // Disable per-message read size cap (default is 32 KiB which would break + // large RDP graphics messages). + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if err := g.serveSession(ctx, ws); err != nil { + log.Printf("session error: %v", err) + } +} + +func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { + // Expose the WebSocket as a streaming net.Conn. Binary messages are + // concatenated into a byte stream and writes become single binary frames. + // This is a thin wrapper with no per-message goroutine, unlike Gorilla. + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // -- Read the initial RDCleanPath request from the client -- + pdu, err := readCleanPath(stream) + if err != nil { + return fmt.Errorf("read RDCleanPath: %w", err) + } + + if pdu.Destination == "" { + return errors.New("RDCleanPath missing destination") + } + if len(pdu.X224) == 0 { + return errors.New("RDCleanPath missing X224 connection PDU") + } + + // Constant-time comparison to avoid leaking the expected token via timing. + if subtle.ConstantTimeCompare([]byte(pdu.ProxyAuth), []byte(g.authToken)) != 1 { + return errors.New("RDCleanPath ProxyAuth token mismatch") + } + + target := pdu.Destination + // Default port for RDP if not specified. + if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { + target = net.JoinHostPort(target, "3389") + } + + log.Printf("Connecting to RDP server %s", target) + + // -- Open TCP connection to the destination RDP server -- + serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second) + if err != nil { + return fmt.Errorf("dial %s: %w", target, err) + } + defer serverTCP.Close() + if tcp, ok := serverTCP.(*net.TCPConn); ok { + // NoDelay is Go's default; set explicitly. RDP wants low latency for + // input echo, and the bulk path is naturally chunked by TLS records. + _ = tcp.SetNoDelay(true) + _ = tcp.SetKeepAlive(true) + _ = tcp.SetKeepAlivePeriod(30 * time.Second) + _ = tcp.SetReadBuffer(forwardBufSize) + _ = tcp.SetWriteBuffer(forwardBufSize) + } + serverAddr := serverTCP.RemoteAddr().String() + + // Forward the optional pre-connection blob, then the X.224 connection request. + if pdu.PreconnectionBlob != "" { + if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil { + return fmt.Errorf("send PCB: %w", err) + } + } + if _, err := serverTCP.Write(pdu.X224); err != nil { + return fmt.Errorf("send X224: %w", err) + } + + // -- Read the X.224 connection confirm from the server -- + x224Rsp, err := readX224(serverTCP) + if err != nil { + return fmt.Errorf("read X224 response: %w", err) + } + logX224Negotiation(x224Rsp) + + // -- Upgrade the server connection to TLS (skip verification) -- + // + // Windows RDP hosts are picky: only set SNI when the target is a hostname + // (Go would skip SNI for IP literals anyway, but be explicit), and accept + // the full range of TLS versions / ciphers since some servers only + // negotiate TLS 1.0 or legacy suites. + host, _, _ := net.SplitHostPort(target) + tlsCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification + MinVersion: tls.VersionTLS10, + // Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error" + // alert when CredSSP/NLA is layered on top of a TLS 1.3 session. + MaxVersion: tls.VersionTLS12, + } + if net.ParseIP(host) == nil { + tlsCfg.ServerName = host + } + tlsConn := tls.Client(serverTCP, tlsCfg) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("TLS handshake with server: %w", err) + } + log.Printf("Server TLS handshake OK (version=0x%04x cipher=0x%04x)", + tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite) + + // Collect the raw DER server certificate chain to return to the client. + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return errors.New("server did not present any certificates") + } + chain := make([][]byte, 0, len(state.PeerCertificates)) + for _, c := range state.PeerCertificates { + chain = append(chain, c.Raw) + } + + // -- Send the RDCleanPath response back to the client -- + rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain) + if err != nil { + return fmt.Errorf("encode RDCleanPath response: %w", err) + } + if _, err := stream.Write(rsp); err != nil { + return fmt.Errorf("write RDCleanPath response: %w", err) + } + + log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr) + + // -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream -- + return forward(stream, tlsConn) +} + +// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has +// been received, then decodes it. +func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) { + buf := make([]byte, 0, 1024) + tmp := make([]byte, 1024) + for { + total := detectRDCleanPathLength(buf) + switch { + case total == -2: + return nil, errors.New("invalid RDCleanPath PDU") + case total > 0 && len(buf) >= total: + return decodeRDCleanPathRequest(buf[:total]) + } + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + return nil, err + } + } +} + +// readX224 reads exactly one TPKT-framed X.224 PDU from the server. +// +// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16 +// big-endian total length that includes the header itself. +func readX224(r io.Reader) ([]byte, error) { + hdr := make([]byte, 4) + if _, err := io.ReadFull(r, hdr); err != nil { + return nil, err + } + if hdr[0] != 0x03 { + return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0]) + } + total := int(binary.BigEndian.Uint16(hdr[2:4])) + if total < 4 || total > 4096 { + return nil, fmt.Errorf("unreasonable TPKT length %d", total) + } + out := make([]byte, total) + copy(out, hdr) + if _, err := io.ReadFull(r, out[4:]); err != nil { + return nil, err + } + return out, nil +} + +// logX224Negotiation prints which RDP security protocol the server selected +// (or the failure code), to help diagnose handshake issues such as the server +// requiring NLA/CredSSP. +// +// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]): +// +// bytes 0..3 TPKT header (03 00 LL LL) +// byte 4 X.224 length indicator +// byte 5 X.224 code (0xD0 = CC) +// bytes 6..10 DST-REF, SRC-REF, class +// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure) +// byte 12 flags +// bytes 13..14 length (little-endian, =8) +// bytes 15..18 selected protocol / failure code (u32 little-endian) +func logX224Negotiation(pdu []byte) { + if len(pdu) < 19 { + log.Printf("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu)) + return + } + switch pdu[11] { + case 0x02: + proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + name := "unknown" + switch proto { + case 0: + name = "RDP (standard)" + case 1: + name = "SSL/TLS" + case 2: + name = "HYBRID (CredSSP/NLA)" + case 8: + name = "HYBRID_EX" + } + log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name) + case 0x03: + code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + log.Printf("Server returned RDP negotiation failure code 0x%x", code) + default: + log.Printf("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11]) + } +} + +// forward shuttles bytes between the two streams until either side closes. +func forward(a, b io.ReadWriteCloser) error { + errc := make(chan error, 2) + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(a, b, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(b, a, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + // Wait for one side to finish, then return. + err := <-errc + if errors.Is(err, io.EOF) || err == nil { + return nil + } + return err +} diff --git a/browsergateway/rdcleanpath.go b/browsergateway/rdcleanpath.go new file mode 100644 index 0000000..28dd486 --- /dev/null +++ b/browsergateway/rdcleanpath.go @@ -0,0 +1,88 @@ +package browsergateway + +import ( + "encoding/asn1" + "fmt" +) + +// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1). +const rdCleanPathVersion = int64(3390) + +// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the +// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific +// tagging, matching the Rust `der::Sequence` derivation with +// `tag_mode = "EXPLICIT"`. +// +// We only need a subset of fields for the basic proxy flow, but the struct +// declares every tag we may encounter so that decoding does not fail on an +// unexpected element. +type rdCleanPathPdu struct { + Version int64 `asn1:"explicit,tag:0"` + Destination string `asn1:"explicit,tag:2,optional,utf8"` + ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"` + ServerAuth string `asn1:"explicit,tag:4,optional,utf8"` + PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"` + X224 []byte `asn1:"explicit,tag:6,optional"` + ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"` + ServerAddr string `asn1:"explicit,tag:9,optional,utf8"` +} + +// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU. +func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) { + var pdu rdCleanPathPdu + rest, err := asn1.Unmarshal(buf, &pdu) + if err != nil { + return nil, fmt.Errorf("asn1 unmarshal: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest)) + } + if pdu.Version != rdCleanPathVersion { + return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version) + } + return &pdu, nil +} + +// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU +// containing the server address, X.224 connection confirm and server TLS chain. +func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) { + pdu := rdCleanPathPdu{ + Version: rdCleanPathVersion, + X224: x224Rsp, + ServerCertChain: certChain, + ServerAddr: serverAddr, + } + return asn1.Marshal(pdu) +} + +// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU +// if enough bytes are available, otherwise -1. +// +// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag +// (0x30) followed by a length octet/octets. We parse just enough to know the +// total length so we can buffer accordingly. +func detectRDCleanPathLength(buf []byte) int { + if len(buf) < 2 { + return -1 + } + if buf[0] != 0x30 { + // Not a SEQUENCE: cannot be RDCleanPath. + return -2 + } + l := buf[1] + if l < 0x80 { + return 2 + int(l) + } + n := int(l & 0x7f) + if n == 0 || n > 4 { + return -2 + } + if len(buf) < 2+n { + return -1 + } + total := 0 + for i := 0; i < n; i++ { + total = (total << 8) | int(buf[2+i]) + } + return 2 + n + total +} diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go new file mode 100644 index 0000000..290eb5a --- /dev/null +++ b/browsergateway/ssh.go @@ -0,0 +1,225 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" + "golang.org/x/crypto/ssh" +) + +// sshClientMsg is a JSON message sent from the browser to the proxy. +type sshClientMsg struct { + // type: "auth" | "data" | "resize" + Type string `json:"type"` + Password string `json:"password,omitempty"` // used when type="auth" + Data string `json:"data,omitempty"` // used when type="data" + Cols uint32 `json:"cols,omitempty"` // used when type="resize" + Rows uint32 `json:"rows,omitempty"` // used when type="resize" +} + +// sshServerMsg is a JSON message sent from the proxy back to the browser. +type sshServerMsg struct { + // type: "data" | "error" + Type string `json:"type"` + Data string `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections. +func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // -- Validate auth token from query parameter before upgrading -- + token := r.URL.Query().Get("authToken") + if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // In proxy mode we also need host + username from query params. + var target, username string + if g.nativeSSH == nil { + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + username = r.URL.Query().Get("username") + if host == "" || username == "" { + http.Error(w, "missing host or username", http.StatusBadRequest) + return + } + if port == "" { + port = "22" + } + target = net.JoinHostPort(host, port) + } + + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"ssh"}, + }) + if err != nil { + log.Printf("SSH websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if g.nativeSSH != nil { + if err := serveNativeSSHSession(ctx, ws, *g.nativeSSH); err != nil { + log.Printf("SSH native session error: %v", err) + } + } else { + if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil { + log.Printf("SSH session error: %v", err) + } + } +} + +func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error { + // -- Wait for the auth message from the client to get the password -- + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + password := authMsg.Password + + // -- Dial the SSH server -- + log.Printf("SSH: connecting to %s as %s", target, username) + sshCfg := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + // HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev + // proxy. In production, verify against a known-hosts store. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec + Timeout: 15 * time.Second, + } + + sshClient, err := ssh.Dial("tcp", target, sshCfg) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err)) + return fmt.Errorf("ssh dial %s: %w", target, err) + } + defer sshClient.Close() + + // -- Open an interactive session -- + sess, err := sshClient.NewSession() + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err)) + return fmt.Errorf("ssh new session: %w", err) + } + defer sess.Close() + + // Request a PTY. + if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 38400, + ssh.TTY_OP_OSPEED: 38400, + }); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err)) + return fmt.Errorf("ssh request pty: %w", err) + } + + stdinPipe, err := sess.StdinPipe() + if err != nil { + return fmt.Errorf("ssh stdin pipe: %w", err) + } + stdoutPipe, err := sess.StdoutPipe() + if err != nil { + return fmt.Errorf("ssh stdout pipe: %w", err) + } + stderrPipe, err := sess.StderrPipe() + if err != nil { + return fmt.Errorf("ssh stderr pipe: %w", err) + } + + if err := sess.Shell(); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err)) + return fmt.Errorf("ssh shell: %w", err) + } + + log.Printf("SSH: session established with %s", target) + + // -- Pump SSH stdout/stderr → WebSocket -- + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stdoutPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + cancelSess() + return + } + } + }() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stderrPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // -- Pump WebSocket input → SSH stdin / resize -- + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + + switch msg.Type { + case "data": + if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil { + return fmt.Errorf("write ssh stdin: %w", err) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = sess.WindowChange(int(msg.Rows), int(msg.Cols)) + } + } + } + + return nil +} + +// sendSSHError sends an error message to the browser and logs it. +func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) { + log.Printf("SSH error: %s", msg) + b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg}) + _ = ws.Write(ctx, websocket.MessageText, b) +} diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go new file mode 100644 index 0000000..b65eb83 --- /dev/null +++ b/browsergateway/ssh_native.go @@ -0,0 +1,107 @@ +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + + "github.com/coder/websocket" + "github.com/creack/pty" +) + +// NativeSSHConfig holds configuration for the native PTY/shell mode. +type NativeSSHConfig struct { + // Shell is the executable to spawn (e.g. /bin/bash). Defaults to /bin/sh. + Shell string +} + +// serveNativeSSHSession handles a WebSocket SSH session by spawning a local +// PTY+shell instead of proxying to an external SSH server. The auth token has +// already been validated at the WebSocket upgrade level, so this function only +// reads (and discards) the initial "auth" frame for protocol compatibility with +// the browser client before starting the shell. +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSSHConfig) error { + // Read and discard the auth frame (token already validated at HTTP layer). + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + + shell := cfg.Shell + if shell == "" { + shell = "/bin/sh" + } + + log.Printf("SSH native: spawning %s", shell) + + cmd := exec.CommandContext(ctx, shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + // Start the command with a PTY attached. + ptmx, err := pty.Start(cmd) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) + return fmt.Errorf("pty start: %w", err) + } + defer func() { + _ = ptmx.Close() + _ = cmd.Wait() + }() + + // Cancel context to unblock the WebSocket read loop when the shell exits. + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + // Pump PTY output → WebSocket. + go func() { + defer cancelSess() + buf := make([]byte, 4096) + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // Pump WebSocket input → PTY stdin / resize. + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + switch msg.Type { + case "data": + if _, writeErr := ptmx.Write([]byte(msg.Data)); writeErr != nil { + return fmt.Errorf("write pty: %w", writeErr) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{ + Cols: uint16(msg.Cols), + Rows: uint16(msg.Rows), + }) + } + } + } + + return nil +} diff --git a/go.mod b/go.mod index d182bff..1508e71 100644 --- a/go.mod +++ b/go.mod @@ -35,8 +35,10 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.4.0 // indirect diff --git a/go.sum b/go.sum index a60e6db..c79a283 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,16 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= From 825a7f460f9a789c2d6995b343ba0c6c21e901bd Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:13:55 -0700 Subject: [PATCH 02/18] Add vnc --- browsergateway/main.go | 1 + browsergateway/vnc.go | 96 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 browsergateway/vnc.go diff --git a/browsergateway/main.go b/browsergateway/main.go index 0317167..2c580ab 100644 --- a/browsergateway/main.go +++ b/browsergateway/main.go @@ -51,6 +51,7 @@ func New(cfg Config) *Gateway { func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) mux.HandleFunc("/ssh", g.HandleSSH) + mux.HandleFunc("/vnc", g.handleVNC) } // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 0000000..57a93a7 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,96 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" +) + +const ( + vncDialTimeout = 10 * time.Second + vncKeepAlive = 30 * time.Second + vncForwardBufSize = 32 * 1024 +) + +// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend. +// It follows the same auth-token-in-query-param pattern as handleSSH. +// +// Query parameters: +// +// authToken – shared secret matching the -auth-token flag +// host – VNC backend hostname or IP +// port – VNC backend port (default: 5900) +func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + if host == "" { + http.Error(w, "missing host", http.StatusBadRequest) + return + } + if port == "" { + port = "5900" + } + target := net.JoinHostPort(host, port) + + // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; + // fall back gracefully when the client sends "base64" as well. + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"binary", "base64"}, + }) + if err != nil { + log.Printf("vnc: websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + ctx := r.Context() + if err := serveVNC(ctx, ws, target); err != nil { + log.Printf("vnc: session error (%s): %v", target, err) + } +} + +func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error { + // Dial the VNC backend TCP server. + dialer := &net.Dialer{ + Timeout: vncDialTimeout, + KeepAlive: vncKeepAlive, + } + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + // Expose the WebSocket as a plain net.Conn byte stream (binary frames). + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // Proxy bidirectionally: VNC backend <-> browser. + errc := make(chan error, 2) + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(conn, stream, buf) + errc <- err + }() + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(stream, conn, buf) + errc <- err + }() + + // Return when either direction closes. + err = <-errc + return err +} From bd53edf8e4fa788b1474bf5a555161deb75b4617 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:49:55 -0700 Subject: [PATCH 03/18] Split out rdp --- browsergateway/browsergateway.go | 43 ++++++++++++++++++++++++++++++ browsergateway/{main.go => rdp.go} | 38 -------------------------- 2 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 browsergateway/browsergateway.go rename browsergateway/{main.go => rdp.go} (86%) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go new file mode 100644 index 0000000..2f3a2e5 --- /dev/null +++ b/browsergateway/browsergateway.go @@ -0,0 +1,43 @@ +package browsergateway + +import ( + "net/http" +) + +// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap +// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame +// without wasting memory per session. +const forwardBufSize = 64 * 1024 + +// Config holds the configuration for a Gateway. +type Config struct { + // AuthToken is the shared secret required by RDP clients in the RDCleanPath + // ProxyAuth field, and by SSH clients as the authToken query parameter. + AuthToken string + // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead + // of proxying to an external SSH server. + NativeSSH *NativeSSHConfig +} + +// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. +// Create one with New and mount it via RegisterHandlers or the individual +// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. +type Gateway struct { + authToken string + nativeSSH *NativeSSHConfig +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + nativeSSH: cfg.NativeSSH, + } +} + +// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/rdp", g.HandleRDP) + mux.HandleFunc("/ssh", g.HandleSSH) + mux.HandleFunc("/vnc", g.handleVNC) +} diff --git a/browsergateway/main.go b/browsergateway/rdp.go similarity index 86% rename from browsergateway/main.go rename to browsergateway/rdp.go index 2c580ab..dbd58e0 100644 --- a/browsergateway/main.go +++ b/browsergateway/rdp.go @@ -16,44 +16,6 @@ import ( "github.com/coder/websocket" ) -// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap -// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame -// without wasting memory per session. -const forwardBufSize = 64 * 1024 - -// Config holds the configuration for a Gateway. -type Config struct { - // AuthToken is the shared secret required by RDP clients in the RDCleanPath - // ProxyAuth field, and by SSH clients as the authToken query parameter. - AuthToken string - // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead - // of proxying to an external SSH server. - NativeSSH *NativeSSHConfig -} - -// Gateway is a browser-based RDP/SSH WebSocket proxy. -// Create one with New and mount it via RegisterHandlers or the individual -// HandleRDP / HandleSSH http.HandlerFunc methods. -type Gateway struct { - authToken string - nativeSSH *NativeSSHConfig -} - -// New creates a new Gateway from the provided Config. -func New(cfg Config) *Gateway { - return &Gateway{ - authToken: cfg.AuthToken, - nativeSSH: cfg.NativeSSH, - } -} - -// RegisterHandlers registers the /jet/rdp and /jet/ssh routes on mux. -func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { - mux.HandleFunc("/rdp", g.HandleRDP) - mux.HandleFunc("/ssh", g.HandleSSH) - mux.HandleFunc("/vnc", g.handleVNC) -} - // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() From 559b7021fece50378719f2ed85de6eb7022cf048 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 16:24:26 -0700 Subject: [PATCH 04/18] Basic browser gateway target support --- browsergateway/browsergateway.go | 77 +++++++++++++++++++++++++++ browsergateway/rdp.go | 8 +++ browsergateway/ssh.go | 6 +++ browsergateway/vnc.go | 6 +++ main.go | 91 +++++++++++++++++++++++++------- 5 files changed, 170 insertions(+), 18 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 2f3a2e5..56faffc 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,7 +1,10 @@ package browsergateway import ( + "errors" + "net" "net/http" + "sync" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -9,6 +12,24 @@ import ( // without wasting memory per session. const forwardBufSize = 64 * 1024 +// ListenPort is the port the browser gateway HTTP server listens on inside the +// WireGuard netstack. This is a fixed value shared between newt and pangolin. +const ListenPort = 8082 + +// HardcodedAuthToken is a temporary shared secret used during development. +// TODO: replace with a per-session token negotiated with pangolin. +const HardcodedAuthToken = "pangolin-browser-gateway-dev" + +// Target represents an allowed proxy destination for the browser gateway. +// Only connections whose (Type, Destination, DestinationPort) match a +// registered Target will be forwarded; all others are rejected. +type Target struct { + ID int + Type string // "rdp" | "ssh" | "vnc" + Destination string + DestinationPort int +} + // Config holds the configuration for a Gateway. type Config struct { // AuthToken is the shared secret required by RDP clients in the RDCleanPath @@ -25,6 +46,11 @@ type Config struct { type Gateway struct { authToken string nativeSSH *NativeSSHConfig + + mu sync.RWMutex + targets map[int]Target // keyed by Target.ID + + server *http.Server } // New creates a new Gateway from the provided Config. @@ -32,9 +58,60 @@ func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, nativeSSH: cfg.NativeSSH, + targets: make(map[int]Target), } } +// SetTargets replaces the entire allowed-destination list atomically. +func (g *Gateway) SetTargets(targets []Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets = make(map[int]Target, len(targets)) + for _, t := range targets { + g.targets[t.ID] = t + } +} + +// AddTarget adds or updates a single allowed destination. +func (g *Gateway) AddTarget(t Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets[t.ID] = t +} + +// RemoveTarget removes an allowed destination by its ID. +func (g *Gateway) RemoveTarget(id int) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.targets, id) +} + +// isAllowed reports whether a connection to (targetType, host, port) is +// permitted by the current target list. +func (g *Gateway) isAllowed(targetType, host string, port int) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType && t.Destination == host && t.DestinationPort == port { + return true + } + } + return false +} + +// Start serves the browser gateway HTTP server on the provided listener. +// It returns nil when the listener is closed (normal shutdown). +func (g *Gateway) Start(ln net.Listener) error { + mux := http.NewServeMux() + g.RegisterHandlers(mux) + g.server = &http.Server{Handler: mux} + err := g.server.Serve(ln) + if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index dbd58e0..78046b3 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -68,6 +69,13 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { target = net.JoinHostPort(target, "3389") } + // Validate destination against the registered target allowlist. + rdpHost, rdpPortStr, _ := net.SplitHostPort(target) + rdpPort, _ := strconv.Atoi(rdpPortStr) + if !g.isAllowed("rdp", rdpHost, rdpPort) { + return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + } + log.Printf("Connecting to RDP server %s", target) // -- Open TCP connection to the destination RDP server -- diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 290eb5a..0596f99 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -8,6 +8,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -56,6 +57,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { if port == "" { port = "22" } + sshPort, _ := strconv.Atoi(port) + if !g.isAllowed("ssh", host, sshPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target = net.JoinHostPort(host, port) } diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 57a93a7..55ec439 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -7,6 +7,7 @@ import ( "log" "net" "net/http" + "strconv" "time" "github.com/coder/websocket" @@ -40,6 +41,11 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { if port == "" { port = "5900" } + vncPort, _ := strconv.Atoi(port) + if !g.isAllowed("vnc", host, vncPort) { + http.Error(w, "destination not allowed", http.StatusForbidden) + return + } target := net.JoinHostPort(host, port) // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; diff --git a/main.go b/main.go index 448f71d..c86a35c 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "time" "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" @@ -40,15 +41,23 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type BrowserGatewayTarget struct { + ID int `json:"id"` + Type string `json:"type"` + Destination string `json:"destination"` + DestinationPort int `json:"destinationPort"` +} + type WgData struct { - Endpoint string `json:"endpoint"` - RelayPort uint16 `json:"relayPort"` - PublicKey string `json:"publicKey"` - ServerIP string `json:"serverIP"` - TunnelIP string `json:"tunnelIP"` - Targets TargetsByType `json:"targets"` - HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` - ChainId string `json:"chainId"` + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -134,6 +143,8 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() pendingPingChainId string healthFile string useNativeInterface bool @@ -150,15 +161,15 @@ var ( newtVersion = "version_replaceme" // Observability/metrics flags - metricsEnabled bool - otlpEnabled bool - adminAddr string - region string - metricsAsyncBytes bool - pprofEnabled bool - blueprintFile string - provisioningBlueprintFile string - noCloud bool + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool // New mTLS configuration variables tlsClientCert string @@ -741,6 +752,13 @@ func runNewtMain(ctx context.Context) { pingStopChan = nil } + // Shutdown browser gateway if running + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + browserGateway = nil + } + // Stop proxy manager if running if pm != nil { pm.Stop() @@ -947,6 +965,43 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if err != nil { logger.Error("Failed to start proxy manager: %v", err) } + + // Start browser gateway if targets are present + if len(wgData.BrowserGatewayTargets) > 0 { + // Shutdown any existing gateway first + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + } + + bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets)) + for _, t := range wgData.BrowserGatewayTargets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{ + AuthToken: browsergateway.HardcodedAuthToken, + }) + browserGateway.SetTargets(bgTargets) + + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } }) client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { @@ -1841,7 +1896,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } else { logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT") } - + sendBlueprint(client, blueprintFile) if client.WasJustProvisioned() { logger.Info("Provisioning detected – sending provisioning blueprint") From 710408ac670bb3f5ded6ba612f2643c406323f18 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 13:46:39 -0700 Subject: [PATCH 05/18] Support per target auth token --- browsergateway/browsergateway.go | 38 ++++++++++++++++++-------------- browsergateway/rdp.go | 13 ++++------- browsergateway/ssh.go | 15 +++++++------ browsergateway/vnc.go | 10 +++------ main.go | 6 ++--- 5 files changed, 40 insertions(+), 42 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 56faffc..4f99a29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -1,9 +1,11 @@ package browsergateway import ( + "crypto/subtle" "errors" "net" "net/http" + "strings" "sync" ) @@ -14,26 +16,25 @@ const forwardBufSize = 64 * 1024 // ListenPort is the port the browser gateway HTTP server listens on inside the // WireGuard netstack. This is a fixed value shared between newt and pangolin. -const ListenPort = 8082 - -// HardcodedAuthToken is a temporary shared secret used during development. -// TODO: replace with a per-session token negotiated with pangolin. -const HardcodedAuthToken = "pangolin-browser-gateway-dev" +// Targets do not overlap with this port because they start at 40000. +const ListenPort = 39999 // Target represents an allowed proxy destination for the browser gateway. -// Only connections whose (Type, Destination, DestinationPort) match a +// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a // registered Target will be forwarded; all others are rejected. type Target struct { ID int Type string // "rdp" | "ssh" | "vnc" Destination string DestinationPort int + AuthToken string // per-target secret; must match the token supplied by the client } // Config holds the configuration for a Gateway. type Config struct { - // AuthToken is the shared secret required by RDP clients in the RDCleanPath - // ProxyAuth field, and by SSH clients as the authToken query parameter. + // AuthToken is used only for NativeSSH mode (which has no external target + // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are + // stored per-Target and validated by isAllowed. AuthToken string // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead // of proxying to an external SSH server. @@ -86,14 +87,15 @@ func (g *Gateway) RemoveTarget(id int) { delete(g.targets, id) } -// isAllowed reports whether a connection to (targetType, host, port) is -// permitted by the current target list. -func (g *Gateway) isAllowed(targetType, host string, port int) bool { +// isAllowed reports whether a connection to (targetType, host, port) with the +// given authToken is permitted. The token is compared against the per-target +// AuthToken using constant-time comparison to prevent timing attacks. +func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool { g.mu.RLock() defer g.mu.RUnlock() for _, t := range g.targets { if t.Type == targetType && t.Destination == host && t.DestinationPort == port { - return true + return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 } } return false @@ -106,7 +108,11 @@ func (g *Gateway) Start(ln net.Listener) error { g.RegisterHandlers(mux) g.server = &http.Server{Handler: mux} err := g.server.Serve(ln) - if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, http.ErrServerClosed) || + strings.Contains(err.Error(), "use of closed") || + strings.Contains(err.Error(), "invalid state") { return nil } return err @@ -114,7 +120,7 @@ func (g *Gateway) Start(ln net.Listener) error { // RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { - mux.HandleFunc("/rdp", g.HandleRDP) - mux.HandleFunc("/ssh", g.HandleSSH) - mux.HandleFunc("/vnc", g.handleVNC) + mux.HandleFunc("/gateway/rdp", g.HandleRDP) + mux.HandleFunc("/gateway/ssh", g.HandleSSH) + mux.HandleFunc("/gateway/vnc", g.handleVNC) } diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index 78046b3..1df5a13 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "crypto/tls" "encoding/binary" "errors" @@ -58,22 +57,18 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { return errors.New("RDCleanPath missing X224 connection PDU") } - // Constant-time comparison to avoid leaking the expected token via timing. - if subtle.ConstantTimeCompare([]byte(pdu.ProxyAuth), []byte(g.authToken)) != 1 { - return errors.New("RDCleanPath ProxyAuth token mismatch") - } - target := pdu.Destination // Default port for RDP if not specified. if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { target = net.JoinHostPort(target, "3389") } - // Validate destination against the registered target allowlist. + // Validate destination against the registered target allowlist, + // including per-target auth token. rdpHost, rdpPortStr, _ := net.SplitHostPort(target) rdpPort, _ := strconv.Atoi(rdpPortStr) - if !g.isAllowed("rdp", rdpHost, rdpPort) { - return fmt.Errorf("RDP destination %s is not in the allowed target list", target) + if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) { + return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target) } log.Printf("Connecting to RDP server %s", target) diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 0596f99..f8e83d4 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -37,12 +37,7 @@ type sshServerMsg struct { func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // -- Validate auth token from query parameter before upgrading -- token := r.URL.Query().Get("authToken") - if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } // In proxy mode we also need host + username from query params. var target, username string @@ -58,11 +53,17 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { port = "22" } sshPort, _ := strconv.Atoi(port) - if !g.isAllowed("ssh", host, sshPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + if !g.isAllowed("ssh", host, sshPort, token) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target = net.JoinHostPort(host, port) + } else { + // Native SSH mode: validate against the global gateway token. + if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 55ec439..5d917a4 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "io" "log" "net" @@ -28,10 +27,6 @@ const ( // host – VNC backend hostname or IP // port – VNC backend port (default: 5900) func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { - if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } host := r.URL.Query().Get("host") port := r.URL.Query().Get("port") if host == "" { @@ -42,8 +37,9 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { port = "5900" } vncPort, _ := strconv.Atoi(port) - if !g.isAllowed("vnc", host, vncPort) { - http.Error(w, "destination not allowed", http.StatusForbidden) + authToken := r.URL.Query().Get("authToken") + if !g.isAllowed("vnc", host, vncPort, authToken) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) return } target := net.JoinHostPort(host, port) diff --git a/main.go b/main.go index c86a35c..566c105 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ type BrowserGatewayTarget struct { Type string `json:"type"` Destination string `json:"destination"` DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` } type WgData struct { @@ -981,12 +982,11 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( Type: t.Type, Destination: t.Destination, DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, }) } - browserGateway = browsergateway.New(browsergateway.Config{ - AuthToken: browsergateway.HardcodedAuthToken, - }) + browserGateway = browsergateway.New(browsergateway.Config{}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) From 28cddf7066b1ed5af3667868da173d3945ca33dd Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 14:59:37 -0700 Subject: [PATCH 06/18] Support add and remove for gateway --- main.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/main.go b/main.go index 566c105..1727f2e 100644 --- a/main.go +++ b/main.go @@ -1872,6 +1872,94 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) + // Register handler for adding browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway add message") + + type BrowserGatewayAddData struct { + Targets []BrowserGatewayTarget `json:"targets"` + } + + var addData BrowserGatewayAddData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &addData); err != nil { + logger.Error("Error unmarshaling browser gateway add data: %v", err) + return + } + + if len(addData.Targets) == 0 { + return + } + + // If the gateway doesn't exist yet but we have a tunnel, start it + if browserGateway == nil && tnet != nil { + browserGateway = browsergateway.New(browsergateway.Config{}) + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + browserGateway = nil + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot add targets") + return + } + + for _, t := range addData.Targets { + browserGateway.AddTarget(browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + logger.Debug("Added browser gateway target %d", t.ID) + } + }) + + // Register handler for removing browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway remove message") + + type BrowserGatewayRemoveData struct { + IDs []int `json:"ids"` + } + + var removeData BrowserGatewayRemoveData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway remove data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &removeData); err != nil { + logger.Error("Error unmarshaling browser gateway remove data: %v", err) + return + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot remove targets") + return + } + + for _, id := range removeData.IDs { + browserGateway.RemoveTarget(id) + logger.Debug("Removed browser gateway target %d", id) + } + }) + client.OnConnect(func() error { publicKey = privateKey.PublicKey() logger.Debug("Public key: %s", publicKey) From 1f8be1d826aa6653d617fd6d2d427d6ccbe7b1e4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 16:07:05 -0700 Subject: [PATCH 07/18] Support ssh private key --- browsergateway/ssh.go | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index f8e83d4..dc5bb13 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -18,11 +18,12 @@ import ( // sshClientMsg is a JSON message sent from the browser to the proxy. type sshClientMsg struct { // type: "auth" | "data" | "resize" - Type string `json:"type"` - Password string `json:"password,omitempty"` // used when type="auth" - Data string `json:"data,omitempty"` // used when type="data" - Cols uint32 `json:"cols,omitempty"` // used when type="resize" - Rows uint32 `json:"rows,omitempty"` // used when type="resize" + Type string `json:"type"` + Password string `json:"password,omitempty"` // used when type="auth" + PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Data string `json:"data,omitempty"` // used when type="data" + Cols uint32 `json:"cols,omitempty"` // used when type="resize" + Rows uint32 `json:"rows,omitempty"` // used when type="resize" } // sshServerMsg is a JSON message sent from the proxy back to the browser. @@ -99,14 +100,29 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, return fmt.Errorf("expected auth message, got: %s", authBytes) } password := authMsg.Password + privateKey := authMsg.PrivateKey - // -- Dial the SSH server -- + // Build the list of auth methods. Private key takes priority when provided. + var authMethods []ssh.AuthMethod + if privateKey != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKey)) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err)) + return fmt.Errorf("parse private key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + if password != "" { + authMethods = append(authMethods, ssh.Password(password)) + } + if len(authMethods) == 0 { + sendSSHError(ctx, ws, "No authentication credentials provided") + return fmt.Errorf("no auth credentials") + } log.Printf("SSH: connecting to %s as %s", target, username) sshCfg := &ssh.ClientConfig{ User: username, - Auth: []ssh.AuthMethod{ - ssh.Password(password), - }, + Auth: authMethods, // HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev // proxy. In production, verify against a known-hosts store. HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec From e0d65d81258b14bbe8e0f93e1bf213391d3ce937 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:19:39 -0700 Subject: [PATCH 08/18] Basic ssh server for private resources created --- browsergateway/ssh_native.go | 33 +--- main.go | 9 + nativessh/pty.go | 52 ++++++ nativessh/server.go | 321 +++++++++++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 25 deletions(-) create mode 100644 nativessh/pty.go create mode 100644 nativessh/server.go diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index b65eb83..91509b0 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -5,11 +5,9 @@ import ( "encoding/json" "fmt" "log" - "os" - "os/exec" "github.com/coder/websocket" - "github.com/creack/pty" + "github.com/fosrl/newt/nativessh" ) // NativeSSHConfig holds configuration for the native PTY/shell mode. @@ -34,26 +32,14 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS return fmt.Errorf("expected auth message, got: %s", authBytes) } - shell := cfg.Shell - if shell == "" { - shell = "/bin/sh" - } + log.Printf("SSH native: spawning shell") - log.Printf("SSH native: spawning %s", shell) - - cmd := exec.CommandContext(ctx, shell) - cmd.Env = append(os.Environ(), "TERM=xterm-256color") - - // Start the command with a PTY attached. - ptmx, err := pty.Start(cmd) + sess, err := nativessh.NewPTYSession(cfg.Shell) if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) - return fmt.Errorf("pty start: %w", err) + return fmt.Errorf("pty session: %w", err) } - defer func() { - _ = ptmx.Close() - _ = cmd.Wait() - }() + defer sess.Close() // Cancel context to unblock the WebSocket read loop when the shell exits. sessCtx, cancelSess := context.WithCancel(ctx) @@ -64,7 +50,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS defer cancelSess() buf := make([]byte, 4096) for { - n, readErr := ptmx.Read(buf) + n, readErr := sess.Read(buf) if n > 0 { msg := sshServerMsg{Type: "data", Data: string(buf[:n])} b, _ := json.Marshal(msg) @@ -90,15 +76,12 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS } switch msg.Type { case "data": - if _, writeErr := ptmx.Write([]byte(msg.Data)); writeErr != nil { + if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil { return fmt.Errorf("write pty: %w", writeErr) } case "resize": if msg.Cols > 0 && msg.Rows > 0 { - _ = pty.Setsize(ptmx, &pty.Winsize{ - Cols: uint16(msg.Cols), - Rows: uint16(msg.Rows), - }) + _ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows)) } } } diff --git a/main.go b/main.go index 1727f2e..2ccfbda 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -534,6 +535,14 @@ func runNewtMain(ctx context.Context) { logger.Fatal("Failed to start auth daemon: %v", err) } } + + // Start native SSH server for testing (listens on :2222). + go func() { + srv := nativessh.NewServer(nativessh.ServerConfig{}) + if err := srv.ListenAndServe(); err != nil { + logger.Error("Native SSH server error: %v", err) + } + }() logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) diff --git a/nativessh/pty.go b/nativessh/pty.go new file mode 100644 index 0000000..f35531a --- /dev/null +++ b/nativessh/pty.go @@ -0,0 +1,52 @@ +package nativessh + +import ( + "fmt" + "os" + "os/exec" + + "github.com/creack/pty" +) + +// PTYSession is a running shell process attached to a PTY. +// It implements io.ReadWriteCloser so it can be bridged to any transport. +type PTYSession struct { + ptmx *os.File + cmd *exec.Cmd +} + +// NewPTYSession spawns shell in a PTY. If shell is empty, /bin/sh is used. +func NewPTYSession(shell string) (*PTYSession, error) { + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} + +// Read reads output from the PTY. +func (p *PTYSession) Read(b []byte) (int, error) { + return p.ptmx.Read(b) +} + +// Write writes input to the PTY. +func (p *PTYSession) Write(b []byte) (int, error) { + return p.ptmx.Write(b) +} + +// Resize changes the PTY window size. +func (p *PTYSession) Resize(cols, rows uint16) error { + return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// Close closes the PTY and waits for the child process to exit. +func (p *PTYSession) Close() error { + err := p.ptmx.Close() + _ = p.cmd.Wait() + return err +} diff --git a/nativessh/server.go b/nativessh/server.go new file mode 100644 index 0000000..8cedf94 --- /dev/null +++ b/nativessh/server.go @@ -0,0 +1,321 @@ +package nativessh + +import ( + "bufio" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "io" + "log" + "net" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +const ( + // DefaultCAKeyPath is the path to the SSH CA public key used to validate + // client certificates. + DefaultCAKeyPath = "/tmp/newt/ssh_ca.pub" + // DefaultPrincipalsPath is the path to a file listing allowed SSH + // certificate principals, one per line. + DefaultPrincipalsPath = "/tmp/newt/ssh_principals" + // DefaultHostKeyPath is where the server's Ed25519 host key is persisted. + // A new key is generated and saved here on first run. + DefaultHostKeyPath = "/tmp/newt/ssh_host_key" +) + +// ServerConfig holds configuration for the native SSH server. +type ServerConfig struct { + // ListenAddr is the TCP address to listen on. Defaults to ":2222". + ListenAddr string + // CAKeyPath is the path to the CA public key file (authorized_keys format). + // Defaults to DefaultCAKeyPath. + CAKeyPath string + // PrincipalsPath is the path to a file of allowed principals, one per line. + // Defaults to DefaultPrincipalsPath. + PrincipalsPath string + // HostKeyPath is where the Ed25519 host private key is stored (PEM). + // Defaults to DefaultHostKeyPath. Generated on first run if absent. + HostKeyPath string + // Shell is the shell executable to spawn. Defaults to /bin/sh. + Shell string +} + +// Server is a simple SSH server that authenticates clients via SSH certificate +// auth only. Certificates must be signed by the configured CA and the +// connecting username must appear in both the certificate's principal list and +// the local principals file. +type Server struct { + cfg ServerConfig +} + +// NewServer creates a new Server. Zero-value fields in cfg are replaced with +// defaults. +func NewServer(cfg ServerConfig) *Server { + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":2222" + } + if cfg.CAKeyPath == "" { + cfg.CAKeyPath = DefaultCAKeyPath + } + if cfg.PrincipalsPath == "" { + cfg.PrincipalsPath = DefaultPrincipalsPath + } + if cfg.HostKeyPath == "" { + cfg.HostKeyPath = DefaultHostKeyPath + } + if cfg.Shell == "" { + cfg.Shell = "/bin/sh" + } + return &Server{cfg: cfg} +} + +// ListenAndServe starts the SSH server and blocks until the listener is closed. +func (s *Server) ListenAndServe() error { + caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) + if err != nil { + return fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) + } + + principals, err := loadPrincipals(s.cfg.PrincipalsPath) + if err != nil { + return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) + } + + hostSigner, err := generateOrLoadHostKey(s.cfg.HostKeyPath) + if err != nil { + return fmt.Errorf("host key: %w", err) + } + + sshCfg := &ssh.ServerConfig{ + PublicKeyCallback: makeCertAuthCallback(caKey, principals), + } + sshCfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", s.cfg.ListenAddr) + if err != nil { + return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err) + } + defer ln.Close() + log.Printf("nativessh: server listening on %s", s.cfg.ListenAddr) + + for { + conn, err := ln.Accept() + if err != nil { + return fmt.Errorf("accept: %w", err) + } + go s.handleConn(conn, sshCfg) + } +} + +func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { + defer conn.Close() + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) + return + } + defer sshConn.Close() + log.Printf("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User()) + + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + log.Printf("nativessh: channel accept error: %v", err) + return + } + go s.handleSession(ch, requests) + } +} + +// handleSession drives a single SSH session channel. It waits for a pty-req +// followed by a shell request and then bridges the PTY to the channel. +func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { + defer ch.Close() + + var ( + sess *PTYSession + started bool + ) + + for req := range requests { + switch req.Type { + case "pty-req": + var err error + if sess == nil { + sess, err = NewPTYSession(s.cfg.Shell) + if err != nil { + log.Printf("nativessh: PTY start error: %v", err) + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + } + cols, rows := parsePTYReq(req.Payload) + _ = sess.Resize(cols, rows) + if req.WantReply { + _ = req.Reply(true, nil) + } + + case "shell": + if req.WantReply { + _ = req.Reply(true, nil) + } + if started || sess == nil { + continue + } + started = true + // PTY output → SSH channel. + go func() { + _, _ = io.Copy(ch, sess) + _ = ch.CloseWrite() + sess.Close() //nolint:errcheck + }() + // SSH channel input → PTY stdin. + go func() { + _, _ = io.Copy(sess, ch) + }() + + case "window-change": + if sess != nil { + cols, rows := parseWindowChange(req.Payload) + _ = sess.Resize(cols, rows) + } + if req.WantReply { + _ = req.Reply(true, nil) + } + + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } + + if sess != nil && !started { + sess.Close() //nolint:errcheck + } +} + +// makeCertAuthCallback returns an ssh.PublicKeyCallback that accepts only +// SSH user certificates that are: +// 1. Signed by caKey. +// 2. Listing the connecting username in ValidPrincipals (standard cert auth). +// 3. Whose connecting username is also in the local allowedPrincipals set. +func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]struct{}) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + perms, err := checker.Authenticate(meta, key) + if err != nil { + return nil, err + } + if _, ok := allowedPrincipals[meta.User()]; !ok { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + return perms, nil + } +} + +// generateOrLoadHostKey loads an Ed25519 host key from path, or generates and +// saves a new one if the file does not exist. +func generateOrLoadHostKey(path string) (ssh.Signer, error) { + data, err := os.ReadFile(path) + if err == nil { + return ssh.ParsePrivateKey(data) + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read host key %s: %w", path, err) + } + + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate host key: %w", err) + } + pemBlock, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return nil, fmt.Errorf("marshal host key: %w", err) + } + pemData := pem.EncodeToMemory(pemBlock) + if writeErr := os.WriteFile(path, pemData, 0600); writeErr != nil { + log.Printf("nativessh: warning: could not persist host key to %s: %v", path, writeErr) + } + log.Printf("nativessh: generated new Ed25519 host key (saved to %s)", path) + return ssh.NewSignerFromKey(priv) +} + +func loadCAPublicKey(path string) (ssh.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + key, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return nil, err + } + return key, nil +} + +func loadPrincipals(path string) (map[string]struct{}, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + principals := make(map[string]struct{}) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + principals[line] = struct{}{} + } + } + return principals, scanner.Err() +} + +// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2). +type ptyRequestMsg struct { + Term string + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 + Modelist string +} + +func parsePTYReq(payload []byte) (cols, rows uint16) { + var req ptyRequestMsg + if err := ssh.Unmarshal(payload, &req); err != nil { + return 80, 24 + } + return uint16(req.Columns), uint16(req.Rows) +} + +// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7). +type windowChangeMsg struct { + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 +} + +func parseWindowChange(payload []byte) (cols, rows uint16) { + var msg windowChangeMsg + if err := ssh.Unmarshal(payload, &msg); err != nil { + return 80, 24 + } + return uint16(msg.Columns), uint16(msg.Rows) +} From 133311f1c4b0f8694cd40b81bea103bbaf40e46e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:23:11 -0700 Subject: [PATCH 09/18] Pty to find its own shell --- browsergateway/ssh_native.go | 2 +- nativessh/pty.go | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 91509b0..a2ac8e9 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -34,7 +34,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSS log.Printf("SSH native: spawning shell") - sess, err := nativessh.NewPTYSession(cfg.Shell) + sess, err := nativessh.NewPTYSession() if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) return fmt.Errorf("pty session: %w", err) diff --git a/nativessh/pty.go b/nativessh/pty.go index f35531a..ee03295 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -15,11 +15,21 @@ type PTYSession struct { cmd *exec.Cmd } -// NewPTYSession spawns shell in a PTY. If shell is empty, /bin/sh is used. -func NewPTYSession(shell string) (*PTYSession, error) { - if shell == "" { - shell = "/bin/sh" +// findShell returns the path to the best available interactive shell by +// checking preferred shells in order, falling back to /bin/sh. +func findShell() string { + preferred := []string{"zsh", "bash", "fish", "ksh", "sh"} + for _, name := range preferred { + if path, err := exec.LookPath(name); err == nil { + return path + } } + return "/bin/sh" +} + +// NewPTYSession spawns the best available shell in a PTY. +func NewPTYSession() (*PTYSession, error) { + shell := findShell() cmd := exec.Command(shell) cmd.Env = append(os.Environ(), "TERM=xterm-256color") ptmx, err := pty.Start(cmd) From 388795ecf4d691268bd9ed2f234de64ce7dc01cd Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:16:46 -0700 Subject: [PATCH 10/18] Keep host key in memory --- nativessh/server.go | 43 ++++++------------------------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index 8cedf94..0e5cbe9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -4,7 +4,6 @@ import ( "bufio" "crypto/ed25519" "crypto/rand" - "encoding/pem" "fmt" "io" "log" @@ -22,9 +21,6 @@ const ( // DefaultPrincipalsPath is the path to a file listing allowed SSH // certificate principals, one per line. DefaultPrincipalsPath = "/tmp/newt/ssh_principals" - // DefaultHostKeyPath is where the server's Ed25519 host key is persisted. - // A new key is generated and saved here on first run. - DefaultHostKeyPath = "/tmp/newt/ssh_host_key" ) // ServerConfig holds configuration for the native SSH server. @@ -37,11 +33,6 @@ type ServerConfig struct { // PrincipalsPath is the path to a file of allowed principals, one per line. // Defaults to DefaultPrincipalsPath. PrincipalsPath string - // HostKeyPath is where the Ed25519 host private key is stored (PEM). - // Defaults to DefaultHostKeyPath. Generated on first run if absent. - HostKeyPath string - // Shell is the shell executable to spawn. Defaults to /bin/sh. - Shell string } // Server is a simple SSH server that authenticates clients via SSH certificate @@ -64,12 +55,6 @@ func NewServer(cfg ServerConfig) *Server { if cfg.PrincipalsPath == "" { cfg.PrincipalsPath = DefaultPrincipalsPath } - if cfg.HostKeyPath == "" { - cfg.HostKeyPath = DefaultHostKeyPath - } - if cfg.Shell == "" { - cfg.Shell = "/bin/sh" - } return &Server{cfg: cfg} } @@ -85,7 +70,7 @@ func (s *Server) ListenAndServe() error { return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) } - hostSigner, err := generateOrLoadHostKey(s.cfg.HostKeyPath) + hostSigner, err := generateHostKey() if err != nil { return fmt.Errorf("host key: %w", err) } @@ -152,7 +137,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { case "pty-req": var err error if sess == nil { - sess, err = NewPTYSession(s.cfg.Shell) + sess, err = NewPTYSession() if err != nil { log.Printf("nativessh: PTY start error: %v", err) if req.WantReply { @@ -230,30 +215,14 @@ func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]stru } } -// generateOrLoadHostKey loads an Ed25519 host key from path, or generates and -// saves a new one if the file does not exist. -func generateOrLoadHostKey(path string) (ssh.Signer, error) { - data, err := os.ReadFile(path) - if err == nil { - return ssh.ParsePrivateKey(data) - } - if !os.IsNotExist(err) { - return nil, fmt.Errorf("read host key %s: %w", path, err) - } - +// generateHostKey generates a fresh ephemeral Ed25519 host key in memory. +// A new key is created on every server start; nothing is written to disk. +func generateHostKey() (ssh.Signer, error) { _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, fmt.Errorf("generate host key: %w", err) } - pemBlock, err := ssh.MarshalPrivateKey(priv, "") - if err != nil { - return nil, fmt.Errorf("marshal host key: %w", err) - } - pemData := pem.EncodeToMemory(pemBlock) - if writeErr := os.WriteFile(path, pemData, 0600); writeErr != nil { - log.Printf("nativessh: warning: could not persist host key to %s: %v", path, writeErr) - } - log.Printf("nativessh: generated new Ed25519 host key (saved to %s)", path) + log.Printf("nativessh: generated ephemeral Ed25519 host key") return ssh.NewSignerFromKey(priv) } From 9640ada8b7a823e605e45f319457340c3981b8d0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:30:17 -0700 Subject: [PATCH 11/18] Bind the ssh server to the newt ip --- clients/clients.go | 44 +++++++++++++++++++++++++++++++++++++++++++ main.go | 8 -------- nativessh/server.go | 46 +++++++++++++++++++++++++++++---------------- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 3862160..6c4d968 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -19,6 +19,7 @@ import ( newtDevice "github.com/fosrl/newt/device" "github.com/fosrl/newt/holepunch" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/netstack2" "github.com/fosrl/newt/network" "github.com/fosrl/newt/util" @@ -108,6 +109,8 @@ type WireGuardService struct { sharedBind *bind.SharedBind holePunchManager *holepunch.Manager useNativeInterface bool + // SSH server running on the clients' netstack + sshServer *sshServerHandle // Direct UDP relay from main tunnel to clients' WireGuard directRelayStop chan struct{} directRelayWg sync.WaitGroup @@ -211,6 +214,12 @@ func (s *WireGuardService) Close() { s.stopGetConfig = nil } + // Stop SSH server before tearing down the netstack + if s.sshServer != nil { + s.sshServer.stop() + s.sshServer = nil + } + // Flush access logs before tearing down the tunnel if s.tnet != nil { if ph := s.tnet.GetProxyHandler(); ph != nil { @@ -890,6 +899,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { logger.Error("Failed to start WireGuard tester server: %v", err) } + // Start the SSH server on the clients' netstack (port 22). + if h, sshErr := startSSHOnNetstack(s.tnet); sshErr != nil { + logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) + } else { + s.sshServer = h + } + // Note: we already unlocked above, so don't use defer unlock return nil } @@ -1563,3 +1579,31 @@ func (s *WireGuardService) filterReadOnlyFields(config string) string { return strings.Join(filteredLines, "\n") } + +// sshServerHandle holds the listener so the SSH server can be stopped by +// closing it. +type sshServerHandle struct { + ln net.Listener +} + +func (h *sshServerHandle) stop() { + _ = h.ln.Close() +} + +// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack +// and starts serving SSH connections on it in the background. The returned +// handle can be used to stop the server by closing the listener. +func startSSHOnNetstack(tnet *netstack2.Net) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{}) + ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) + if err != nil { + return nil, fmt.Errorf("listen on netstack port 22: %w", err) + } + h := &sshServerHandle{ln: ln} + go func() { + if err := srv.Serve(ln); err != nil { + logger.Debug("nativessh: clients netstack server stopped: %v", err) + } + }() + return h, nil +} diff --git a/main.go b/main.go index 2ccfbda..cfeacf2 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,6 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" - "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -536,13 +535,6 @@ func runNewtMain(ctx context.Context) { } } - // Start native SSH server for testing (listens on :2222). - go func() { - srv := nativessh.NewServer(nativessh.ServerConfig{}) - if err := srv.ListenAndServe(); err != nil { - logger.Error("Native SSH server error: %v", err) - } - }() logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) diff --git a/nativessh/server.go b/nativessh/server.go index 0e5cbe9..d5c8b1f 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -58,42 +58,56 @@ func NewServer(cfg ServerConfig) *Server { return &Server{cfg: cfg} } -// ListenAndServe starts the SSH server and blocks until the listener is closed. -func (s *Server) ListenAndServe() error { +// buildSSHConfig loads keys/principals and builds the ssh.ServerConfig. +func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) if err != nil { - return fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) + return nil, fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) } principals, err := loadPrincipals(s.cfg.PrincipalsPath) if err != nil { - return fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) + return nil, fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) } hostSigner, err := generateHostKey() if err != nil { - return fmt.Errorf("host key: %w", err) + return nil, fmt.Errorf("host key: %w", err) } - sshCfg := &ssh.ServerConfig{ + cfg := &ssh.ServerConfig{ PublicKeyCallback: makeCertAuthCallback(caKey, principals), } - sshCfg.AddHostKey(hostSigner) + cfg.AddHostKey(hostSigner) + return cfg, nil +} +// Serve accepts connections on ln and handles them. It returns when ln is +// closed or a non-temporary Accept error occurs. +func (s *Server) Serve(ln net.Listener) error { + sshCfg, err := s.buildSSHConfig() + if err != nil { + return err + } + log.Printf("nativessh: server listening on %s", ln.Addr()) + for { + conn, err := ln.Accept() + if err != nil { + return err + } + go s.handleConn(conn, sshCfg) + } +} + +// ListenAndServe starts the SSH server on the host network and blocks until +// the listener is closed. +func (s *Server) ListenAndServe() error { ln, err := net.Listen("tcp", s.cfg.ListenAddr) if err != nil { return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err) } defer ln.Close() - log.Printf("nativessh: server listening on %s", s.cfg.ListenAddr) - - for { - conn, err := ln.Accept() - if err != nil { - return fmt.Errorf("accept: %w", err) - } - go s.handleConn(conn, sshCfg) - } + return s.Serve(ln) } func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { From f217ddf6a7b41baec00af570628e7f226ff848cd Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:38:38 -0700 Subject: [PATCH 12/18] Fix exit not closing session --- nativessh/pty.go | 33 ++++++++++++++++++++++++++++++--- nativessh/server.go | 10 ++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/nativessh/pty.go b/nativessh/pty.go index ee03295..cb5fa88 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,9 +1,11 @@ package nativessh import ( + "errors" "fmt" "os" "os/exec" + "sync" "github.com/creack/pty" ) @@ -11,8 +13,10 @@ import ( // PTYSession is a running shell process attached to a PTY. // It implements io.ReadWriteCloser so it can be bridged to any transport. type PTYSession struct { - ptmx *os.File - cmd *exec.Cmd + ptmx *os.File + cmd *exec.Cmd + waitOnce sync.Once + exitCode int } // findShell returns the path to the best available interactive shell by @@ -54,9 +58,32 @@ func (p *PTYSession) Resize(cols, rows uint16) error { return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) } +// wait waits for the child process to exit exactly once and records its exit +// code. Safe to call concurrently or multiple times. +func (p *PTYSession) wait() { + p.waitOnce.Do(func() { + err := p.cmd.Wait() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + p.exitCode = exitErr.ExitCode() + return + } + p.exitCode = 1 + } + }) +} + +// ExitCode waits for the shell process to exit and returns its exit code. +// It is safe to call before or after Close. +func (p *PTYSession) ExitCode() int { + p.wait() + return p.exitCode +} + // Close closes the PTY and waits for the child process to exit. func (p *PTYSession) Close() error { err := p.ptmx.Close() - _ = p.cmd.Wait() + p.wait() return err } diff --git a/nativessh/server.go b/nativessh/server.go index d5c8b1f..db91d08 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -177,8 +177,18 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { // PTY output → SSH channel. go func() { _, _ = io.Copy(ch, sess) + // Notify the client of the shell's exit status so it can + // disconnect cleanly instead of requiring a manual disconnect. + exitCode := sess.ExitCode() + exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)}) + _, _ = ch.SendRequest("exit-status", false, exitStatusPayload) _ = ch.CloseWrite() sess.Close() //nolint:errcheck + // Close the channel so the ssh library closes the requests + // channel, which unblocks the for-range loop in handleSession + // and allows the deferred ch.Close() to run. Without this, + // handleSession blocks forever waiting for requests to drain. + _ = ch.Close() }() // SSH channel input → PTY stdin. go func() { From 631eab53d1f1d663d5bb458e13403a5742e5e1f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 21:09:51 -0700 Subject: [PATCH 13/18] Add credentials store and use the auth daemon info --- clients.go | 5 +- clients/clients.go | 16 ++++- main.go | 27 +++++++- nativessh/server.go | 153 ++++++++++++++++++++++---------------------- 4 files changed, 118 insertions(+), 83 deletions(-) diff --git a/clients.go b/clients.go index d650eeb..4162eef 100644 --- a/clients.go +++ b/clients.go @@ -7,6 +7,7 @@ import ( wgnetstack "github.com/fosrl/newt/clients" "github.com/fosrl/newt/clients/permissions" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/websocket" "golang.zx2c4.com/wireguard/tun/netstack" ) @@ -14,7 +15,7 @@ import ( var wgService *clients.WireGuardService var ready bool -func setupClients(client *websocket.Client) { +func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { var host = endpoint if strings.HasPrefix(host, "http://") { host = strings.TrimPrefix(host, "http://") @@ -42,6 +43,8 @@ func setupClients(client *websocket.Client) { logger.Fatal("Failed to create WireGuard service: %v", err) } + wgService.SetCredentialStore(credStore) + client.OnTokenUpdate(func(token string) { wgService.SetToken(token) }) diff --git a/clients/clients.go b/clients/clients.go index 6c4d968..325a828 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -111,6 +111,7 @@ type WireGuardService struct { useNativeInterface bool // SSH server running on the clients' netstack sshServer *sshServerHandle + credStore *nativessh.CredentialStore // Direct UDP relay from main tunnel to clients' WireGuard directRelayStop chan struct{} directRelayWg sync.WaitGroup @@ -196,6 +197,13 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string return service, nil } +// SetCredentialStore sets the in-memory SSH credential store used by the +// native SSH server. It must be called before the netstack is configured +// (i.e. before the first newt/wg/receive-config message is processed). +func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) { + s.credStore = store +} + // ReportRTT allows reporting native RTTs to telemetry, rate-limited externally. func (s *WireGuardService) ReportRTT(seconds float64) { if s.serverPubKey == "" { @@ -900,7 +908,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Start the SSH server on the clients' netstack (port 22). - if h, sshErr := startSSHOnNetstack(s.tnet); sshErr != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) } else { s.sshServer = h @@ -1593,8 +1601,10 @@ func (h *sshServerHandle) stop() { // startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack // and starts serving SSH connections on it in the background. The returned // handle can be used to stop the server by closing the listener. -func startSSHOnNetstack(tnet *netstack2.Net) (*sshServerHandle, error) { - srv := nativessh.NewServer(nativessh.ServerConfig{}) +func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{ + Credentials: creds, + }) ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) if err != nil { return nil, fmt.Errorf("listen on netstack port 22: %w", err) diff --git a/main.go b/main.go index cfeacf2..cc03bc2 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -714,8 +715,12 @@ func runNewtMain(ctx context.Context) { var wgData WgData var dockerEventMonitor *docker.EventMonitor + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + sshCredStore := nativessh.NewCredentialStore() + if !disableClients { - setupClients(client) + setupClients(client, sshCredStore) } // Initialize health check monitor with status change callback @@ -1740,6 +1745,26 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } + var useNativeSSH = true + + if useNativeSSH { + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + + // Acknowledge the PAM connection to the cloud. + if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }); err != nil { + logger.Error("nativessh: failed to send round-trip complete: %v", err) + } + return + } + // Check if we're running the auth daemon internally if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally diff --git a/nativessh/server.go b/nativessh/server.go index db91d08..46eb804 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -1,38 +1,80 @@ package nativessh import ( - "bufio" "crypto/ed25519" "crypto/rand" "fmt" "io" "log" "net" - "os" "strings" + "sync" "golang.org/x/crypto/ssh" ) -const ( - // DefaultCAKeyPath is the path to the SSH CA public key used to validate - // client certificates. - DefaultCAKeyPath = "/tmp/newt/ssh_ca.pub" - // DefaultPrincipalsPath is the path to a file listing allowed SSH - // certificate principals, one per line. - DefaultPrincipalsPath = "/tmp/newt/ssh_principals" -) +// CredentialStore holds in-memory SSH credentials that can be updated at runtime. +// It is safe for concurrent use. +type CredentialStore struct { + mu sync.RWMutex + caKey ssh.PublicKey + principals map[string]map[string]struct{} // username -> set of allowed principals +} + +// NewCredentialStore returns an empty, ready-to-use CredentialStore. +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey parses and stores the CA public key from authorized_keys-format data. +func (s *CredentialStore) SetCAKey(authorizedKeyData string) error { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData)) + if err != nil { + return fmt.Errorf("parse CA key: %w", err) + } + s.mu.Lock() + s.caKey = key + s.mu.Unlock() + return nil +} + +// AddPrincipals records username and niceId as allowed principals for username. +// Both values are stored; either can appear in the certificate's ValidPrincipals +// field to satisfy the standard cert-auth principal check. +func (s *CredentialStore) AddPrincipals(username, niceId string) { + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.principals[username] == nil { + s.principals[username] = make(map[string]struct{}) + } + s.principals[username][username] = struct{}{} + if niceId != "" { + s.principals[username][niceId] = struct{}{} + } +} + +// get returns the CA key and the principal set for username under a read lock. +func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.caKey, s.principals[username] +} // ServerConfig holds configuration for the native SSH server. type ServerConfig struct { // ListenAddr is the TCP address to listen on. Defaults to ":2222". ListenAddr string - // CAKeyPath is the path to the CA public key file (authorized_keys format). - // Defaults to DefaultCAKeyPath. - CAKeyPath string - // PrincipalsPath is the path to a file of allowed principals, one per line. - // Defaults to DefaultPrincipalsPath. - PrincipalsPath string + // Credentials provides in-memory CA key and per-user principals. + // Updates to the store are reflected immediately for new connections. + // If nil or the store has no CA key set, all connections are rejected. + Credentials *CredentialStore } // Server is a simple SSH server that authenticates clients via SSH certificate @@ -43,40 +85,22 @@ type Server struct { cfg ServerConfig } -// NewServer creates a new Server. Zero-value fields in cfg are replaced with -// defaults. +// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty. func NewServer(cfg ServerConfig) *Server { if cfg.ListenAddr == "" { cfg.ListenAddr = ":2222" } - if cfg.CAKeyPath == "" { - cfg.CAKeyPath = DefaultCAKeyPath - } - if cfg.PrincipalsPath == "" { - cfg.PrincipalsPath = DefaultPrincipalsPath - } return &Server{cfg: cfg} } -// buildSSHConfig loads keys/principals and builds the ssh.ServerConfig. +// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { - caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) - if err != nil { - return nil, fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) - } - - principals, err := loadPrincipals(s.cfg.PrincipalsPath) - if err != nil { - return nil, fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) - } - hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } - cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCertAuthCallback(caKey, principals), + PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -216,23 +240,25 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCertAuthCallback returns an ssh.PublicKeyCallback that accepts only -// SSH user certificates that are: -// 1. Signed by caKey. -// 2. Listing the connecting username in ValidPrincipals (standard cert auth). -// 3. Whose connecting username is also in the local allowedPrincipals set. -func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]struct{}) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, - } +// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads +// the CA key and per-user principals from store on every auth attempt, so +// credentials updated via AddPrincipals/SetCAKey are applied immediately. +func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + caKey, userPrincipals := store.get(meta.User()) + if caKey == nil { + return nil, fmt.Errorf("no CA key configured") + } + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } perms, err := checker.Authenticate(meta, key) if err != nil { return nil, err } - if _, ok := allowedPrincipals[meta.User()]; !ok { + if len(userPrincipals) == 0 { return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) } return perms, nil @@ -250,35 +276,6 @@ func generateHostKey() (ssh.Signer, error) { return ssh.NewSignerFromKey(priv) } -func loadCAPublicKey(path string) (ssh.PublicKey, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - key, _, _, _, err := ssh.ParseAuthorizedKey(data) - if err != nil { - return nil, err - } - return key, nil -} - -func loadPrincipals(path string) (map[string]struct{}, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - principals := make(map[string]struct{}) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" && !strings.HasPrefix(line, "#") { - principals[line] = struct{}{} - } - } - return principals, scanner.Err() -} - // ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2). type ptyRequestMsg struct { Term string From e36ae4b746d3c9e84a2db071af58fd98e59eb5d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:19:57 -0700 Subject: [PATCH 14/18] Remove nativeSsh config with shell --- browsergateway/browsergateway.go | 5 ----- browsergateway/ssh.go | 8 +++++--- browsergateway/ssh_native.go | 8 +------- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 4f99a29..2d0c6a8 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -36,9 +36,6 @@ type Config struct { // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are // stored per-Target and validated by isAllowed. AuthToken string - // NativeSSH, when non-nil, configures a local PTY/shell SSH mode instead - // of proxying to an external SSH server. - NativeSSH *NativeSSHConfig } // Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. @@ -46,7 +43,6 @@ type Config struct { // HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. type Gateway struct { authToken string - nativeSSH *NativeSSHConfig mu sync.RWMutex targets map[int]Target // keyed by Target.ID @@ -58,7 +54,6 @@ type Gateway struct { func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, - nativeSSH: cfg.NativeSSH, targets: make(map[int]Target), } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index dc5bb13..bc76460 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -40,9 +40,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("authToken") + var nativeSSH = false + // In proxy mode we also need host + username from query params. var target, username string - if g.nativeSSH == nil { + if !nativeSSH { host := r.URL.Query().Get("host") port := r.URL.Query().Get("port") username = r.URL.Query().Get("username") @@ -78,8 +80,8 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { ws.SetReadLimit(-1) defer ws.CloseNow() //nolint:errcheck - if g.nativeSSH != nil { - if err := serveNativeSSHSession(ctx, ws, *g.nativeSSH); err != nil { + if nativeSSH { + if err := serveNativeSSHSession(ctx, ws); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index a2ac8e9..3745aa4 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -10,18 +10,12 @@ import ( "github.com/fosrl/newt/nativessh" ) -// NativeSSHConfig holds configuration for the native PTY/shell mode. -type NativeSSHConfig struct { - // Shell is the executable to spawn (e.g. /bin/bash). Defaults to /bin/sh. - Shell string -} - // serveNativeSSHSession handles a WebSocket SSH session by spawning a local // PTY+shell instead of proxying to an external SSH server. The auth token has // already been validated at the WebSocket upgrade level, so this function only // reads (and discards) the initial "auth" frame for protocol compatibility with // the browser client before starting the shell. -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, cfg NativeSSHConfig) error { +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { // Read and discard the auth frame (token already validated at HTTP layer). _, authBytes, err := ws.Read(ctx) if err != nil { From e6267cc1fc1f7d074acd34495a8a9831c7ad57a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:20:21 -0700 Subject: [PATCH 15/18] Add pam to native ssh server --- go.mod | 1 + go.sum | 2 ++ main.go | 49 +++++++++++----------------- nativessh/auth.go | 50 +++++++++++++++++++++++++++++ nativessh/pam_linux.go | 34 ++++++++++++++++++++ nativessh/pam_other.go | 11 +++++++ nativessh/server.go | 72 ++++++++++++++++++++++++++++++------------ 7 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 nativessh/auth.go create mode 100644 nativessh/pam_linux.go create mode 100644 nativessh/pam_other.go diff --git a/go.mod b/go.mod index 1508e71..32b24c5 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/msteinert/pam/v2 v2.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect diff --git a/go.sum b/go.sum index c79a283..77e8085 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY= +github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/main.go b/main.go index cc03bc2..75a3f61 100644 --- a/main.go +++ b/main.go @@ -1715,14 +1715,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - ExternalAuthDaemon bool `json:"externalAuthDaemon"` - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { SudoMode string `json:"sudoMode"` SudoCommands []string `json:"sudoCommands"` Homedir bool `json:"homedir"` @@ -1745,28 +1745,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - var useNativeSSH = true - - if useNativeSSH { - // Update in-memory credentials used by the native SSH server. - if err := sshCredStore.SetCAKey(certData.CACert); err != nil { - logger.Error("nativessh: failed to set CA key: %v", err) - } - sshCredStore.AddPrincipals(certData.Username, certData.NiceID) - logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) - - // Acknowledge the PAM connection to the cloud. - if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }); err != nil { - logger.Error("nativessh: failed to send round-trip complete: %v", err) - } - return - } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled + if authDaemonServer != nil && certData.AuthDaemonMode == "site" { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) @@ -1789,7 +1769,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - } else { + } else if certData.AuthDaemonMode == "remote" { // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1886,6 +1866,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + } else if certData.AuthDaemonMode == "native" { + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) } // Send success response back to cloud diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 0000000..54d0902 --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,50 @@ +package nativessh + +import ( + "bufio" + "os" + "os/user" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" +) + +// checkAuthorizedKeys reports whether key matches any entry in the system +// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the +// user or file does not exist. +func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { + u, err := user.Lookup(username) + if err != nil { + return false + } + f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys")) + if err != nil { + return false + } + defer f.Close() + + want := ssh.FingerprintSHA256(key) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil { + continue + } + if ssh.FingerprintSHA256(parsed) == want { + return true + } + } + return false +} + +// systemUserExists reports whether a user account with the given name exists +// on the host OS. +func systemUserExists(username string) bool { + _, err := user.Lookup(username) + return err == nil +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go new file mode 100644 index 0000000..c41a046 --- /dev/null +++ b/nativessh/pam_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package nativessh + +import ( + "fmt" + + "github.com/msteinert/pam/v2" +) + +// verifySystemPassword authenticates username/password via PAM using the +// "sshd" service stack. It returns nil on success and an error on failure. +// The caller must not reveal the error detail to the client. +func verifySystemPassword(username, password string) error { + tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { + switch s { + case pam.PromptEchoOff, pam.PromptEchoOn: + return password, nil + default: + return "", nil + } + }) + if err != nil { + return fmt.Errorf("PAM start: %w", err) + } + + if err := tx.Authenticate(0); err != nil { + return fmt.Errorf("PAM authenticate: %w", err) + } + if err := tx.AcctMgmt(0); err != nil { + return fmt.Errorf("PAM acct_mgmt: %w", err) + } + return nil +} diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go new file mode 100644 index 0000000..267400a --- /dev/null +++ b/nativessh/pam_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package nativessh + +import "errors" + +// verifySystemPassword is not supported on non-Linux platforms; it always +// returns an error so that password authentication is never accepted. +func verifySystemPassword(username, password string) error { + return errors.New("password authentication not supported on this platform") +} diff --git a/nativessh/server.go b/nativessh/server.go index 46eb804..f4aa2c9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -93,14 +93,17 @@ func NewServer(cfg ServerConfig) *Server { return &Server{cfg: cfg} } -// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. +// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication: +// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate. +// 2. Password: system PAM stack (Linux only). func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), + PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials), + PasswordCallback: makePasswordCallback(), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -240,28 +243,57 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads -// the CA key and per-user principals from store on every auth attempt, so -// credentials updated via AddPrincipals/SetCAKey are applied immediately. -func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { +// makePublicKeyCallback returns a PublicKeyCallback that tries, in order: +// 1. Host authorized_keys – matches any key in the OS user's +// ~/.ssh/authorized_keys file. +// 2. CA certificate – validates an SSH certificate signed by the +// configured CA and checks that the user appears in the principals map. +// +// store may be nil or empty; those paths are simply skipped. +func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - caKey, userPrincipals := store.get(meta.User()) - if caKey == nil { - return nil, fmt.Errorf("no CA key configured") + // 1. Host authorized_keys. + if checkAuthorizedKeys(meta.User(), key) { + log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, + + // 2. CA certificate. + if store != nil { + caKey, userPrincipals := store.get(meta.User()) + if caKey != nil { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + perms, err := checker.Authenticate(meta, key) + if err == nil { + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + log.Printf("nativessh: CA cert auth for user %q", meta.User()) + return perms, nil + } + } } - perms, err := checker.Authenticate(meta, key) - if err != nil { - return nil, err + + return nil, fmt.Errorf("public key not authorized for user %q", meta.User()) + } +} + +// makePasswordCallback returns a PasswordCallback that validates the supplied +// password via the host OS PAM stack. On non-Linux platforms this always +// fails (see pam_other.go). +func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if err := verifySystemPassword(meta.User(), string(password)); err != nil { + // Return a generic message to the client; log the real reason. + log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) + return nil, fmt.Errorf("permission denied") } - if len(userPrincipals) == 0 { - return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) - } - return perms, nil + log.Printf("nativessh: password auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } } From 1ff26b7acdbd0ab4c76247a24bde0edca611092e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:30:21 -0700 Subject: [PATCH 16/18] Bring the naitve ssh pam to the browser gateway --- browsergateway/ssh.go | 9 +++-- browsergateway/ssh_native.go | 28 +++++++++------ nativessh/auth.go | 34 +++++++++++++++--- nativessh/pam_linux.go | 4 +-- nativessh/pam_other.go | 4 +-- nativessh/pty_unix.go | 69 ++++++++++++++++++++++++++++++++++++ nativessh/server.go | 4 +-- 7 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 nativessh/pty_unix.go diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index bc76460..eaea313 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -62,11 +62,16 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { } target = net.JoinHostPort(host, port) } else { - // Native SSH mode: validate against the global gateway token. + // Native SSH mode: validate the gateway token then read the target username. if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + username = r.URL.Query().Get("username") + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ @@ -81,7 +86,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if nativeSSH { - if err := serveNativeSSHSession(ctx, ws); err != nil { + if err := serveNativeSSHSession(ctx, ws, username); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 3745aa4..d81e762 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -10,13 +10,15 @@ import ( "github.com/fosrl/newt/nativessh" ) -// serveNativeSSHSession handles a WebSocket SSH session by spawning a local -// PTY+shell instead of proxying to an external SSH server. The auth token has -// already been validated at the WebSocket upgrade level, so this function only -// reads (and discards) the initial "auth" frame for protocol compatibility with -// the browser client before starting the shell. -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { - // Read and discard the auth frame (token already validated at HTTP layer). +// serveNativeSSHSession handles a WebSocket SSH session by authenticating the +// user against the host OS (authorized_keys then PAM password) and then +// spawning a PTY+shell running as that user. +// +// The auth frame from the browser must be a JSON sshClientMsg with type="auth" +// carrying the same password/privateKey fields used by the proxy SSH path. +// The target username is passed in from the HTTP layer (query param). +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string) error { + // Read the auth frame. _, authBytes, err := ws.Read(ctx) if err != nil { return fmt.Errorf("read auth message: %w", err) @@ -26,12 +28,18 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn) error { return fmt.Errorf("expected auth message, got: %s", authBytes) } - log.Printf("SSH native: spawning shell") + // Authenticate using host authorized_keys or PAM password. + if err := nativessh.Authenticate(username, authMsg.Password, authMsg.PrivateKey); err != nil { + sendSSHError(ctx, ws, "Authentication failed") + return fmt.Errorf("auth for user %q: %w", username, err) + } - sess, err := nativessh.NewPTYSession() + log.Printf("SSH native: spawning shell as user %q", username) + + sess, err := nativessh.NewPTYSessionAs(username) if err != nil { sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) - return fmt.Errorf("pty session: %w", err) + return fmt.Errorf("pty session as %q: %w", username, err) } defer sess.Close() diff --git a/nativessh/auth.go b/nativessh/auth.go index 54d0902..a060ef7 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -2,6 +2,7 @@ package nativessh import ( "bufio" + "fmt" "os" "os/user" "path/filepath" @@ -10,10 +11,10 @@ import ( "golang.org/x/crypto/ssh" ) -// checkAuthorizedKeys reports whether key matches any entry in the system +// CheckAuthorizedKeys reports whether key matches any entry in the system // user's ~/.ssh/authorized_keys file. Returns false (not an error) when the // user or file does not exist. -func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { +func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool { u, err := user.Lookup(username) if err != nil { return false @@ -42,9 +43,34 @@ func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { return false } -// systemUserExists reports whether a user account with the given name exists +// SystemUserExists reports whether a user account with the given name exists // on the host OS. -func systemUserExists(username string) bool { +func SystemUserExists(username string) bool { _, err := user.Lookup(username) return err == nil } + +// Authenticate authenticates a user for a browser-based native SSH session. +// It tries, in order: +// 1. Private key — parses privateKeyPEM and checks it against the user's +// ~/.ssh/authorized_keys. +// 2. Password — verifies password via the host OS PAM stack (Linux only). +// +// Returns nil on the first method that succeeds, or an error if all fail. +func Authenticate(username, password, privateKeyPEM string) error { + if !SystemUserExists(username) { + return fmt.Errorf("user %q does not exist", username) + } + if privateKeyPEM != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + if err == nil && CheckAuthorizedKeys(username, signer.PublicKey()) { + return nil + } + } + if password != "" { + if err := VerifySystemPassword(username, password); err == nil { + return nil + } + } + return fmt.Errorf("authentication failed for user %q", username) +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index c41a046..b6f359d 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -8,10 +8,10 @@ import ( "github.com/msteinert/pam/v2" ) -// verifySystemPassword authenticates username/password via PAM using the +// VerifySystemPassword authenticates username/password via PAM using the // "sshd" service stack. It returns nil on success and an error on failure. // The caller must not reveal the error detail to the client. -func verifySystemPassword(username, password string) error { +func VerifySystemPassword(username, password string) error { tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { switch s { case pam.PromptEchoOff, pam.PromptEchoOn: diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go index 267400a..624c847 100644 --- a/nativessh/pam_other.go +++ b/nativessh/pam_other.go @@ -4,8 +4,8 @@ package nativessh import "errors" -// verifySystemPassword is not supported on non-Linux platforms; it always +// VerifySystemPassword is not supported on non-Linux platforms; it always // returns an error so that password authentication is never accepted. -func verifySystemPassword(username, password string) error { +func VerifySystemPassword(username, password string) error { return errors.New("password authentication not supported on this platform") } diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go new file mode 100644 index 0000000..7e89c15 --- /dev/null +++ b/nativessh/pty_unix.go @@ -0,0 +1,69 @@ +//go:build !windows + +package nativessh + +import ( + "fmt" + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/creack/pty" +) + +// NewPTYSessionAs spawns an interactive shell in a PTY running as the given +// system user. The calling process must have sufficient privileges (typically +// root / CAP_SETUID) to switch to a different UID/GID. +func NewPTYSessionAs(username string) (*PTYSession, error) { + u, err := user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("user lookup %q: %w", username, err) + } + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse uid: %w", err) + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse gid: %w", err) + } + + // Collect supplementary group IDs. + groupIDs, err := u.GroupIds() + if err != nil { + groupIDs = []string{} + } + var groups []uint32 + for _, g := range groupIDs { + gval, err := strconv.ParseUint(g, 10, 32) + if err == nil { + groups = append(groups, uint32(gval)) + } + } + + shell := findShell() + cmd := exec.Command(shell, "--login") + cmd.Env = []string{ + "TERM=xterm-256color", + "HOME=" + u.HomeDir, + "USER=" + username, + "LOGNAME=" + username, + "SHELL=" + shell, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + cmd.Dir = u.HomeDir + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + Groups: groups, + }, + } + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} diff --git a/nativessh/server.go b/nativessh/server.go index f4aa2c9..e6109b1 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -253,7 +253,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { // 1. Host authorized_keys. - if checkAuthorizedKeys(meta.User(), key) { + if CheckAuthorizedKeys(meta.User(), key) { log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) return &ssh.Permissions{}, nil } @@ -287,7 +287,7 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu // fails (see pam_other.go). func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { - if err := verifySystemPassword(meta.User(), string(password)); err != nil { + if err := VerifySystemPassword(meta.User(), string(password)); err != nil { // Return a generic message to the client; log the real reason. log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) return nil, fmt.Errorf("permission denied") From 60d67ee9a1e65481e690b8948666fa1bfaa652fe Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:39:14 -0700 Subject: [PATCH 17/18] Add --disable-ssh flag to replace --auth-daemon --- clients/clients.go | 11 ++++++---- main.go | 51 +++++++++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/clients/clients.go b/clients/clients.go index 325a828..9c6e680 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -908,10 +908,13 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Start the SSH server on the clients' netstack (port 22). - if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { - logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) - } else { - s.sshServer = h + // A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server. + if s.credStore != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { + logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) + } else { + s.sshServer = h + } } // Note: we already unlocked above, so don't use defer unlock diff --git a/main.go b/main.go index 75a3f61..d7cf7ad 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,7 @@ var ( port uint16 portStr string disableClients bool + disableSSH bool updownScript string dockerSocket string dockerEnforceNetworkValidation string @@ -157,7 +158,6 @@ var ( authDaemonKey string authDaemonPrincipalsFile string authDaemonCACertPath string - authDaemonEnabled bool authDaemonGenerateRandomPassword bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") newtVersion = "version_replaceme" @@ -255,7 +255,6 @@ func runNewtMain(ctx context.Context) { authDaemonKey = os.Getenv("AD_KEY") authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") - authDaemonEnabledEnv := os.Getenv("AUTH_DAEMON_ENABLED") authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") // Metrics/observability env mirrors @@ -268,6 +267,8 @@ func runNewtMain(ctx context.Context) { disableClientsEnv := os.Getenv("DISABLE_CLIENTS") disableClients = disableClientsEnv == "true" + disableSSHEnv := os.Getenv("DISABLE_SSH") + disableSSH = disableSSHEnv == "true" useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") useNativeInterface = useNativeInterfaceEnv == "true" enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") @@ -340,6 +341,9 @@ func runNewtMain(ctx context.Context) { if disableClientsEnv == "" { flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface") } + if disableSSHEnv == "" { + flag.BoolVar(&disableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") + } if enforceHealthcheckCertEnv == "" { flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") } @@ -374,6 +378,8 @@ func runNewtMain(ctx context.Context) { if tlsClientKey == "" { flag.StringVar(&tlsClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") } + // add a dummy input for --auth-daemon but ignore it since the auth daemon is always enabled now and this is just for backward compatibility with older versions + flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") // Handle multiple CA files var tlsClientCAsFlag stringSlice @@ -485,13 +491,7 @@ func runNewtMain(ctx context.Context) { if authDaemonCACertPath == "" { flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") } - if authDaemonEnabledEnv == "" { - flag.BoolVar(&authDaemonEnabled, "auth-daemon", false, "Enable auth daemon mode (runs alongside normal newt operation)") - } else { - if v, err := strconv.ParseBool(authDaemonEnabledEnv); err == nil { - authDaemonEnabled = v - } - } + if authDaemonGenerateRandomPasswordEnv == "" { flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") } else { @@ -530,7 +530,7 @@ func runNewtMain(ctx context.Context) { loggerLevel := util.ParseLogLevel(logLevel) // Start auth daemon if enabled - if authDaemonEnabled { + if !disableSSH { if err := startAuthDaemon(ctx); err != nil { logger.Fatal("Failed to start auth daemon: %v", err) } @@ -717,7 +717,10 @@ func runNewtMain(ctx context.Context) { // In-memory SSH credentials shared with the native SSH server started in // the clients netstack once the WireGuard interface is ready. - sshCredStore := nativessh.NewCredentialStore() + var sshCredStore *nativessh.CredentialStore + if !disableSSH { + sshCredStore = nativessh.NewCredentialStore() + } if !disableClients { setupClients(client, sshCredStore) @@ -1867,12 +1870,28 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) } else if certData.AuthDaemonMode == "native" { - // Update in-memory credentials used by the native SSH server. - if err := sshCredStore.SetCAKey(certData.CACert); err != nil { - logger.Error("nativessh: failed to set CA key: %v", err) + if disableSSH { + logger.Warn("Received native SSH connection request but SSH is disabled via --disable-ssh") + } else { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) } - sshCredStore.AddPrincipals(certData.Username, certData.NiceID) - logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) } else { logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) } From 5d84fa91414b43c16d028c697a7e69dcab888676 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:49:54 -0700 Subject: [PATCH 18/18] Handle the different modes --- authdaemon/connection.go | 2 +- authdaemon/routes.go | 4 +- main.go | 87 +++++++++++++++++++++++++++------------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/authdaemon/connection.go b/authdaemon/connection.go index 3fb44c5..82ddae6 100644 --- a/authdaemon/connection.go +++ b/authdaemon/connection.go @@ -19,7 +19,7 @@ func (s *Server) ProcessConnection(req ConnectionRequest) { if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil { logger.Warn("auth-daemon: ensure user: %v", err) } - if cfg.PrincipalsFilePath != "" { + if cfg.PrincipalsFilePath != "" && req.NiceId != "" { if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { logger.Warn("auth-daemon: write principals: %v", err) } diff --git a/authdaemon/routes.go b/authdaemon/routes.go index 8457c60..3d1be3e 100644 --- a/authdaemon/routes.go +++ b/authdaemon/routes.go @@ -14,9 +14,9 @@ func (s *Server) registerRoutes() { // ConnectionMetadata is the metadata object in POST /connection. type ConnectionMetadata struct { SudoMode string `json:"sudoMode"` // "none" | "full" | "commands" - SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" + SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" Homedir bool `json:"homedir"` - Groups []string `json:"groups"` // system groups to add the user to + Groups []string `json:"groups"` // system groups to add the user to } // ConnectionRequest is the JSON body for POST /connection. diff --git a/main.go b/main.go index d7cf7ad..d21628d 100644 --- a/main.go +++ b/main.go @@ -1748,31 +1748,45 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && certData.AuthDaemonMode == "site" { // if the auth daemon is running internally and the external auth daemon is not enabled + // Use a switch statement for AuthDaemonMode + switch certData.AuthDaemonMode { + case "site": // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) - authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ - CaCert: certData.CACert, - NiceId: certData.NiceID, - Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ - SudoMode: certData.Metadata.SudoMode, - SudoCommands: certData.Metadata.SudoCommands, - Homedir: certData.Metadata.Homedir, - Groups: certData.Metadata.Groups, - }, - }) - - // Send success response back to cloud - err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }) + if authDaemonServer != nil { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + // Send success response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }) + } else { + logger.Error("Auth daemon server is not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - } else if certData.AuthDaemonMode == "remote" { + case "remote": // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1869,15 +1883,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) - } else if certData.AuthDaemonMode == "native" { - if disableSSH { - logger.Warn("Received native SSH connection request but SSH is disabled via --disable-ssh") - } else { + case "native": + logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username) + if authDaemonServer != nil && sshCredStore != nil { authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ - CaCert: certData.CACert, - NiceId: certData.NiceID, + CaCert: "", // dont write the cert to the host + NiceId: "", // dont write the cert to the host Username: certData.Username, - Metadata: authdaemon.ConnectionMetadata{ + Metadata: authdaemon.ConnectionMetadata{ // but push the user SudoMode: certData.Metadata.SudoMode, SudoCommands: certData.Metadata.SudoCommands, Homedir: certData.Metadata.Homedir, @@ -1891,9 +1904,27 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } sshCredStore.AddPrincipals(certData.Username, certData.NiceID) logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server or SSH credential store not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return } - } else { + default: logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode), + }) + return } // Send success response back to cloud