From 832b06d8d57578ddf1051b6590685cd6568ab7df Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 18:03:40 -0700 Subject: [PATCH 01/36] Add browser gateway Former-commit-id: 67b81c1f406900e9578e7e5eeca61892cfe36b99 --- 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 7ace4978a41897a66375cf2e1823c0685bb68e23 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:13:55 -0700 Subject: [PATCH 02/36] Add vnc Former-commit-id: 825a7f460f9a789c2d6995b343ba0c6c21e901bd --- 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 b72c8e643d0ca9d7242909d7dd0fe614421b0e2e Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 21:49:55 -0700 Subject: [PATCH 03/36] Split out rdp Former-commit-id: bd53edf8e4fa788b1474bf5a555161deb75b4617 --- 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 c55071c60aae4ff93c3aa532d1a4ea120c95965d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 16:24:26 -0700 Subject: [PATCH 04/36] Basic browser gateway target support Former-commit-id: 559b7021fece50378719f2ed85de6eb7022cf048 --- 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 385e9c0a8368fd905900ea0961a38425089c0f12 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 13:46:39 -0700 Subject: [PATCH 05/36] Support per target auth token Former-commit-id: 710408ac670bb3f5ded6ba612f2643c406323f18 --- 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 40710e5197e42116e3528710502b44e4406f6a92 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 14:59:37 -0700 Subject: [PATCH 06/36] Support add and remove for gateway Former-commit-id: 28cddf7066b1ed5af3667868da173d3945ca33dd --- 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 e438a249dd1e4dfa0ae4c4ddfcad14b70914acf4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 16:07:05 -0700 Subject: [PATCH 07/36] Support ssh private key Former-commit-id: 1f8be1d826aa6653d617fd6d2d427d6ccbe7b1e4 --- 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 cb5d87587c518e5938266a76cc316d41484a77f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:19:39 -0700 Subject: [PATCH 08/36] Basic ssh server for private resources created Former-commit-id: e0d65d81258b14bbe8e0f93e1bf213391d3ce937 --- 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 7636549e9a442f227c4f6400641d6a14dbc5ea6b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 18:23:11 -0700 Subject: [PATCH 09/36] Pty to find its own shell Former-commit-id: 133311f1c4b0f8694cd40b81bea103bbaf40e46e --- 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 cc7b2a5ade3dcb2a1c6cd5624af53b1a274581f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:16:46 -0700 Subject: [PATCH 10/36] Keep host key in memory Former-commit-id: 388795ecf4d691268bd9ed2f234de64ce7dc01cd --- 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 097450ae1593704085911586da49a4eba9389211 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:30:17 -0700 Subject: [PATCH 11/36] Bind the ssh server to the newt ip Former-commit-id: 9640ada8b7a823e605e45f319457340c3981b8d0 --- 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 677146e20e320b7ef17753be2fdfd86681909486 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 20:38:38 -0700 Subject: [PATCH 12/36] Fix exit not closing session Former-commit-id: f217ddf6a7b41baec00af570628e7f226ff848cd --- 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 647a6d2635e103ea0a2903e9975d528a7eb36fa3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 21:09:51 -0700 Subject: [PATCH 13/36] Add credentials store and use the auth daemon info Former-commit-id: 631eab53d1f1d663d5bb458e13403a5742e5e1f8 --- 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 b9536597d297b918d48bc639ded2efe06792a784 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:19:57 -0700 Subject: [PATCH 14/36] Remove nativeSsh config with shell Former-commit-id: e36ae4b746d3c9e84a2db071af58fd98e59eb5d6 --- 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 6bf6b47d1869054c0bf7f8c4512d31e9cc2d9ba2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:20:21 -0700 Subject: [PATCH 15/36] Add pam to native ssh server Former-commit-id: e6267cc1fc1f7d074acd34495a8a9831c7ad57a1 --- 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 865697204b900f1bb645196ab802dfb974c77f9b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:30:21 -0700 Subject: [PATCH 16/36] Bring the naitve ssh pam to the browser gateway Former-commit-id: 1ff26b7acdbd0ab4c76247a24bde0edca611092e --- 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 2a3ec1953f91a36534b757f7465b1158cef3e141 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:39:14 -0700 Subject: [PATCH 17/36] Add --disable-ssh flag to replace --auth-daemon Former-commit-id: 60d67ee9a1e65481e690b8948666fa1bfaa652fe --- 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 71ab4da7f52545621cb723f0cbd5427fee98e433 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 13:49:54 -0700 Subject: [PATCH 18/36] Handle the different modes Former-commit-id: 5d84fa91414b43c16d028c697a7e69dcab888676 --- 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 From 3264aa097549a93cd7a35ee955ba7600bba3c3e7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 25 May 2026 21:40:43 -0700 Subject: [PATCH 19/36] update issue template Former-commit-id: 6ccfe96e69ad0d027035da5ad245cfed849ec8a9 --- .github/ISSUE_TEMPLATE/1.bug_report.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index 41dbe7b..c945608 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -14,12 +14,13 @@ body: label: Environment description: Please fill out the relevant details below for your environment. value: | - - OS Type & Version: (e.g., Ubuntu 22.04) + - OS Type & Version: - Pangolin Version: + - Edition (Community or Enterprise): - Gerbil Version: - Traefik Version: - Newt Version: - - Olm Version: (if applicable) + - Client Version: validations: required: true From c058500932ace118d989528e3d04b3d0ac3ecda4 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 11:29:57 -0700 Subject: [PATCH 20/36] Auto update newt Former-commit-id: 42cb8e790851ea6fa2df73a846523751e0d96276 --- Dockerfile | 4 + main.go | 26 +++++ updates/reexec_unix.go | 14 +++ updates/reexec_windows.go | 28 +++++ updates/selfupdate.go | 218 ++++++++++++++++++++++++++++++++++++++ websocket/client.go | 16 +++ 6 files changed, 306 insertions(+) create mode 100644 updates/reexec_unix.go create mode 100644 updates/reexec_windows.go create mode 100644 updates/selfupdate.go diff --git a/Dockerfile b/Dockerfile index ea870c2..6887923 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,10 @@ RUN apk --no-cache add ca-certificates tzdata iputils COPY --from=builder /newt /usr/local/bin/ COPY entrypoint.sh / +# Marks this as an official Fossorial container image. +# Auto-update is disabled in official images — update by pulling a new image tag. +ENV NEWT_OFFICIAL_CONTAINER=true + # Admin/metrics endpoint (Prometheus scrape) EXPOSE 2112 diff --git a/main.go b/main.go index 448f71d..034ab82 100644 --- a/main.go +++ b/main.go @@ -176,6 +176,9 @@ var ( // Path to config file (overrides CONFIG_FILE env var and default location) configFile string + + // Auto-update flag + autoUpdate bool ) // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -489,6 +492,7 @@ func runNewtMain(ctx context.Context) { // do a --version check version := flag.Bool("version", false, "Print the version") + flag.BoolVar(&autoUpdate, "auto-update", false, "Check for a newer version on the server and self-update if one is available") flag.Parse() @@ -656,9 +660,31 @@ func runNewtMain(ctx context.Context) { endpoint = client.GetConfig().Endpoint // Update endpoint from config id = client.GetConfig().ID // Update ID from config + secret = client.GetConfig().Secret // Update secret from config // Update site labels for metrics with the resolved ID telemetry.UpdateSiteInfo(id, region) + // Auto-update: check for a new version, download and replace if available. + if autoUpdate { + var tlsCfg *tls.Config + if opt != nil { + // Reuse the TLS configuration already set up for the websocket client. + tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) + } + if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ + Endpoint: endpoint, + NewtID: id, + Secret: secret, + CurrentVersion: newtVersion, + TLSConfig: tlsCfg, + }); err != nil { + logger.Fatal("Auto-update failed: %v", err) + } + // CheckAndSelfUpdate re-execs on success, so we only reach here if + // newt is already up to date. + return + } + // output env var values if set logger.Debug("Endpoint: %v", endpoint) logger.Debug("Log Level: %v", logLevel) diff --git a/updates/reexec_unix.go b/updates/reexec_unix.go new file mode 100644 index 0000000..b439d10 --- /dev/null +++ b/updates/reexec_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package updates + +import ( + "os" + "syscall" +) + +// reexec replaces the current process image with the binary at exePath, +// forwarding all original arguments and environment variables. +func reexec(exePath string) error { + return syscall.Exec(exePath, os.Args, os.Environ()) +} diff --git a/updates/reexec_windows.go b/updates/reexec_windows.go new file mode 100644 index 0000000..6174037 --- /dev/null +++ b/updates/reexec_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package updates + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec on Windows cannot use syscall.Exec (there is no exec syscall that +// replaces the process image). Instead we start a new process and exit the +// current one. +func reexec(exePath string) error { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start updated binary: %w", err) + } + + // Exit the current process so the new binary takes over. + os.Exit(0) + return nil // unreachable +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go new file mode 100644 index 0000000..751f358 --- /dev/null +++ b/updates/selfupdate.go @@ -0,0 +1,218 @@ +package updates + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// SelfUpdateConfig holds the configuration required to perform a self-update. +type SelfUpdateConfig struct { + // Endpoint is the base URL of the pangolin server (e.g. "https://pangolin.example.com") + Endpoint string + // NewtID is the newt client identifier used for authentication. + NewtID string + // Secret is the newt client secret used for authentication. + Secret string + // CurrentVersion is the version of the currently running binary. + CurrentVersion string + // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). + TLSConfig *tls.Config +} + +// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version +type versionResponse struct { + Data struct { + LatestVersion string `json:"latestVersion"` + CurrentIsLatest bool `json:"currentIsLatest"` + DownloadUrl string `json:"downloadUrl"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// isOfficialContainer returns true when the process is running inside an +// official Fossorial-built container image. The image sets +// NEWT_OFFICIAL_CONTAINER=true at build time; users running newt in their own +// containers (or bare-metal) will not have this variable set. +func isOfficialContainer() bool { + return os.Getenv("NEWT_OFFICIAL_CONTAINER") == "true" +} + +// platform returns the OS+arch string used in the newt release binary names, +// e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +func platform() string { + goarch := runtime.GOARCH + goos := runtime.GOOS + + // Map Go arch names to the names used by newt releases + archMap := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + "arm": "arm32", + "riscv64": "riscv64", + } + + arch, ok := archMap[goarch] + if !ok { + arch = goarch + } + + return fmt.Sprintf("%s_%s", goos, arch) +} + +// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer +// version of newt is available, downloads it if so, replaces the running +// binary on disk, and re-executes from the new binary. +// +// It returns an error when the check or update fails. On a successful update +// the function does not return – the process is replaced by the new binary via +// syscall.Exec. +func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + if isOfficialContainer() { + return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") + } + + if cfg.CurrentVersion == "version_replaceme" { + return fmt.Errorf("cannot auto-update a development build (version_replaceme)") + } + + baseEndpoint := strings.TrimRight(cfg.Endpoint, "/") + + // Build the HTTP client. + httpClient := &http.Client{Timeout: 30 * time.Second} + if cfg.TLSConfig != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig} + } + + // Check the current binary path before we do anything else so we can fail + // fast if the executable path cannot be determined. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine current executable path: %w", err) + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) + } + + // --- Step 1: Ask the server for the latest version --- + reqBody, err := json.Marshal(map[string]string{ + "newtId": cfg.NewtID, + "secret": cfg.Secret, + "platform": platform(), + }) + if err != nil { + return fmt.Errorf("failed to marshal version request: %w", err) + } + + versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version") + if err != nil { + return fmt.Errorf("failed to build version URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("failed to create version request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to request version info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + } + + var verResp versionResponse + if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { + return fmt.Errorf("failed to parse version response: %w", err) + } + + if !verResp.Success { + return fmt.Errorf("server error: %s", verResp.Message) + } + + if verResp.Data.CurrentIsLatest { + fmt.Printf("newt is already up to date (%s)\n", cfg.CurrentVersion) + return nil + } + + fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) + fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + + // --- Step 2: Download the new binary --- + dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer dlCancel() + + dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + dlResp, err := httpClient.Do(dlReq) + if err != nil { + return fmt.Errorf("failed to download new binary: %w", err) + } + defer dlResp.Body.Close() + + if dlResp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", dlResp.StatusCode) + } + + // Write to a temp file in the same directory as the current binary so that + // an atomic rename works even across filesystem boundaries. + exeDir := filepath.Dir(exePath) + tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") + if err != nil { + return fmt.Errorf("failed to create temp file for download: %w", err) + } + tmpPath := tmpFile.Name() + + // Ensure the temp file is cleaned up on any error path. + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := io.Copy(tmpFile, dlResp.Body); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write downloaded binary: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Make the new binary executable. + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to set executable permission: %w", err) + } + + // --- Step 3: Replace the running binary --- + // On Unix an atomic rename works even while the file is running. + if err := os.Rename(tmpPath, exePath); err != nil { + return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) + } + + fmt.Printf("Binary updated to %s at %s\n", verResp.Data.LatestVersion, exePath) + + // --- Step 4: Re-exec --- + return reexec(exePath) +} diff --git a/websocket/client.go b/websocket/client.go index 5068471..22187ac 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -931,3 +931,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) { RootCAs: rootCAs, }, nil } + +// BuildTLSConfig creates a *tls.Config from the provided certificate files. +// Pass empty strings / nil slice for fields that are not used. +// Returns nil, nil when no TLS credentials are provided. +func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) { + c := &Client{ + tlsConfig: TLSConfig{ + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFiles: caFiles, + PKCS12File: pkcs12File, + }, + config: &Config{}, + } + return c.setupTLS() +} From e1b6fa90083ca017ec4ddd92c67f878888146f73 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 14:33:29 -0700 Subject: [PATCH 21/36] Add permissions check, shasum check, & build info Former-commit-id: 357f718e00112be07fd1e4116910028b2ae7db35 --- Makefile | 20 +++++++------- main.go | 24 +++++++++-------- updates/selfupdate.go | 63 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index 53c4bb2..6382b21 100644 --- a/Makefile +++ b/Makefile @@ -43,31 +43,31 @@ go-build-release: \ go-build-release-freebsd-arm64 go-build-release-linux-arm64: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64 go-build-release-linux-arm32-v7: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32 go-build-release-linux-arm32-v6: - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6 go-build-release-linux-amd64: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64 go-build-release-linux-riscv64: - CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64 + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64 go-build-release-darwin-arm64: - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64 go-build-release-darwin-amd64: - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64 go-build-release-windows-amd64: - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe go-build-release-freebsd-amd64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64 + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64 go-build-release-freebsd-arm64: - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64 \ No newline at end of file + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64 \ No newline at end of file diff --git a/main.go b/main.go index 034ab82..b612fb9 100644 --- a/main.go +++ b/main.go @@ -147,18 +147,19 @@ var ( authDaemonEnabled bool authDaemonGenerateRandomPassword bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") - newtVersion = "version_replaceme" + newtVersion = "version_replaceme" + newtPlatform = "" // embedded at build time via -X main.newtPlatform=_ // 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 @@ -676,6 +677,7 @@ func runNewtMain(ctx context.Context) { NewtID: id, Secret: secret, CurrentVersion: newtVersion, + Platform: newtPlatform, TLSConfig: tlsCfg, }); err != nil { logger.Fatal("Auto-update failed: %v", err) @@ -1867,7 +1869,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") diff --git a/updates/selfupdate.go b/updates/selfupdate.go index 751f358..b322c75 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -3,7 +3,9 @@ package updates import ( "bytes" "context" + "crypto/sha256" "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" @@ -26,6 +28,10 @@ type SelfUpdateConfig struct { Secret string // CurrentVersion is the version of the currently running binary. CurrentVersion string + // Platform is the OS+arch string embedded at build time via ldflags + // (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used + // directly; when empty the value is derived from runtime.GOOS/GOARCH. + Platform string // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). TLSConfig *tls.Config } @@ -33,9 +39,10 @@ type SelfUpdateConfig struct { // versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version type versionResponse struct { Data struct { - LatestVersion string `json:"latestVersion"` + LatestVersion string `json:"latestVersion"` CurrentIsLatest bool `json:"currentIsLatest"` - DownloadUrl string `json:"downloadUrl"` + DownloadUrl string `json:"downloadUrl"` + Sha256 string `json:"sha256"` } `json:"data"` Success bool `json:"success"` Message string `json:"message"` @@ -51,6 +58,7 @@ func isOfficialContainer() bool { // platform returns the OS+arch string used in the newt release binary names, // e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +// It is used as a fallback when no platform was embedded at build time. func platform() string { goarch := runtime.GOARCH goos := runtime.GOOS @@ -71,6 +79,26 @@ func platform() string { return fmt.Sprintf("%s_%s", goos, arch) } +// verifySHA256 checks that the file at path has the expected SHA-256 hex digest. +func verifySHA256(path, expected string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file for hashing: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("failed to hash file: %w", err) + } + + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expected) { + return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got) + } + return nil +} + // CheckAndSelfUpdate contacts the pangolin server, checks whether a newer // version of newt is available, downloads it if so, replaces the running // binary on disk, and re-executes from the new binary. @@ -107,10 +135,14 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } // --- Step 1: Ask the server for the latest version --- + plat := cfg.Platform + if plat == "" { + plat = platform() + } reqBody, err := json.Marshal(map[string]string{ "newtId": cfg.NewtID, "secret": cfg.Secret, - "platform": platform(), + "platform": plat, }) if err != nil { return fmt.Errorf("failed to marshal version request: %w", err) @@ -159,6 +191,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + // --- Pre-download: verify we can write to the binary's directory --- + // Do this before downloading so a permission failure doesn't waste bandwidth. + exeDir := filepath.Dir(exePath) + writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*") + if err != nil { + return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err) + } + writeTestFile.Close() + _ = os.Remove(writeTestFile.Name()) + // --- Step 2: Download the new binary --- dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer dlCancel() @@ -180,11 +222,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // Write to a temp file in the same directory as the current binary so that // an atomic rename works even across filesystem boundaries. - exeDir := filepath.Dir(exePath) tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") - if err != nil { - return fmt.Errorf("failed to create temp file for download: %w", err) - } tmpPath := tmpFile.Name() // Ensure the temp file is cleaned up on any error path. @@ -200,6 +238,17 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { return fmt.Errorf("failed to close temp file: %w", err) } + // --- Verify SHA256 checksum if the server provided one --- + if verResp.Data.Sha256 != "" { + fmt.Println("Verifying SHA256 checksum...") + if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil { + return fmt.Errorf("binary integrity check failed: %w", err) + } + fmt.Println("SHA256 checksum verified.") + } else { + fmt.Println("Warning: no SHA256 checksum provided by server, skipping verification.") + } + // Make the new binary executable. if err := os.Chmod(tmpPath, 0755); err != nil { return fmt.Errorf("failed to set executable permission: %w", err) From 24c4fbc0b080b0b9bcb6aba184b8144281d5925b Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 17:04:38 -0700 Subject: [PATCH 22/36] Keep checking if we need to update Former-commit-id: 535fd717be3d3c35292e08ca6955bbc6c47e5b4b --- Makefile | 2 +- main.go | 38 +++++++++++++++++++++++--------------- updates/selfupdate.go | 37 ++++++++++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 6382b21..ce9989b 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= dev LDFLAGS = -X main.newtVersion=$(VERSION) local: - CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt docker-build: docker build -t fosrl/newt:latest . diff --git a/main.go b/main.go index b612fb9..0c8e7dd 100644 --- a/main.go +++ b/main.go @@ -177,9 +177,6 @@ var ( // Path to config file (overrides CONFIG_FILE env var and default location) configFile string - - // Auto-update flag - autoUpdate bool ) // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -493,7 +490,6 @@ func runNewtMain(ctx context.Context) { // do a --version check version := flag.Bool("version", false, "Print the version") - flag.BoolVar(&autoUpdate, "auto-update", false, "Check for a newer version on the server and self-update if one is available") flag.Parse() @@ -665,13 +661,13 @@ func runNewtMain(ctx context.Context) { // Update site labels for metrics with the resolved ID telemetry.UpdateSiteInfo(id, region) - // Auto-update: check for a new version, download and replace if available. - if autoUpdate { - var tlsCfg *tls.Config - if opt != nil { - // Reuse the TLS configuration already set up for the websocket client. - tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) - } + var tlsCfg *tls.Config + if opt != nil { + // Reuse the TLS configuration already set up for the websocket client. + tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) + } + doUpdate := func() { + logger.Debug("checkAndSelfUpdate: running periodic update check") if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ Endpoint: endpoint, NewtID: id, @@ -680,12 +676,24 @@ func runNewtMain(ctx context.Context) { Platform: newtPlatform, TLSConfig: tlsCfg, }); err != nil { - logger.Fatal("Auto-update failed: %v", err) + logger.Error("Auto-update check failed: %v", err) } - // CheckAndSelfUpdate re-execs on success, so we only reach here if - // newt is already up to date. - return } + go func() { + // wait for 2 minutes after startup before the first check to avoid interfering with initial provisioning and registration + time.Sleep(2 * time.Minute) + doUpdate() // run once at startup + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + doUpdate() + case <-ctx.Done(): + return + } + } + }() // output env var values if set logger.Debug("Endpoint: %v", endpoint) diff --git a/updates/selfupdate.go b/updates/selfupdate.go index b322c75..f80dc2d 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -16,11 +16,13 @@ import ( "runtime" "strings" "time" + + "github.com/fosrl/newt/logger" ) // SelfUpdateConfig holds the configuration required to perform a self-update. type SelfUpdateConfig struct { - // Endpoint is the base URL of the pangolin server (e.g. "https://pangolin.example.com") + // Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net") Endpoint string // NewtID is the newt client identifier used for authentication. NewtID string @@ -107,11 +109,15 @@ func verifySHA256(path, expected string) error { // the function does not return – the process is replaced by the new binary via // syscall.Exec. func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + logger.Debug("++++++++++++++++++++++++++++++++++++++++++++checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) + if isOfficialContainer() { + logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") } if cfg.CurrentVersion == "version_replaceme" { + logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update") return fmt.Errorf("cannot auto-update a development build (version_replaceme)") } @@ -133,12 +139,14 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { if err != nil { return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) } + logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath) // --- Step 1: Ask the server for the latest version --- plat := cfg.Platform if plat == "" { plat = platform() } + logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint) reqBody, err := json.Marshal(map[string]string{ "newtId": cfg.NewtID, "secret": cfg.Secret, @@ -169,6 +177,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { // updates are disabled + logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)") + return nil + } + + if resp.StatusCode == http.StatusNotFound { // older server without version endpoint + logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping") + return nil + } + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) @@ -184,16 +202,16 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { } if verResp.Data.CurrentIsLatest { - fmt.Printf("newt is already up to date (%s)\n", cfg.CurrentVersion) + logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion) return nil } - fmt.Printf("Update available: %s → %s\n", cfg.CurrentVersion, verResp.Data.LatestVersion) - fmt.Printf("Downloading from: %s\n", verResp.Data.DownloadUrl) + logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion) // --- Pre-download: verify we can write to the binary's directory --- // Do this before downloading so a permission failure doesn't waste bandwidth. exeDir := filepath.Dir(exePath) + logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir) writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*") if err != nil { return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err) @@ -202,6 +220,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { _ = os.Remove(writeTestFile.Name()) // --- Step 2: Download the new binary --- + logger.Debug("checkAndSelfUpdate: beginning download of new binary") dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer dlCancel() @@ -240,13 +259,13 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // --- Verify SHA256 checksum if the server provided one --- if verResp.Data.Sha256 != "" { - fmt.Println("Verifying SHA256 checksum...") + logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum") if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil { return fmt.Errorf("binary integrity check failed: %w", err) } - fmt.Println("SHA256 checksum verified.") + logger.Debug("checkAndSelfUpdate: SHA256 checksum verified") } else { - fmt.Println("Warning: no SHA256 checksum provided by server, skipping verification.") + logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification") } // Make the new binary executable. @@ -256,12 +275,12 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { // --- Step 3: Replace the running binary --- // On Unix an atomic rename works even while the file is running. + logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath) if err := os.Rename(tmpPath, exePath); err != nil { return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) } - fmt.Printf("Binary updated to %s at %s\n", verResp.Data.LatestVersion, exePath) - // --- Step 4: Re-exec --- + logger.Debug("checkAndSelfUpdate: re-executing new binary") return reexec(exePath) } From d2bd8cb97d49444ad1fc7ff40c3bd46e3ac5d716 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 17:01:48 -0700 Subject: [PATCH 23/36] Add advantech router app updates Former-commit-id: ec89eb7d0fd3418986a8bcfed52dfbe33c451532 --- Dockerfile | 2 +- updates/advantech.go | 48 +++++++++++++++++++++++++++++++++++++++++++ updates/selfupdate.go | 15 +++++++++++--- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 updates/advantech.go diff --git a/Dockerfile b/Dockerfile index 6887923..10309d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY entrypoint.sh / # Marks this as an official Fossorial container image. # Auto-update is disabled in official images — update by pulling a new image tag. -ENV NEWT_OFFICIAL_CONTAINER=true +ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER" # Admin/metrics endpoint (Prometheus scrape) EXPOSE 2112 diff --git a/updates/advantech.go b/updates/advantech.go new file mode 100644 index 0000000..18cacdf --- /dev/null +++ b/updates/advantech.go @@ -0,0 +1,48 @@ +//go:build !windows + +package updates + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fosrl/newt/logger" +) + +const advantechVersionFile = "/opt/newt/etc/version" + +// postUpdateAdvantech performs Advantech Router App-specific post-update steps: +// - Writes the new version string to /opt/newt/etc/version so that the +// router firmware's package management reflects the installed version. +// - Updates the PID file at pidFile (if non-empty) with the current process +// PID, in case the re-exec lands with a new PID on platforms where +// syscall.Exec behaviour differs. +func postUpdateAdvantech(newVersion, pidFile string) error { + // --- Write version file --- + versionDir := filepath.Dir(advantechVersionFile) + if err := os.MkdirAll(versionDir, 0755); err != nil { + return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err) + } + + if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil { + return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err) + } + logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile) + + // --- Update PID file --- + // syscall.Exec replaces the process image in-place so the PID is preserved. + // We update the PID file here anyway so that any race between the old and + // new binary is covered (e.g. on platforms that fork before exec). + if pidFile != "" { + pid := fmt.Sprintf("%d\n", os.Getpid()) + if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil { + // Non-fatal: log and continue so the update still proceeds. + logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err) + } else { + logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid()) + } + } + + return nil +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go index f80dc2d..c828236 100644 --- a/updates/selfupdate.go +++ b/updates/selfupdate.go @@ -52,10 +52,10 @@ type versionResponse struct { // isOfficialContainer returns true when the process is running inside an // official Fossorial-built container image. The image sets -// NEWT_OFFICIAL_CONTAINER=true at build time; users running newt in their own +// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own // containers (or bare-metal) will not have this variable set. func isOfficialContainer() bool { - return os.Getenv("NEWT_OFFICIAL_CONTAINER") == "true" + return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER" } // platform returns the OS+arch string used in the newt release binary names, @@ -109,7 +109,7 @@ func verifySHA256(path, expected string) error { // the function does not return – the process is replaced by the new binary via // syscall.Exec. func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { - logger.Debug("++++++++++++++++++++++++++++++++++++++++++++checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) + logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) if isOfficialContainer() { logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") @@ -280,6 +280,15 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) } + systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE") + logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate) + if systemSubstrate == "ADVANTECH_ROUTER_APP" { + pidFile := os.Getenv("NEWT_PID_FILE") + if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil { + logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err) + } + } + // --- Step 4: Re-exec --- logger.Debug("checkAndSelfUpdate: re-executing new binary") return reexec(exePath) From 7c22fe274ae71f5d0ea07fa9c94e45fee00ff157 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 17:56:01 -0700 Subject: [PATCH 24/36] Add advantech package Former-commit-id: 8955213aefa1bb404d72a15c8cb1451b16391470 --- packages/advantech/.gitignore | 3 + packages/advantech/Makefile | 53 +++++ packages/advantech/merge/etc/defaults | 61 +++++ packages/advantech/merge/etc/description | 4 + packages/advantech/merge/etc/init | 124 ++++++++++ packages/advantech/merge/etc/install | 15 ++ packages/advantech/merge/etc/name | 1 + packages/advantech/merge/etc/summary | 1 + packages/advantech/merge/etc/uninstall | 9 + packages/advantech/merge/etc/version | 1 + packages/advantech/merge/www/index.cgi | 282 +++++++++++++++++++++++ packages/openwrt/.gitkeep | 0 packages/teltonika/.gitkeep | 0 13 files changed, 554 insertions(+) create mode 100644 packages/advantech/.gitignore create mode 100644 packages/advantech/Makefile create mode 100644 packages/advantech/merge/etc/defaults create mode 100644 packages/advantech/merge/etc/description create mode 100644 packages/advantech/merge/etc/init create mode 100644 packages/advantech/merge/etc/install create mode 100644 packages/advantech/merge/etc/name create mode 100644 packages/advantech/merge/etc/summary create mode 100644 packages/advantech/merge/etc/uninstall create mode 100644 packages/advantech/merge/etc/version create mode 100644 packages/advantech/merge/www/index.cgi create mode 100644 packages/openwrt/.gitkeep create mode 100644 packages/teltonika/.gitkeep diff --git a/packages/advantech/.gitignore b/packages/advantech/.gitignore new file mode 100644 index 0000000..0377a32 --- /dev/null +++ b/packages/advantech/.gitignore @@ -0,0 +1,3 @@ +.build +*.tgz +bin \ No newline at end of file diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile new file mode 100644 index 0000000..2250025 --- /dev/null +++ b/packages/advantech/Makefile @@ -0,0 +1,53 @@ +MODNAME := newt +VERSION := 1.12.5 +RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION) +PLATFORM ?= v4 + +# Map Advantech platform to newt release binary name +arch_v4 := arm64 +arch_v4i := arm64 +arch_v3 := arm32 +arch_v2i := arm32 + +NEWT_ARCH := $(arch_$(PLATFORM)) +ifeq ($(NEWT_ARCH),) +$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2i) +endif + +BINARY := newt_linux_$(NEWT_ARCH) +BINDIR := bin +OUTFILE := $(MODNAME).$(PLATFORM).tgz +STAGEDIR := .build/$(MODNAME) + +.PHONY: all clean + +all: $(OUTFILE) + +# Cache the downloaded binary in bin/ (re-download only if missing) +$(BINDIR)/$(BINARY): + mkdir -p $(BINDIR) + @echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..." + wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \ + curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)" + chmod +x $@ + @echo "Binary ready: $@" + +# Build the package +$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null) + @rm -rf $(STAGEDIR) + @mkdir -p $(STAGEDIR)/bin + @cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt + @chmod +x $(STAGEDIR)/bin/newt + @cp -r merge/. $(STAGEDIR)/ + @chmod +x \ + $(STAGEDIR)/etc/init \ + $(STAGEDIR)/etc/install \ + $(STAGEDIR)/etc/uninstall \ + $(STAGEDIR)/www/index.cgi + tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \ + -C .build $(MODNAME) | gzip -n > $@ + @echo "Created: $@" + +clean: + rm -rf .build $(MODNAME).*.tgz + @echo "Cleaned." diff --git a/packages/advantech/merge/etc/defaults b/packages/advantech/merge/etc/defaults new file mode 100644 index 0000000..e566be4 --- /dev/null +++ b/packages/advantech/merge/etc/defaults @@ -0,0 +1,61 @@ +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT= +MOD_PANGOLIN_SITE_ID= +MOD_PANGOLIN_SITE_SECRET= + +# Core networking +MOD_PANGOLIN_SITE_MTU= +MOD_PANGOLIN_SITE_DNS= +MOD_PANGOLIN_SITE_INTERFACE= +MOD_PANGOLIN_SITE_PORT= +MOD_PANGOLIN_SITE_UPDOWN_SCRIPT= +MOD_PANGOLIN_SITE_PREFER_ENDPOINT= + +# Logging +MOD_PANGOLIN_SITE_LOG_LEVEL= + +# Behavior toggles (true/false) +MOD_PANGOLIN_SITE_DISABLE_CLIENTS= +MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE= +MOD_PANGOLIN_SITE_ENFORCE_HC_CERT= +MOD_PANGOLIN_SITE_NO_CLOUD= + +# Timers +MOD_PANGOLIN_SITE_PING_INTERVAL= +MOD_PANGOLIN_SITE_PING_TIMEOUT= +MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT= + +# Docker integration +MOD_PANGOLIN_SITE_DOCKER_SOCKET= +MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION= + +# Health / files +MOD_PANGOLIN_SITE_HEALTH_FILE= +MOD_PANGOLIN_SITE_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_CONFIG_FILE= + +# Provisioning +MOD_PANGOLIN_SITE_PROVISIONING_KEY= +MOD_PANGOLIN_SITE_NAME= + +# Auth daemon +MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED= +MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD= +MOD_PANGOLIN_SITE_AD_KEY= +MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE= +MOD_PANGOLIN_SITE_AD_CA_CERT_PATH= + +# Metrics / observability +MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED= +MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED= +MOD_PANGOLIN_SITE_ADMIN_ADDR= +MOD_PANGOLIN_SITE_REGION= +MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES= +MOD_PANGOLIN_SITE_PPROF_ENABLED= + +# mTLS +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT= +MOD_PANGOLIN_SITE_TLS_CLIENT_KEY= +MOD_PANGOLIN_SITE_TLS_CLIENT_CAS= +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12= diff --git a/packages/advantech/merge/etc/description b/packages/advantech/merge/etc/description new file mode 100644 index 0000000..04f09a4 --- /dev/null +++ b/packages/advantech/merge/etc/description @@ -0,0 +1,4 @@ +Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a +Pangolin server, enabling secure remote access to local resources without opening +firewall ports. Configure the server endpoint, ID, and secret via the web +interface to establish the tunnel automatically on startup. diff --git a/packages/advantech/merge/etc/init b/packages/advantech/merge/etc/init new file mode 100644 index 0000000..cf90ae0 --- /dev/null +++ b/packages/advantech/merge/etc/init @@ -0,0 +1,124 @@ +#!/bin/sh + +MODNAME=newt +PIDFILE=/tmp/newt.pid +LOGFILE=/tmp/newt.log + +set_setting_raw() { + key="$1" + value="$2" + [ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings" +} + +/usr/bin/logger -t $MODNAME "DEBUG: $0 $@" + +case "$1" in + start) + . /opt/$MODNAME/etc/settings + if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then + echo "$MODNAME is disabled in settings, skipping start" + exit 0 + fi + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))" + exit 0 + fi + + # Starting newt implies enable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + + # Map module settings to Newt environment variables. + [ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT" + [ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID" + [ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET" + [ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU" + [ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS" + [ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL" + [ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" + [ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT" + [ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" + [ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET" + [ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL" + [ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" + [ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE" + [ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" + [ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY" + [ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" + [ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" + [ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR" + [ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION" + [ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" + [ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" + [ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY" + [ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME" + [ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE" + + export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP" + export NEWT_PID_FILE="$PIDFILE" + + echo "Starting $MODNAME..." + if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then + /opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 & + else + /opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 & + fi + echo $! > "$PIDFILE" + echo "Started $MODNAME (PID: $(cat "$PIDFILE"))" + exit 0 + ;; + stop) + echo "Stopping $MODNAME..." + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null + rm -f "$PIDFILE" + fi + killall newt 2>/dev/null + # Stopping newt implies disable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + echo "Stopped $MODNAME" + exit 0 + ;; + restart) + $0 stop + sleep 1 + # Restart should remain enabled after reboot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + $0 start + ;; + status) + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is running (PID: $(cat "$PIDFILE"))" + exit 0 + else + echo "$MODNAME is not running" + exit 1 + fi + ;; + defaults) + cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null + ;; + *) + echo "Usage: $0 {start|stop|restart|status|defaults}" + exit 1 +esac diff --git a/packages/advantech/merge/etc/install b/packages/advantech/merge/etc/install new file mode 100644 index 0000000..f83fb05 --- /dev/null +++ b/packages/advantech/merge/etc/install @@ -0,0 +1,15 @@ +#!/bin/sh + +MODNAME=newt + +# Ensure scripts are executable +chmod +x /opt/$MODNAME/etc/init +chmod +x /opt/$MODNAME/etc/install +chmod +x /opt/$MODNAME/etc/uninstall +chmod +x /opt/$MODNAME/bin/newt +chmod +x /opt/$MODNAME/www/index.cgi + +# Secure the web interface with router authentication +ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd + +exit 0 diff --git a/packages/advantech/merge/etc/name b/packages/advantech/merge/etc/name new file mode 100644 index 0000000..0656222 --- /dev/null +++ b/packages/advantech/merge/etc/name @@ -0,0 +1 @@ +Pangolin Site diff --git a/packages/advantech/merge/etc/summary b/packages/advantech/merge/etc/summary new file mode 100644 index 0000000..64f982c --- /dev/null +++ b/packages/advantech/merge/etc/summary @@ -0,0 +1 @@ +Tunnel client for Pangolin secure remote access. diff --git a/packages/advantech/merge/etc/uninstall b/packages/advantech/merge/etc/uninstall new file mode 100644 index 0000000..70a9e38 --- /dev/null +++ b/packages/advantech/merge/etc/uninstall @@ -0,0 +1,9 @@ +#!/bin/sh + +MODNAME=newt + +rm -f /opt/$MODNAME/www/.htpasswd +rm -f /tmp/newt.pid +rm -f /tmp/newt.log + +exit 0 diff --git a/packages/advantech/merge/etc/version b/packages/advantech/merge/etc/version new file mode 100644 index 0000000..e0a6b34 --- /dev/null +++ b/packages/advantech/merge/etc/version @@ -0,0 +1 @@ +1.12.5 diff --git a/packages/advantech/merge/www/index.cgi b/packages/advantech/merge/www/index.cgi new file mode 100644 index 0000000..c21233e --- /dev/null +++ b/packages/advantech/merge/www/index.cgi @@ -0,0 +1,282 @@ +#!/bin/sh + +MODNAME=newt +SETTINGS=/opt/$MODNAME/etc/settings +LOGFILE=/tmp/newt.log +PIDFILE=/tmp/newt.pid + +# Escape special HTML characters +htmlesc() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' +} + +# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets) +urldecode() { + printf '%s' "$1" | sed \ + -e 's/+/ /g' \ + -e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \ + -e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \ + -e 's/%40/@/g' \ + -e 's/%2[Ee]/./g' \ + -e 's/%2[Dd]/-/g' \ + -e 's/%5[Ff]/_/g' \ + -e 's/%3[Dd]/=/g' \ + -e 's/%3[Ff]/?/g' \ + -e 's/%23/#/g' \ + -e 's/%25/%/g' +} + +# Extract a named field from URL-encoded POST data (awk splits on & without tr) +get_field() { + printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}' +} + +# Check whether newt process is alive +is_running() { + [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null +} + +ensure_settings_file() { + [ -f "$SETTINGS" ] && return + if [ -f "/opt/$MODNAME/etc/defaults" ]; then + cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null + else + printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS" + fi +} + +set_setting_raw() { + key="$1" + value="$2" + ensure_settings_file + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" +} + +quote_sh_value() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# ── Read POST body ────────────────────────────────────────────────────────── +POST_DATA="" +if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then + POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null) +fi + +# ── Load current settings ─────────────────────────────────────────────────── +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Handle actions ────────────────────────────────────────────────────────── +if [ -n "$POST_DATA" ]; then + ACTION=$(get_field "action" "$POST_DATA") + case "$ACTION" in + save) + EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")") + ID=$(urldecode "$(get_field "id" "$POST_DATA")") + SEC=$(urldecode "$(get_field "secret" "$POST_DATA")") + set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\"" + set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\"" + set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\"" + ;; + start) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init start >/dev/null 2>&1 + ;; + stop) + /opt/$MODNAME/etc/init stop >/dev/null 2>&1 + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + ;; + restart) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init restart >/dev/null 2>&1 + ;; + clearlog) + printf '' > "$LOGFILE" + ;; + esac +fi + +# Reload settings after actions. +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Status ────────────────────────────────────────────────────────────────── +if is_running; then + STATUS_TEXT="Running" + STATUS_CLASS="running" + DISABLED="disabled" + SETTINGS_HINT='
Stop Newt first to edit settings.
' +else + STATUS_TEXT="Stopped" + STATUS_CLASS="stopped" + DISABLED="" + SETTINGS_HINT="" +fi + +EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT") +ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID") +SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET") +EP_INPUT_ESC="$EP_ESC" +[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net" + +# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─ +LOG_HTML="" +if [ -f "$LOGFILE" ]; then + LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/\$/\$/g') +fi + +# ── Output ────────────────────────────────────────────────────────────────── +printf 'Content-type: text/html\r\n\r\n' + +# Static head — split around the conditional refresh meta tag +cat << 'HEAD_START' + + + + +HEAD_START + +# Only auto-refresh while newt is running (avoids wiping settings form mid-edit) +[ "$STATUS_CLASS" = "running" ] && printf '\n' + +cat << 'STATIC_HEAD' +Pangolin Site + + + + +
Router Apps - Pangolin Site
+
+STATIC_HEAD + +# Status card (double-quoted heredoc — variables expand) +cat << STATUS_CARD +
+
Status
+
+
${STATUS_TEXT}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+STATUS_CARD + +# Settings card +cat << SETTINGS_CARD +
+
Settings
+
+${SETTINGS_HINT}
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+SETTINGS_CARD + +# Log card header +cat << 'LOG_HEADER' +
+
+Log (last 100 lines, auto-refreshes every 10s while running) + +
+ + +
+
+ +
+
+
+
+
+LOG_HEADER + +# Log content — printed with printf to prevent any shell expansion +printf '%s' "$LOG_HTML" + +# Close tags (static) +cat << 'STATIC_FOOTER' +
+
+
+ + +STATIC_FOOTER diff --git a/packages/openwrt/.gitkeep b/packages/openwrt/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/teltonika/.gitkeep b/packages/teltonika/.gitkeep new file mode 100644 index 0000000..e69de29 From 34d2cad512c14979f4554cee72796e879f3f3dae Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 20:37:53 -0700 Subject: [PATCH 25/36] Add to cicd Former-commit-id: 3d57d37c9eee33f4d5052108730fa340dffe8712 --- .github/workflows/cicd.yml | 15 +++++++++++++++ packages/advantech/Makefile | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index cb6eecd..09be3df 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -897,6 +897,20 @@ jobs: set -euo pipefail make -j 10 go-build-release VERSION="${TAG}" + - name: Build Advantech packages + shell: bash + run: | + set -euo pipefail + ADVANTECH_DIR="packages/advantech" + + mkdir -p "${ADVANTECH_DIR}/bin" + install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64" + install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32" + + for platform in v2 v2i v3 v4 v4i; do + make -C "${ADVANTECH_DIR}" PLATFORM="${platform}" + done + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: @@ -905,6 +919,7 @@ jobs: prerelease: ${{ env.IS_RC == 'true' }} files: | bin/* + packages/advantech/*.tgz fail_on_unmatched_files: true draft: true body: | diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile index 2250025..9598e62 100644 --- a/packages/advantech/Makefile +++ b/packages/advantech/Makefile @@ -7,11 +7,12 @@ PLATFORM ?= v4 arch_v4 := arm64 arch_v4i := arm64 arch_v3 := arm32 +arch_v2 := arm32 arch_v2i := arm32 NEWT_ARCH := $(arch_$(PLATFORM)) ifeq ($(NEWT_ARCH),) -$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2i) +$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i) endif BINARY := newt_linux_$(NEWT_ARCH) From 71eb81182952eab7155fb8bd25538f7716aa5033 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 25 May 2026 21:58:27 -0700 Subject: [PATCH 26/36] Add sighup and block flag to block connections Former-commit-id: 694a7986f5b7c5f0610073b882e7a3203fe27108 --- clients.go | 7 +++++ clients/clients.go | 27 ++++++++++++++++++ main.go | 60 +++++++++++++++++++++++++++++++++++++++ netstack2/handlers.go | 18 ++++++++---- netstack2/http_handler.go | 5 ---- netstack2/proxy.go | 26 ++++++++++++++++- netstack2/tun.go | 5 ---- proxy/manager.go | 46 +++++++++++++++++++++++------- websocket/client.go | 5 ++++ websocket/types.go | 3 +- 10 files changed, 175 insertions(+), 27 deletions(-) diff --git a/clients.go b/clients.go index d650eeb..53ce63f 100644 --- a/clients.go +++ b/clients.go @@ -63,6 +63,13 @@ func closeClients() { } } +// setClientsBlocked enables or disables connection blocking on the WireGuard service. +func setClientsBlocked(v bool) { + if wgService != nil { + wgService.SetBlocked(v) + } +} + func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) { if !ready { return diff --git a/clients/clients.go b/clients/clients.go index 3862160..cbd2816 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/fosrl/newt/bind" @@ -114,6 +115,9 @@ type WireGuardService struct { netstackListener net.PacketConn netstackListenerMu sync.Mutex wgTesterServer *wgtester.Server + + // connection blocking: when true, all new incoming connections are dropped + blocked atomic.Bool } // generateChainId generates a random chain ID for deduplicating round-trip messages. @@ -286,6 +290,21 @@ func (s *WireGuardService) GetPublicKey() wgtypes.Key { return s.key.PublicKey() } +// SetBlocked enables or disables connection blocking for this WireGuard service. +// The state is persisted and applied immediately to any active proxy handler. +func (s *WireGuardService) SetBlocked(v bool) { + s.blocked.Store(v) + s.mu.Lock() + tnet := s.tnet + s.mu.Unlock() + if tnet == nil { + return + } + if ph := tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(v) + } +} + // SetOnNetstackReady sets a callback function to be called when the netstack interface is ready func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) { s.onNetstackReady = callback @@ -891,6 +910,14 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Note: we already unlocked above, so don't use defer unlock + + // Apply any pending blocked state to the newly created proxy handler + if s.blocked.Load() { + if ph := s.tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(true) + } + } + return nil } diff --git a/main.go b/main.go index 0c8e7dd..85d15e7 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "os/signal" "strconv" "strings" + "sync/atomic" "syscall" "time" @@ -736,11 +737,20 @@ func runNewtMain(ctx context.Context) { var connected bool var wgData WgData var dockerEventMonitor *docker.EventMonitor + var connectionBlocked atomic.Bool + var currentPM atomic.Pointer[proxy.ProxyManager] if !disableClients { setupClients(client) } + // Initialize connection blocked state from config + connectionBlocked.Store(client.GetConfig().Blocked) + if connectionBlocked.Load() { + logger.Info("Connection blocking is enabled (from config)") + setClientsBlocked(true) + } + // Initialize health check monitor with status change callback healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) { logger.Debug("Health check status update for %d targets", len(targets)) @@ -780,6 +790,7 @@ func runNewtMain(ctx context.Context) { // Stop proxy manager if running if pm != nil { pm.Stop() + currentPM.Store(nil) pm = nil } @@ -950,6 +961,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( pm.SetUDPIdleTimeout(udpProxyIdleTimeout) // Set tunnel_id for metrics (WireGuard peer public key) pm.SetTunnelID(wgData.PublicKey) + pm.SetBlocked(connectionBlocked.Load()) + currentPM.Store(pm) connected = true @@ -1925,6 +1938,53 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return nil }) + // Handle SIGHUP for config reload + sighupChan := make(chan os.Signal, 1) + signal.Notify(sighupChan, syscall.SIGHUP) + go func() { + defer signal.Stop(sighupChan) + for { + select { + case <-sighupChan: + logger.Info("SIGHUP received, reloading config...") + cfgPath := client.GetConfigFilePath() + data, err := os.ReadFile(cfgPath) + if err != nil { + logger.Error("Failed to read config file on SIGHUP: %v", err) + continue + } + var newCfg websocket.Config + if err := json.Unmarshal(data, &newCfg); err != nil { + logger.Error("Failed to parse config file on SIGHUP: %v", err) + continue + } + oldCfg := client.GetConfig() + // If credentials changed, exit so the supervisor can restart with new values + if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { + logger.Info("Config credentials changed (endpoint/id/secret), exiting for supervisor restart...") + os.Exit(0) + } + // If blocked state changed, apply in-place without restart + if newCfg.Blocked != connectionBlocked.Load() { + connectionBlocked.Store(newCfg.Blocked) + if newCfg.Blocked { + logger.Info("Config reload: connection blocking enabled") + } else { + logger.Info("Config reload: connection blocking disabled") + } + if p := currentPM.Load(); p != nil { + p.SetBlocked(newCfg.Blocked) + } + setClientsBlocked(newCfg.Blocked) + } else { + logger.Info("Config reload: no relevant changes detected") + } + case <-ctx.Done(): + return + } + } + }() + // Connect to the WebSocket server if err := client.Connect(); err != nil { logger.Fatal("Failed to connect to server: %v", err) diff --git a/netstack2/handlers.go b/netstack2/handlers.go index e28a543..e6ea62e 100644 --- a/netstack2/handlers.go +++ b/netstack2/handlers.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( @@ -144,6 +139,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo dstIP := id.LocalAddress.String() dstPort := id.LocalPort + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + netstackConn.Close() + return + } + // For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has // Protocol configured, hand the connection off to the HTTP handler which // takes full ownership of the lifecycle (the defer close must not be @@ -315,6 +317,12 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + return + } + // Check if there's a destination rewrite for this connection (e.g., localhost targets) actualDstIP := dstIP if h.proxyHandler != nil { diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go index ece82e9..7a4d69d 100644 --- a/netstack2/http_handler.go +++ b/netstack2/http_handler.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 95fab6a..2df5c83 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "sync" + "sync/atomic" "time" "github.com/fosrl/newt/logger" @@ -134,6 +135,7 @@ type ProxyHandler struct { notifiable channel.Notification // Notification handler for triggering reads accessLogger *AccessLogger // Access logger for tracking sessions httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests + blocked atomic.Bool // when true, all new connections are dropped } // ProxyHandlerOptions configures the proxy handler @@ -240,6 +242,28 @@ func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { p.subnetLookup.AddSubnet(rule) } +// SetBlocked enables or disables connection blocking on this proxy handler. +// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately. +func (p *ProxyHandler) SetBlocked(v bool) { + if p == nil { + return + } + p.blocked.Store(v) + if v { + logger.Info("ProxyHandler: connection blocking enabled") + } else { + logger.Info("ProxyHandler: connection blocking disabled") + } +} + +// IsBlocked returns true if connection blocking is currently enabled. +func (p *ProxyHandler) IsBlocked() bool { + if p == nil { + return false + } + return p.blocked.Load() +} + // RemoveSubnetRule removes a subnet from the proxy handler func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) { if p == nil || !p.enabled { @@ -612,7 +636,7 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool { } // logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)", - // srcAddr, dstAddr, protocol, dstPort) + // srcAddr, dstAddr, protocol, dstPort) return false } diff --git a/netstack2/tun.go b/netstack2/tun.go index fae90dd..d104f2e 100644 --- a/netstack2/tun.go +++ b/netstack2/tun.go @@ -1,8 +1,3 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved. - */ - package netstack2 import ( diff --git a/proxy/manager.go b/proxy/manager.go index 0d1f750..b04be26 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -70,6 +70,9 @@ type ProxyManager struct { asyncBytes bool flushStop chan struct{} udpIdleTimeout time.Duration + + // connection blocking + blocked atomic.Bool } // tunnelEntry holds per-tunnel attributes and (optional) async counters. @@ -149,12 +152,12 @@ func classifyProxyError(err error) string { // NewProxyManager creates a new proxy manager instance func NewProxyManager(tnet *netstack.Net) *ProxyManager { return &ProxyManager{ - tnet: tnet, - tcpTargets: make(map[string]map[int]string), - udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), - tunnels: make(map[string]*tunnelEntry), + tnet: tnet, + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), + tunnels: make(map[string]*tunnelEntry), udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -229,10 +232,10 @@ func (pm *ProxyManager) ClearTunnelID() { // init function without tnet func NewProxyManagerWithoutTNet() *ProxyManager { return &ProxyManager{ - tcpTargets: make(map[string]map[int]string), - udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), udpIdleTimeout: defaultUDPIdleTimeout, } } @@ -244,6 +247,18 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { pm.tnet = tnet } +// SetBlocked enables or disables connection blocking. +// When enabled, all new incoming TCP connections are immediately closed +// and all incoming UDP packets are silently dropped. +func (pm *ProxyManager) SetBlocked(v bool) { + pm.blocked.Store(v) + if v { + logger.Info("ProxyManager: connection blocking enabled, new connections will be dropped") + } else { + logger.Info("ProxyManager: connection blocking disabled, accepting connections") + } +} + // AddTarget adds as new target for proxying func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr string) error { pm.mutex.Lock() @@ -535,6 +550,12 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) } tunnelID := pm.currentTunnelID + // Drop connection if blocking is enabled + if pm.blocked.Load() { + conn.Close() + logger.Debug("TCP proxy: connection dropped (blocking enabled)") + continue + } telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "") telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened) if tunnelID != "" { @@ -631,6 +652,11 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { } clientKey := remoteAddr.String() + // Drop packet if blocking is enabled + if pm.blocked.Load() { + logger.Debug("UDP proxy: packet dropped (blocking enabled)") + continue + } // bytes from client -> target (direction=in) if pm.currentTunnelID != "" && n > 0 { if pm.asyncBytes { diff --git a/websocket/client.go b/websocket/client.go index 22187ac..0f4ea39 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -172,6 +172,11 @@ func (c *Client) GetConfig() *Config { return c.config } +// GetConfigFilePath returns the resolved path to the config file used by this client. +func (c *Client) GetConfigFilePath() string { + return getConfigPath(c.clientType, c.configFilePath) +} + func (c *Client) GetServerVersion() string { return c.serverVersion } diff --git a/websocket/types.go b/websocket/types.go index 195e06f..58b304e 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -7,6 +7,7 @@ type Config struct { TlsClientCert string `json:"tlsClientCert"` ProvisioningKey string `json:"provisioningKey,omitempty"` Name string `json:"name,omitempty"` + Blocked bool `json:"blocked,omitempty"` } type TokenResponse struct { @@ -31,4 +32,4 @@ type WSMessage struct { Type string `json:"type"` Data interface{} `json:"data"` ConfigVersion int64 `json:"configVersion,omitempty"` -} \ No newline at end of file +} From e223a35a629d119f91c044a13e37e363da52bbbf Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 26 May 2026 14:09:31 -0700 Subject: [PATCH 27/36] Reexec if the config changes Former-commit-id: a7b028b154b926f0f0424b3eaccbf148ecce29d9 --- main.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 85d15e7..2e50f27 100644 --- a/main.go +++ b/main.go @@ -1959,10 +1959,24 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( continue } oldCfg := client.GetConfig() - // If credentials changed, exit so the supervisor can restart with new values + // If credentials changed, clean up and re-exec ourselves with the same args if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { - logger.Info("Config credentials changed (endpoint/id/secret), exiting for supervisor restart...") - os.Exit(0) + logger.Info("Config credentials changed (endpoint/id/secret), restarting...") + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + exe, exeErr := os.Executable() + if exeErr != nil { + logger.Error("Failed to get executable path for restart: %v", exeErr) + os.Exit(0) + } + if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil { + logger.Error("Failed to re-exec for restart: %v", err) + os.Exit(1) + } } // If blocked state changed, apply in-place without restart if newCfg.Blocked != connectionBlocked.Load() { From 7ff97eda51779073be18b0381ecfbd8e48dc70a3 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 May 2026 16:33:16 -0700 Subject: [PATCH 28/36] Add restart endpoint Former-commit-id: 734542c3ed83ba5a681aaacfce607aae1b88f901 --- main.go | 22 +++++++++++++++------- reexec_unix.go | 21 +++++++++++++++++++++ reexec_windows.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 reexec_unix.go create mode 100644 reexec_windows.go diff --git a/main.go b/main.go index 2e50f27..3366f89 100644 --- a/main.go +++ b/main.go @@ -1032,6 +1032,19 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Info("Tunnel destroyed, ready for reconnection") }) + client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) { + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + }) + client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) { logger.Info("Received termination message") if wgData.PublicKey != "" { @@ -1968,13 +1981,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( healthMonitor.Stop() } client.Close() - exe, exeErr := os.Executable() - if exeErr != nil { - logger.Error("Failed to get executable path for restart: %v", exeErr) - os.Exit(0) - } - if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil { - logger.Error("Failed to re-exec for restart: %v", err) + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) os.Exit(1) } } diff --git a/reexec_unix.go b/reexec_unix.go new file mode 100644 index 0000000..b6c01cc --- /dev/null +++ b/reexec_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "syscall" +) + +// reexec replaces the current process image with a fresh copy of itself, +// preserving all arguments and environment variables. On success it never +// returns (execve replaces the process in-place). On failure it returns an +// error describing why the exec could not be performed. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + return syscall.Exec(exe, os.Args, os.Environ()) +} diff --git a/reexec_windows.go b/reexec_windows.go new file mode 100644 index 0000000..770f544 --- /dev/null +++ b/reexec_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec spawns a new copy of the current process with the same arguments and +// environment, then exits the current process. On Windows, execve is not +// available, so we start a child process and exit. On success it never returns +// (os.Exit terminates the current process). On failure it returns an error. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start new process: %w", err) + } + os.Exit(0) + return nil // unreachable +} From e3fe89b43183df508f0fca2205aec8bf391a2438 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 29 May 2026 15:57:39 -0700 Subject: [PATCH 29/36] Remove github.com/msteinert/pam/v2 Former-commit-id: b33a12a528ebdaa81a49c445d581be3969d9de48 --- go.mod | 1 - go.sum | 2 - nativessh/pam_linux.go | 275 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 257 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 0dc00e8..b5a8f8e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( 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 3ad78d4..bb059f0 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,6 @@ 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= diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index b6f359d..53e8506 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -3,32 +3,271 @@ package nativessh import ( + "bufio" + "bytes" + "crypto/sha512" + "errors" "fmt" + "os" + "strconv" + "strings" - "github.com/msteinert/pam/v2" + "golang.org/x/crypto/bcrypt" ) -// 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. +// VerifySystemPassword authenticates username/password by reading /etc/shadow +// and verifying the stored hash using pure-Go cryptography (no CGo required). +// Supported hash schemes: bcrypt ($2a$/$2b$/$2y$) and SHA-512 crypt ($6$). 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 - } - }) + hash, err := readShadowHash(username) if err != nil { - return fmt.Errorf("PAM start: %w", err) + return fmt.Errorf("shadow: %w", err) + } + return cryptVerify(password, hash) +} + +// readShadowHash reads /etc/shadow and returns the password hash for username. +func readShadowHash(username string) (string, error) { + f, err := os.Open("/etc/shadow") + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.SplitN(scanner.Text(), ":", 3) + if len(fields) < 2 || fields[0] != username { + continue + } + h := fields[1] + if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" { + return "", errors.New("account locked or has no password") + } + return h, nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("user not found in shadow database") +} + +// cryptVerify verifies password against a crypt(3) hash string. +func cryptVerify(password, hash string) error { + switch { + case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"): + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + case strings.HasPrefix(hash, "$6$"): + computed, err := sha512CryptHash([]byte(password), hash) + if err != nil { + return err + } + if computed != hash { + return errors.New("authentication failed") + } + return nil + default: + return fmt.Errorf("unsupported password hash scheme") + } +} + +// --- SHA-512 crypt ($6$) --- +// Specification: https://www.akkadia.org/docs/sha-crypt.txt + +const ( + sha512CryptMagic = "$6$" + sha512CryptRoundsDefault = 5000 + sha512CryptRoundsMin = 1000 + sha512CryptRoundsMax = 999999999 + sha512CryptSaltLenMax = 16 + sha512CryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +var sha512CryptRoundsPrefix = []byte("rounds=") + +// sha512CryptHash computes a SHA-512 crypt hash for key. saltStr may be a +// full stored hash string (for verification) or just the salt parameters. +func sha512CryptHash(key []byte, saltStr string) (string, error) { + salt := []byte(saltStr) + + if !bytes.HasPrefix(salt, []byte(sha512CryptMagic)) { + return "", errors.New("sha512crypt: invalid prefix") + } + salt = salt[len(sha512CryptMagic):] + + rounds := sha512CryptRoundsDefault + isRoundsDef := false + + if bytes.HasPrefix(salt, sha512CryptRoundsPrefix) { + salt = salt[len(sha512CryptRoundsPrefix):] + i := bytes.IndexByte(salt, '$') + if i < 0 { + return "", errors.New("sha512crypt: malformed rounds field") + } + r, err := strconv.Atoi(string(salt[:i])) + if err != nil { + return "", fmt.Errorf("sha512crypt: invalid rounds: %w", err) + } + salt = salt[i+1:] + isRoundsDef = true + rounds = r + if rounds < sha512CryptRoundsMin { + rounds = sha512CryptRoundsMin + } else if rounds > sha512CryptRoundsMax { + rounds = sha512CryptRoundsMax + } } - if err := tx.Authenticate(0); err != nil { - return fmt.Errorf("PAM authenticate: %w", err) + // When saltStr is a full hash, strip the stored hash after the last '$'. + if i := bytes.IndexByte(salt, '$'); i >= 0 { + salt = salt[:i] } - if err := tx.AcctMgmt(0); err != nil { - return fmt.Errorf("PAM acct_mgmt: %w", err) + if len(salt) > sha512CryptSaltLenMax { + salt = salt[:sha512CryptSaltLenMax] } - return nil + + // Alternate = SHA512(key + salt + key) + altH := sha512.New() + altH.Write(key) + altH.Write(salt) + altH.Write(key) + altSum := altH.Sum(nil) + + // Digest A = SHA512(key + salt + altSum-cycling-to-len(key) + bit-pattern) + aH := sha512.New() + aH.Write(key) + aH.Write(salt) + for i := len(key); i > 0; i -= 64 { + if i > 64 { + aH.Write(altSum) + } else { + aH.Write(altSum[:i]) + } + } + for i := len(key); i > 0; i >>= 1 { + if i&1 != 0 { + aH.Write(altSum) + } else { + aH.Write(key) + } + } + aSum := aH.Sum(nil) + + // P-sequence: SHA512(key×len(key)) cycled to len(key) bytes + pH := sha512.New() + for i := 0; i < len(key); i++ { + pH.Write(key) + } + pSeq := sha512CryptCycle(pH.Sum(nil), len(key)) + + // S-sequence: SHA512(salt×(16+aSum[0])) cycled to len(salt) bytes + sH := sha512.New() + for i := 0; i < 16+int(aSum[0]); i++ { + sH.Write(salt) + } + sSeq := sha512CryptCycle(sH.Sum(nil), len(salt)) + + // Iterative hashing rounds + cSum := aSum + for i := 0; i < rounds; i++ { + c := sha512.New() + if i&1 != 0 { + c.Write(pSeq) + } else { + c.Write(cSum) + } + if i%3 != 0 { + c.Write(sSeq) + } + if i%7 != 0 { + c.Write(pSeq) + } + if i&1 != 0 { + c.Write(cSum) + } else { + c.Write(pSeq) + } + cSum = c.Sum(nil) + } + + // Build the output string + out := []byte(sha512CryptMagic) + if isRoundsDef { + out = append(out, fmt.Sprintf("rounds=%d$", rounds)...) + } + out = append(out, salt...) + out = append(out, '$') + out = append(out, sha512CryptEncode(cSum)...) + return string(out), nil +} + +// sha512CryptCycle returns exactly n bytes by cycling the 64-byte src slice. +func sha512CryptCycle(src []byte, n int) []byte { + dst := make([]byte, 0, n) + for i := n; i > 64; i -= 64 { + dst = append(dst, src...) + } + if rem := n % 64; rem == 0 && n > 0 { + dst = append(dst, src...) + } else if rem > 0 { + dst = append(dst, src[:rem]...) + } + return dst +} + +// sha512CryptEncode applies the sha512crypt byte permutation and encodes the +// result with the crypt(3) base-64 alphabet (86 output characters for 64 input bytes). +func sha512CryptEncode(sum []byte) []byte { + perm := []byte{ + sum[42], sum[21], sum[0], + sum[1], sum[43], sum[22], + sum[23], sum[2], sum[44], + sum[45], sum[24], sum[3], + sum[4], sum[46], sum[25], + sum[26], sum[5], sum[47], + sum[48], sum[27], sum[6], + sum[7], sum[49], sum[28], + sum[29], sum[8], sum[50], + sum[51], sum[30], sum[9], + sum[10], sum[52], sum[31], + sum[32], sum[11], sum[53], + sum[54], sum[33], sum[12], + sum[13], sum[55], sum[34], + sum[35], sum[14], sum[56], + sum[57], sum[36], sum[15], + sum[16], sum[58], sum[37], + sum[38], sum[17], sum[59], + sum[60], sum[39], sum[18], + sum[19], sum[61], sum[40], + sum[41], sum[20], sum[62], + sum[63], + } + src := perm + out := make([]byte, 0, 86) + for len(src) > 0 { + switch len(src) { + default: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], + sha512CryptAlphabet[((src[1]>>4)|(src[2]<<4))&0x3f], + sha512CryptAlphabet[(src[2]>>2)&0x3f], + ) + src = src[3:] + case 2: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], + sha512CryptAlphabet[(src[1]>>4)&0x3f], + ) + src = src[2:] + case 1: + out = append(out, + sha512CryptAlphabet[src[0]&0x3f], + sha512CryptAlphabet[(src[0]>>6)&0x3f], + ) + src = src[1:] + } + } + return out } From b35dd025bdc2a9fd000598a412da4651bd60dbf9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 30 May 2026 11:53:31 -0700 Subject: [PATCH 30/36] Get native auth working Former-commit-id: e5345f67172b4eade91dc926dd96a8584a0f206d --- browsergateway/browsergateway.go | 16 ++ browsergateway/ssh.go | 11 +- go.mod | 5 +- go.sum | 22 +++ main.go | 2 +- nativessh/auth.go | 17 +- nativessh/pam_linux.go | 280 ++++++------------------------- newt.REMOVED.git-id | 1 + 8 files changed, 119 insertions(+), 235 deletions(-) create mode 100644 newt.REMOVED.git-id diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 2d0c6a8..60fecb5 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -96,6 +96,22 @@ func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) return false } +// isTokenValid reports whether the given authToken matches any registered +// target of the specified type. Used for native SSH mode where there is no +// external destination to match against. +func (g *Gateway) isTokenValid(targetType, authToken string) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType { + if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 { + 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 { diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index eaea313..bf5a83a 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -2,7 +2,6 @@ package browsergateway import ( "context" - "crypto/subtle" "encoding/json" "fmt" "log" @@ -12,6 +11,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/fosrl/newt/logger" "golang.org/x/crypto/ssh" ) @@ -36,11 +36,14 @@ type sshServerMsg struct { // HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections. func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { + logger.Debug("SSH connection request from %s", r.RemoteAddr) ctx := r.Context() token := r.URL.Query().Get("authToken") - var nativeSSH = false + // "mode=native" (default) connects to the local SSH daemon on this host. + // "mode=proxy" connects to an arbitrary host+port supplied in query params. + nativeSSH := r.URL.Query().Get("mode") != "proxy" // In proxy mode we also need host + username from query params. var target, username string @@ -62,8 +65,8 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { } 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 { + // Native SSH mode: validate the token against any registered ssh target. + if !g.isTokenValid("ssh", token) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/go.mod b/go.mod index b5a8f8e..3207ed1 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 golang.org/x/net v0.53.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.45.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -44,11 +44,14 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-crypt/crypt v0.14.15 // indirect + github.com/go-crypt/x v0.4.16 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/go.sum b/go.sum index bb059f0..4db0e23 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ= +github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0= +github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI= +github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -43,6 +47,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150 h1:ta6N7DaOQEACq28cLa0iRqXIbchByN9Lfll08CT2GBc= +github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150/go.mod h1:SiNTKDgjKQORnazFVHXhpny7UtU0iJOqtxd7R7sCfDI= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -113,22 +119,38 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220921155015-db77216a4ee9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 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= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= diff --git a/main.go b/main.go index 281abf9..25d4102 100644 --- a/main.go +++ b/main.go @@ -534,7 +534,7 @@ func runNewtMain(ctx context.Context) { // Start auth daemon if enabled if !disableSSH { if err := startAuthDaemon(ctx); err != nil { - logger.Fatal("Failed to start auth daemon: %v", err) + logger.Warn("Did not start on site auth daemon: %v", err) } } diff --git a/nativessh/auth.go b/nativessh/auth.go index a060ef7..2de7a21 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -3,6 +3,7 @@ package nativessh import ( "bufio" "fmt" + "log" "os" "os/user" "path/filepath" @@ -58,19 +59,31 @@ func SystemUserExists(username string) bool { // // Returns nil on the first method that succeeds, or an error if all fail. func Authenticate(username, password, privateKeyPEM string) error { + log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { + log.Printf("nativessh: user %q not found on system", 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()) { + if err != nil { + log.Printf("nativessh: failed to parse private key for %q: %v", username, err) + } else if CheckAuthorizedKeys(username, signer.PublicKey()) { + log.Printf("nativessh: private key auth succeeded for %q", username) return nil + } else { + log.Printf("nativessh: private key not in authorized_keys for %q", username) } } if password != "" { - if err := VerifySystemPassword(username, password); err == nil { + if err := VerifySystemPassword(username, password); err != nil { + log.Printf("nativessh: password auth failed for %q: %v", username, err) + } else { + log.Printf("nativessh: password auth succeeded for %q", username) return nil } + } else { + log.Printf("nativessh: no password provided for %q", username) } return fmt.Errorf("authentication failed for user %q", username) } diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index 53e8506..f33828b 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -5,25 +5,71 @@ package nativessh import ( "bufio" "bytes" - "crypto/sha512" "errors" "fmt" + "log" "os" - "strconv" "strings" - "golang.org/x/crypto/bcrypt" + "github.com/go-crypt/crypt" + "github.com/go-crypt/x/yescrypt" ) -// VerifySystemPassword authenticates username/password by reading /etc/shadow -// and verifying the stored hash using pure-Go cryptography (no CGo required). -// Supported hash schemes: bcrypt ($2a$/$2b$/$2y$) and SHA-512 crypt ($6$). +// VerifySystemPassword authenticates username/password by reading /etc/shadow. +// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256 +// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt. func VerifySystemPassword(username, password string) error { hash, err := readShadowHash(username) if err != nil { + log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err) return fmt.Errorf("shadow: %w", err) } - return cryptVerify(password, hash) + + // Log the scheme prefix only (never the full hash). + scheme := "unknown" + for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} { + if strings.HasPrefix(hash, prefix) { + scheme = prefix + break + } + } + log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme) + + // Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly. + if strings.HasPrefix(hash, "$y$") { + computed, err := yescrypt.Hash([]byte(password), []byte(hash)) + if err != nil { + log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) + return fmt.Errorf("yescrypt: %w", err) + } + if !bytes.Equal(computed, []byte(hash)) { + log.Printf("nativessh/pam: yescrypt mismatch for %q", username) + return errors.New("authentication failed") + } + return nil + } + + decoder, err := crypt.NewDefaultDecoder() + if err != nil { + return fmt.Errorf("crypt decoder: %w", err) + } + + digest, err := decoder.Decode(hash) + if err != nil { + log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err) + return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err) + } + + match, err := digest.MatchAdvanced(password) + if err != nil { + log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) + return err + } + if !match { + log.Printf("nativessh/pam: password mismatch for %q", username) + return errors.New("authentication failed") + } + return nil } // readShadowHash reads /etc/shadow and returns the password hash for username. @@ -51,223 +97,3 @@ func readShadowHash(username string) (string, error) { } return "", errors.New("user not found in shadow database") } - -// cryptVerify verifies password against a crypt(3) hash string. -func cryptVerify(password, hash string) error { - switch { - case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"): - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - case strings.HasPrefix(hash, "$6$"): - computed, err := sha512CryptHash([]byte(password), hash) - if err != nil { - return err - } - if computed != hash { - return errors.New("authentication failed") - } - return nil - default: - return fmt.Errorf("unsupported password hash scheme") - } -} - -// --- SHA-512 crypt ($6$) --- -// Specification: https://www.akkadia.org/docs/sha-crypt.txt - -const ( - sha512CryptMagic = "$6$" - sha512CryptRoundsDefault = 5000 - sha512CryptRoundsMin = 1000 - sha512CryptRoundsMax = 999999999 - sha512CryptSaltLenMax = 16 - sha512CryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -) - -var sha512CryptRoundsPrefix = []byte("rounds=") - -// sha512CryptHash computes a SHA-512 crypt hash for key. saltStr may be a -// full stored hash string (for verification) or just the salt parameters. -func sha512CryptHash(key []byte, saltStr string) (string, error) { - salt := []byte(saltStr) - - if !bytes.HasPrefix(salt, []byte(sha512CryptMagic)) { - return "", errors.New("sha512crypt: invalid prefix") - } - salt = salt[len(sha512CryptMagic):] - - rounds := sha512CryptRoundsDefault - isRoundsDef := false - - if bytes.HasPrefix(salt, sha512CryptRoundsPrefix) { - salt = salt[len(sha512CryptRoundsPrefix):] - i := bytes.IndexByte(salt, '$') - if i < 0 { - return "", errors.New("sha512crypt: malformed rounds field") - } - r, err := strconv.Atoi(string(salt[:i])) - if err != nil { - return "", fmt.Errorf("sha512crypt: invalid rounds: %w", err) - } - salt = salt[i+1:] - isRoundsDef = true - rounds = r - if rounds < sha512CryptRoundsMin { - rounds = sha512CryptRoundsMin - } else if rounds > sha512CryptRoundsMax { - rounds = sha512CryptRoundsMax - } - } - - // When saltStr is a full hash, strip the stored hash after the last '$'. - if i := bytes.IndexByte(salt, '$'); i >= 0 { - salt = salt[:i] - } - if len(salt) > sha512CryptSaltLenMax { - salt = salt[:sha512CryptSaltLenMax] - } - - // Alternate = SHA512(key + salt + key) - altH := sha512.New() - altH.Write(key) - altH.Write(salt) - altH.Write(key) - altSum := altH.Sum(nil) - - // Digest A = SHA512(key + salt + altSum-cycling-to-len(key) + bit-pattern) - aH := sha512.New() - aH.Write(key) - aH.Write(salt) - for i := len(key); i > 0; i -= 64 { - if i > 64 { - aH.Write(altSum) - } else { - aH.Write(altSum[:i]) - } - } - for i := len(key); i > 0; i >>= 1 { - if i&1 != 0 { - aH.Write(altSum) - } else { - aH.Write(key) - } - } - aSum := aH.Sum(nil) - - // P-sequence: SHA512(key×len(key)) cycled to len(key) bytes - pH := sha512.New() - for i := 0; i < len(key); i++ { - pH.Write(key) - } - pSeq := sha512CryptCycle(pH.Sum(nil), len(key)) - - // S-sequence: SHA512(salt×(16+aSum[0])) cycled to len(salt) bytes - sH := sha512.New() - for i := 0; i < 16+int(aSum[0]); i++ { - sH.Write(salt) - } - sSeq := sha512CryptCycle(sH.Sum(nil), len(salt)) - - // Iterative hashing rounds - cSum := aSum - for i := 0; i < rounds; i++ { - c := sha512.New() - if i&1 != 0 { - c.Write(pSeq) - } else { - c.Write(cSum) - } - if i%3 != 0 { - c.Write(sSeq) - } - if i%7 != 0 { - c.Write(pSeq) - } - if i&1 != 0 { - c.Write(cSum) - } else { - c.Write(pSeq) - } - cSum = c.Sum(nil) - } - - // Build the output string - out := []byte(sha512CryptMagic) - if isRoundsDef { - out = append(out, fmt.Sprintf("rounds=%d$", rounds)...) - } - out = append(out, salt...) - out = append(out, '$') - out = append(out, sha512CryptEncode(cSum)...) - return string(out), nil -} - -// sha512CryptCycle returns exactly n bytes by cycling the 64-byte src slice. -func sha512CryptCycle(src []byte, n int) []byte { - dst := make([]byte, 0, n) - for i := n; i > 64; i -= 64 { - dst = append(dst, src...) - } - if rem := n % 64; rem == 0 && n > 0 { - dst = append(dst, src...) - } else if rem > 0 { - dst = append(dst, src[:rem]...) - } - return dst -} - -// sha512CryptEncode applies the sha512crypt byte permutation and encodes the -// result with the crypt(3) base-64 alphabet (86 output characters for 64 input bytes). -func sha512CryptEncode(sum []byte) []byte { - perm := []byte{ - sum[42], sum[21], sum[0], - sum[1], sum[43], sum[22], - sum[23], sum[2], sum[44], - sum[45], sum[24], sum[3], - sum[4], sum[46], sum[25], - sum[26], sum[5], sum[47], - sum[48], sum[27], sum[6], - sum[7], sum[49], sum[28], - sum[29], sum[8], sum[50], - sum[51], sum[30], sum[9], - sum[10], sum[52], sum[31], - sum[32], sum[11], sum[53], - sum[54], sum[33], sum[12], - sum[13], sum[55], sum[34], - sum[35], sum[14], sum[56], - sum[57], sum[36], sum[15], - sum[16], sum[58], sum[37], - sum[38], sum[17], sum[59], - sum[60], sum[39], sum[18], - sum[19], sum[61], sum[40], - sum[41], sum[20], sum[62], - sum[63], - } - src := perm - out := make([]byte, 0, 86) - for len(src) > 0 { - switch len(src) { - default: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], - sha512CryptAlphabet[((src[1]>>4)|(src[2]<<4))&0x3f], - sha512CryptAlphabet[(src[2]>>2)&0x3f], - ) - src = src[3:] - case 2: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[((src[0]>>6)|(src[1]<<2))&0x3f], - sha512CryptAlphabet[(src[1]>>4)&0x3f], - ) - src = src[2:] - case 1: - out = append(out, - sha512CryptAlphabet[src[0]&0x3f], - sha512CryptAlphabet[(src[0]>>6)&0x3f], - ) - src = src[1:] - } - } - return out -} diff --git a/newt.REMOVED.git-id b/newt.REMOVED.git-id new file mode 100644 index 0000000..2233bac --- /dev/null +++ b/newt.REMOVED.git-id @@ -0,0 +1 @@ +34062da7bd1b98e4a7953d2f8b43860d2222add0 \ No newline at end of file From 8191f30e77b15353a43d2f32b09e4694f6744b42 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 16:07:33 -0700 Subject: [PATCH 31/36] Make usernames work with native auth Former-commit-id: 55eabb16cb567d4da15c036a5061e3c1a24aed5d --- nativessh/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index e6109b1..c2ff57b 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -159,13 +159,13 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { log.Printf("nativessh: channel accept error: %v", err) return } - go s.handleSession(ch, requests) + go s.handleSession(ch, requests, sshConn.User()) } } // 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) { +func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) { defer ch.Close() var ( @@ -178,7 +178,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { case "pty-req": var err error if sess == nil { - sess, err = NewPTYSession() + sess, err = NewPTYSessionAs(username) if err != nil { log.Printf("nativessh: PTY start error: %v", err) if req.WantReply { From e77b1d93636337acf829481c1458cc45dddbffbf Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 16:24:57 -0700 Subject: [PATCH 32/36] Fall back gracefully when home dir does not exist Former-commit-id: 07bf861179c6c168fe5ef81f750dd8f217be817b --- nativessh/pty.go | 39 +++++++++++++++++++++++++++++++++++++++ nativessh/pty_unix.go | 13 +++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/nativessh/pty.go b/nativessh/pty.go index cb5fa88..3d20e35 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,10 +1,13 @@ package nativessh import ( + "bufio" "errors" "fmt" "os" "os/exec" + "os/user" + "strings" "sync" "github.com/creack/pty" @@ -31,6 +34,42 @@ func findShell() string { return "/bin/sh" } +// userShell returns the login shell configured for u in /etc/passwd. +// If the field is empty or the binary does not exist, it falls back to +// findShell so there is always a usable shell. +func userShell(u *user.User) string { + if shell := passwdShell(u.Username); shell != "" { + if _, err := exec.LookPath(shell); err == nil { + return shell + } + } + return findShell() +} + +// passwdShell reads /etc/passwd and returns the login shell for the named user. +// Returns "" if the user is not found or the file cannot be read. +func passwdShell(username string) string { + f, err := os.Open("/etc/passwd") + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || line[0] == '#' { + continue + } + // Fields: username:password:uid:gid:gecos:home:shell + fields := strings.SplitN(line, ":", 7) + if len(fields) == 7 && fields[0] == username { + return fields[6] + } + } + _ = scanner.Err() + return "" +} + // NewPTYSession spawns the best available shell in a PTY. func NewPTYSession() (*PTYSession, error) { shell := findShell() diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go index 7e89c15..1203535 100644 --- a/nativessh/pty_unix.go +++ b/nativessh/pty_unix.go @@ -4,6 +4,7 @@ package nativessh import ( "fmt" + "os" "os/exec" "os/user" "strconv" @@ -42,7 +43,15 @@ func NewPTYSessionAs(username string) (*PTYSession, error) { } } - shell := findShell() + shell := userShell(u) + + // Prefer the user's home directory as the working directory, but fall back + // to / if it does not exist (e.g. useradd was run without -m). + homeDir := u.HomeDir + if _, err := os.Stat(homeDir); err != nil { + homeDir = "/" + } + cmd := exec.Command(shell, "--login") cmd.Env = []string{ "TERM=xterm-256color", @@ -52,7 +61,7 @@ func NewPTYSessionAs(username string) (*PTYSession, error) { "SHELL=" + shell, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", } - cmd.Dir = u.HomeDir + cmd.Dir = homeDir cmd.SysProcAttr = &syscall.SysProcAttr{ Credential: &syscall.Credential{ Uid: uint32(uid), From e5bf31d118b0b855881d3fb056a7ef41328ad2b0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 31 May 2026 20:59:50 -0700 Subject: [PATCH 33/36] Cert auth working with mode push Former-commit-id: b0d894f05ae5003e9ad64496d4d2011b433b5569 --- nativessh/server.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/nativessh/server.go b/nativessh/server.go index c2ff57b..0ccd7c3 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -21,6 +21,14 @@ type CredentialStore struct { principals map[string]map[string]struct{} // username -> set of allowed principals } +// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks. +type connMetaWithUser struct { + ssh.ConnMetadata + user string +} + +func (m connMetaWithUser) User() string { return m.user } + // NewCredentialStore returns an empty, ready-to-use CredentialStore. func NewCredentialStore() *CredentialStore { return &CredentialStore{ @@ -267,13 +275,23 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu 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()) + + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + + var lastErr error + for principal := range userPrincipals { + perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key) + if err == nil { + log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) + return perms, nil } - log.Printf("nativessh: CA cert auth for user %q", meta.User()) - return perms, nil + lastErr = err + } + + if lastErr != nil { + log.Printf("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr) } } } From 8eddb908e07dfcbaf7a0ba196652f63d67d49578 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:28:20 -0700 Subject: [PATCH 34/36] Handle browser gateway push pam auth Former-commit-id: 1ba9a419cb8cc0f6c3033aa81c38026846b9a519 --- browsergateway/browsergateway.go | 7 +++ browsergateway/ssh.go | 3 +- browsergateway/ssh_native.go | 4 +- main.go | 10 ++--- nativessh/auth.go | 73 +++++++++++++++++++++++++++++++- netstack2/proxy.go | 4 +- proxy/manager.go | 4 +- 7 files changed, 91 insertions(+), 14 deletions(-) diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 60fecb5..df54b29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "sync" + + "github.com/fosrl/newt/nativessh" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -36,6 +38,9 @@ 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 + // SSHCredentials are used by native SSH browser sessions for certificate + // validation against the in-memory CA/principal store. + SSHCredentials *nativessh.CredentialStore } // Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. @@ -43,6 +48,7 @@ type Config struct { // HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. type Gateway struct { authToken string + sshCreds *nativessh.CredentialStore mu sync.RWMutex targets map[int]Target // keyed by Target.ID @@ -54,6 +60,7 @@ type Gateway struct { func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, + sshCreds: cfg.SSHCredentials, targets: make(map[int]Target), } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index bf5a83a..06ca9a0 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -21,6 +21,7 @@ type sshClientMsg struct { Type string `json:"type"` Password string `json:"password,omitempty"` // used when type="auth" PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Certificate string `json:"certificate,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" @@ -89,7 +90,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if nativeSSH { - if err := serveNativeSSHSession(ctx, ws, username); err != nil { + if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index d81e762..992f875 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -17,7 +17,7 @@ import ( // 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 { +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error { // Read the auth frame. _, authBytes, err := ws.Read(ctx) if err != nil { @@ -29,7 +29,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str } // Authenticate using host authorized_keys or PAM password. - if err := nativessh.Authenticate(username, authMsg.Password, authMsg.PrivateKey); err != nil { + if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil { sendSSHError(ctx, ws, "Authentication failed") return fmt.Errorf("auth for user %q: %w", username, err) } diff --git a/main.go b/main.go index 25d4102..cf4cf7f 100644 --- a/main.go +++ b/main.go @@ -1044,7 +1044,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) } - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) @@ -2024,7 +2024,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // If the gateway doesn't exist yet but we have a tunnel, start it if browserGateway == nil && tnet != nil { - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) if bgErr != nil { logger.Error("Failed to start browser gateway listener: %v", bgErr) @@ -2198,16 +2198,16 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if newCfg.Blocked != connectionBlocked.Load() { connectionBlocked.Store(newCfg.Blocked) if newCfg.Blocked { - logger.Info("Config reload: connection blocking enabled") + logger.Debug("Config reload: connection blocking enabled") } else { - logger.Info("Config reload: connection blocking disabled") + logger.Debug("Config reload: connection blocking disabled") } if p := currentPM.Load(); p != nil { p.SetBlocked(newCfg.Blocked) } setClientsBlocked(newCfg.Blocked) } else { - logger.Info("Config reload: no relevant changes detected") + logger.Debug("Config reload: no relevant changes detected") } case <-ctx.Done(): return diff --git a/nativessh/auth.go b/nativessh/auth.go index 2de7a21..9f9289b 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "log" + "net" "os" "os/user" "path/filepath" @@ -12,6 +13,17 @@ import ( "golang.org/x/crypto/ssh" ) +type staticConnMeta struct { + user string +} + +func (m staticConnMeta) User() string { return m.user } +func (m staticConnMeta) SessionID() []byte { return nil } +func (m staticConnMeta) ClientVersion() []byte { return nil } +func (m staticConnMeta) ServerVersion() []byte { return nil } +func (m staticConnMeta) RemoteAddr() net.Addr { return nil } +func (m staticConnMeta) LocalAddr() net.Addr { return nil } + // 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. @@ -59,22 +71,79 @@ func SystemUserExists(username string) bool { // // Returns nil on the first method that succeeds, or an error if all fail. func Authenticate(username, password, privateKeyPEM string) error { + return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "") +} + +// AuthenticateWithCertificate authenticates a user for a browser-based native +// SSH session using the same method ordering as the native SSH server: +// 1. Private key in host ~/.ssh/authorized_keys. +// 2. SSH certificate signed by the configured CA (when provided). +// 3. Password via host PAM stack. +func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error { log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { log.Printf("nativessh: user %q not found on system", username) return fmt.Errorf("user %q does not exist", username) } + + var signer ssh.Signer if privateKeyPEM != "" { - signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { log.Printf("nativessh: failed to parse private key for %q: %v", username, err) - } else if CheckAuthorizedKeys(username, signer.PublicKey()) { + } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { log.Printf("nativessh: private key auth succeeded for %q", username) return nil } else { + signer = parsedSigner log.Printf("nativessh: private key not in authorized_keys for %q", username) } } + + if store != nil && certificate != "" { + if signer == nil { + log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username) + } else { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + log.Printf("nativessh: failed to parse certificate for %q: %v", username, err) + } else { + cert, ok := pub.(*ssh.Certificate) + if !ok { + log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username) + } else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) { + log.Printf("nativessh: certificate key mismatch for %q", username) + } else { + caKey, userPrincipals := store.get(username) + if caKey == nil { + log.Printf("nativessh: CA key is not set for certificate auth user %q", username) + } else if len(userPrincipals) == 0 { + log.Printf("nativessh: no allowed principals found for certificate auth user %q", username) + } else { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + + var lastErr error + for principal := range userPrincipals { + _, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert) + if authErr == nil { + log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal) + return nil + } + lastErr = authErr + } + if lastErr != nil { + log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr) + } + } + } + } + } + } + if password != "" { if err := VerifySystemPassword(username, password); err != nil { log.Printf("nativessh: password auth failed for %q: %v", username, err) diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 2df5c83..00d6763 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -250,9 +250,9 @@ func (p *ProxyHandler) SetBlocked(v bool) { } p.blocked.Store(v) if v { - logger.Info("ProxyHandler: connection blocking enabled") + logger.Debug("ProxyHandler: connection blocking enabled") } else { - logger.Info("ProxyHandler: connection blocking disabled") + logger.Debug("ProxyHandler: connection blocking disabled") } } diff --git a/proxy/manager.go b/proxy/manager.go index b04be26..9203f18 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -253,9 +253,9 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { func (pm *ProxyManager) SetBlocked(v bool) { pm.blocked.Store(v) if v { - logger.Info("ProxyManager: connection blocking enabled, new connections will be dropped") + logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped") } else { - logger.Info("ProxyManager: connection blocking disabled, accepting connections") + logger.Debug("ProxyManager: connection blocking disabled, accepting connections") } } From 3d8a26981901dae2ff58b9dc77015997ea39d641 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:42:06 -0700 Subject: [PATCH 35/36] Fix windows compatibility issues Former-commit-id: 0e690a6a84ba4413f911e03f2a3edf14e9a5b81b --- browsergateway/ssh_native.go | 2 + browsergateway/ssh_native_windows.go | 16 +++++++ nativessh/pty.go | 2 + nativessh/server.go | 2 + nativessh/server_windows.go | 64 ++++++++++++++++++++++++++++ updates/advantech_windows.go | 8 ++++ 6 files changed, 94 insertions(+) create mode 100644 browsergateway/ssh_native_windows.go create mode 100644 nativessh/server_windows.go create mode 100644 updates/advantech_windows.go diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 992f875..87adf92 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -1,3 +1,5 @@ +//go:build !windows + package browsergateway import ( diff --git a/browsergateway/ssh_native_windows.go b/browsergateway/ssh_native_windows.go new file mode 100644 index 0000000..3caff0a --- /dev/null +++ b/browsergateway/ssh_native_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package browsergateway + +import ( + "context" + "errors" + + "github.com/coder/websocket" + "github.com/fosrl/newt/nativessh" +) + +// serveNativeSSHSession is not supported on Windows. +func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error { + return errors.New("native SSH is not supported on Windows") +} diff --git a/nativessh/pty.go b/nativessh/pty.go index 3d20e35..42cdd05 100644 --- a/nativessh/pty.go +++ b/nativessh/pty.go @@ -1,3 +1,5 @@ +//go:build !windows + package nativessh import ( diff --git a/nativessh/server.go b/nativessh/server.go index 0ccd7c3..20a13d9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -1,3 +1,5 @@ +//go:build !windows + package nativessh import ( diff --git a/nativessh/server_windows.go b/nativessh/server_windows.go new file mode 100644 index 0000000..3394e13 --- /dev/null +++ b/nativessh/server_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package nativessh + +import ( + "errors" + "log" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +// CredentialStore is a stub on Windows. Native SSH is not supported on Windows. +type CredentialStore struct { + mu sync.RWMutex + principals map[string]map[string]struct{} +} + +// NewCredentialStore returns an empty CredentialStore stub. +// Native SSH is not supported on Windows; a warning is logged. +func NewCredentialStore() *CredentialStore { + log.Println("WARNING: native SSH is not supported on Windows and will be disabled") + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey is a no-op stub on Windows. +func (s *CredentialStore) SetCAKey(_ string) error { + return errors.New("native SSH not supported on Windows") +} + +// AddPrincipals is a no-op stub on Windows. +func (s *CredentialStore) AddPrincipals(_, _ string) {} + +// get returns nil CA key and empty principals on Windows. +func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) { + return nil, nil +} + +// ServerConfig holds configuration for the native SSH server (stub on Windows). +type ServerConfig struct { + ListenAddr string + Credentials *CredentialStore +} + +// Server is a stub on Windows. +type Server struct{} + +// NewServer returns a stub Server and logs a warning. +func NewServer(cfg ServerConfig) *Server { + return &Server{} +} + +// ListenAndServe always returns an error on Windows. +func (s *Server) ListenAndServe() error { + return errors.New("native SSH not supported on Windows") +} + +// Serve always returns an error on Windows. +func (s *Server) Serve(_ net.Listener) error { + return errors.New("native SSH not supported on Windows") +} diff --git a/updates/advantech_windows.go b/updates/advantech_windows.go new file mode 100644 index 0000000..e6e302c --- /dev/null +++ b/updates/advantech_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package updates + +// postUpdateAdvantech is not supported on Windows. +func postUpdateAdvantech(newVersion, pidFile string) error { + return nil +} From 17a5689cf2085eb272c0b2a940adc269495eaa58 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:51:40 -0700 Subject: [PATCH 36/36] Update Flake Former-commit-id: 49d6cf595d1d5be190488c36d02d99f45bcfee85 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 3657fc3..488f75d 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ inherit version; src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - vendorHash = "sha256-M3MjtU4t0iGskNZhAdN3RKny8TOZbiuljK4HThShfXs="; + vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE="; nativeInstallCheckInputs = [ pkgs.versionCheckHook ];