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")