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/browsergateway/browsergateway.go b/browsergateway/browsergateway.go new file mode 100644 index 0000000..2d0c6a8 --- /dev/null +++ b/browsergateway/browsergateway.go @@ -0,0 +1,121 @@ +package browsergateway + +import ( + "crypto/subtle" + "errors" + "net" + "net/http" + "strings" + "sync" +) + +// 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 + +// 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. +// 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, 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 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 +} + +// 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 + + mu sync.RWMutex + targets map[int]Target // keyed by Target.ID + + server *http.Server +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + 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) 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 subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 + } + } + 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 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 +} + +// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/gateway/rdp", g.HandleRDP) + mux.HandleFunc("/gateway/ssh", g.HandleSSH) + mux.HandleFunc("/gateway/vnc", g.handleVNC) +} 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/rdp.go b/browsergateway/rdp.go new file mode 100644 index 0000000..1df5a13 --- /dev/null +++ b/browsergateway/rdp.go @@ -0,0 +1,271 @@ +package browsergateway + +import ( + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" +) + +// 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") + } + + 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, + // including per-target auth token. + rdpHost, rdpPortStr, _ := net.SplitHostPort(target) + rdpPort, _ := strconv.Atoi(rdpPortStr) + 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) + + // -- 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/ssh.go b/browsergateway/ssh.go new file mode 100644 index 0000000..eaea313 --- /dev/null +++ b/browsergateway/ssh.go @@ -0,0 +1,255 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "strconv" + "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" + 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. +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() + + 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 !nativeSSH { + 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" + } + sshPort, _ := strconv.Atoi(port) + 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 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{ + 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 nativeSSH { + if err := serveNativeSSHSession(ctx, ws, username); 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 + privateKey := authMsg.PrivateKey + + // 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: authMethods, + // 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..d81e762 --- /dev/null +++ b/browsergateway/ssh_native.go @@ -0,0 +1,92 @@ +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/coder/websocket" + "github.com/fosrl/newt/nativessh" +) + +// 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) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + + // 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) + } + + 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 as %q: %w", username, err) + } + defer sess.Close() + + // 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 := sess.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 := sess.Write([]byte(msg.Data)); writeErr != nil { + return fmt.Errorf("write pty: %w", writeErr) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows)) + } + } + } + + return nil +} diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 0000000..5d917a4 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,98 @@ +package browsergateway + +import ( + "context" + "io" + "log" + "net" + "net/http" + "strconv" + "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) { + 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" + } + vncPort, _ := strconv.Atoi(port) + 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) + + // 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 +} diff --git a/clients.go b/clients.go index 53ce63f..de96f2c 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 cbd2816..4568e09 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -20,6 +20,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" @@ -109,6 +110,9 @@ type WireGuardService struct { sharedBind *bind.SharedBind holePunchManager *holepunch.Manager 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 @@ -197,6 +201,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 == "" { @@ -215,6 +226,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 { @@ -909,6 +926,16 @@ 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). + // 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 // Apply any pending blocked state to the newly created proxy handler @@ -1590,3 +1617,33 @@ 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, 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) + } + 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/go.mod b/go.mod index e6d39d7..0dc00e8 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/fosrl/newt go 1.25.0 require ( + github.com/coder/websocket v1.8.14 + github.com/creack/pty v1.1.24 github.com/gaissmai/bart v0.26.1 github.com/gorilla/websocket v1.5.3 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 + github.com/msteinert/pam/v2 v2.1.0 github.com/prometheus/client_golang v1.23.2 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 diff --git a/go.sum b/go.sum index 102924e..3ad78d4 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,14 @@ 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 v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.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/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= @@ -53,6 +57,8 @@ github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +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= @@ -119,6 +125,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/main.go b/main.go index 3366f89..281abf9 100644 --- a/main.go +++ b/main.go @@ -23,9 +23,11 @@ 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" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -41,15 +43,24 @@ 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"` + AuthToken string `json:"authToken"` +} + 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 { @@ -124,6 +135,7 @@ var ( port uint16 portStr string disableClients bool + disableSSH bool updownScript string dockerSocket string dockerEnforceNetworkValidation string @@ -135,6 +147,8 @@ var ( pingStopChan chan struct{} stopFunc func() pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() pendingPingChainId string healthFile string useNativeInterface bool @@ -145,7 +159,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" @@ -244,7 +257,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 @@ -257,6 +269,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") @@ -329,6 +343,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)") } @@ -363,6 +380,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 @@ -474,13 +493,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 { @@ -519,11 +532,12 @@ 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) } } + logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) @@ -740,8 +754,15 @@ func runNewtMain(ctx context.Context) { var connectionBlocked atomic.Bool var currentPM atomic.Pointer[proxy.ProxyManager] + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + var sshCredStore *nativessh.CredentialStore + if !disableSSH { + sshCredStore = nativessh.NewCredentialStore() + } + if !disableClients { - setupClients(client) + setupClients(client, sshCredStore) } // Initialize connection blocked state from config @@ -787,6 +808,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() @@ -996,6 +1024,42 @@ 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, + AuthToken: t.AuthToken, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{}) + 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) { @@ -1716,14 +1780,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"` @@ -1746,31 +1810,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.ExternalAuthDaemon { // 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 { + case "remote": // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1867,6 +1945,48 @@ 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) + 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: "", // dont write the cert to the host + NiceId: "", // dont write the cert to the host + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ // but push the user + 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) + } 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 + } + 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 @@ -1879,6 +1999,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) diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 0000000..a060ef7 --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,76 @@ +package nativessh + +import ( + "bufio" + "fmt" + "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 +} + +// 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 new file mode 100644 index 0000000..b6f359d --- /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..624c847 --- /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/pty.go b/nativessh/pty.go new file mode 100644 index 0000000..cb5fa88 --- /dev/null +++ b/nativessh/pty.go @@ -0,0 +1,89 @@ +package nativessh + +import ( + "errors" + "fmt" + "os" + "os/exec" + "sync" + + "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 + waitOnce sync.Once + exitCode int +} + +// 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) + 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}) +} + +// 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.wait() + return err +} 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 new file mode 100644 index 0000000..e6109b1 --- /dev/null +++ b/nativessh/server.go @@ -0,0 +1,343 @@ +package nativessh + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "log" + "net" + "strings" + "sync" + + "golang.org/x/crypto/ssh" +) + +// 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 + // 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 +// 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. The ListenAddr defaults to ":2222" when empty. +func NewServer(cfg ServerConfig) *Server { + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":2222" + } + return &Server{cfg: cfg} +} + +// 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: makePublicKeyCallback(s.cfg.Credentials), + PasswordCallback: makePasswordCallback(), + } + 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() + return s.Serve(ln) +} + +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() + 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) + // 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() { + _, _ = 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 + } +} + +// 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) { + // 1. Host authorized_keys. + if CheckAuthorizedKeys(meta.User(), key) { + log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + return &ssh.Permissions{}, nil + } + + // 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 + } + } + } + + 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") + } + log.Printf("nativessh: password auth for user %q", meta.User()) + return &ssh.Permissions{}, nil + } +} + +// 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) + } + log.Printf("nativessh: generated ephemeral Ed25519 host key") + return ssh.NewSignerFromKey(priv) +} + +// 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) +}