Handle browser gateway push pam auth

Former-commit-id: 1ba9a419cb
This commit is contained in:
Owen
2026-06-04 11:28:20 -07:00
parent e5bf31d118
commit 8eddb908e0
7 changed files with 91 additions and 14 deletions

View File

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

View File

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

View File

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

10
main.go
View File

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

View File

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

View File

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

View File

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