Bring the naitve ssh pam to the browser gateway

This commit is contained in:
Owen
2026-05-22 11:30:21 -07:00
parent e6267cc1fc
commit 1ff26b7acd
7 changed files with 130 additions and 22 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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:

View File

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

69
nativessh/pty_unix.go Normal file
View File

@@ -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
}

View File

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