mirror of
https://github.com/fosrl/newt.git
synced 2026-08-31 11:11:28 +02:00
Bring the naitve ssh pam to the browser gateway
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
69
nativessh/pty_unix.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user