mirror of
https://github.com/fosrl/newt.git
synced 2026-07-20 14:41:28 +02:00
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user