diff --git a/authdaemon.go b/authdaemon.go index dc6d313..bd4b29f 100644 --- a/authdaemon.go +++ b/authdaemon.go @@ -63,7 +63,7 @@ func startAuthDaemon(ctx context.Context) error { // Start the auth daemon in a goroutine so it runs alongside newt go func() { - logger.Info("Auth daemon starting (native mode, no HTTP server)") + logger.Debug("Auth daemon starting (native mode, no HTTP server)") if err := srv.Run(ctx); err != nil { logger.Error("Auth daemon error: %v", err) } diff --git a/authdaemon/server.go b/authdaemon/server.go index 224ac9e..0bd114b 100644 --- a/authdaemon/server.go +++ b/authdaemon/server.go @@ -23,13 +23,13 @@ import ( type Config struct { // DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events. - DisableHTTPS bool - Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default. - PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer or X-Preshared-Key: ). No default. - CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default. - Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false. - PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default. - GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no). + DisableHTTPS bool + Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default. + PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer or X-Preshared-Key: ). No default. + CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default. + Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false. + PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default. + GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no). } type Server struct { @@ -136,7 +136,7 @@ func NewServer(cfg Config) (*Server, error) { // When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events. func (s *Server) Run(ctx context.Context) error { if s.cfg.DisableHTTPS { - logger.Info("auth-daemon running (HTTPS disabled)") + logger.Debug("auth-daemon running (HTTPS disabled)") <-ctx.Done() s.cleanupPrincipalsFile() return nil diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go index 1df5a13..120de59 100644 --- a/browsergateway/rdp.go +++ b/browsergateway/rdp.go @@ -7,13 +7,13 @@ import ( "errors" "fmt" "io" - "log" "net" "net/http" "strconv" "time" "github.com/coder/websocket" + "github.com/fosrl/newt/logger" ) // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. @@ -24,7 +24,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { Subprotocols: []string{"binary"}, }) if err != nil { - log.Printf("websocket upgrade failed: %v", err) + logger.Debug("websocket upgrade failed: %v", err) return } // Disable per-message read size cap (default is 32 KiB which would break @@ -33,7 +33,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if err := g.serveSession(ctx, ws); err != nil { - log.Printf("session error: %v", err) + logger.Debug("session error: %v", err) } } @@ -71,7 +71,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target) } - log.Printf("Connecting to RDP server %s", target) + logger.Debug("Connecting to RDP server %s", target) // -- Open TCP connection to the destination RDP server -- serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second) @@ -128,7 +128,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { if err := tlsConn.Handshake(); err != nil { return fmt.Errorf("TLS handshake with server: %w", err) } - log.Printf("Server TLS handshake OK (version=0x%04x cipher=0x%04x)", + logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)", tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite) // Collect the raw DER server certificate chain to return to the client. @@ -150,7 +150,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { return fmt.Errorf("write RDCleanPath response: %w", err) } - log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr) + logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr) // -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream -- return forward(stream, tlsConn) @@ -219,7 +219,7 @@ func readX224(r io.Reader) ([]byte, error) { // bytes 15..18 selected protocol / failure code (u32 little-endian) func logX224Negotiation(pdu []byte) { if len(pdu) < 19 { - log.Printf("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu)) + logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu)) return } switch pdu[11] { @@ -236,12 +236,12 @@ func logX224Negotiation(pdu []byte) { case 8: name = "HYBRID_EX" } - log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name) + logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name) case 0x03: code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 - log.Printf("Server returned RDP negotiation failure code 0x%x", code) + logger.Debug("Server returned RDP negotiation failure code 0x%x", code) default: - log.Printf("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11]) + logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11]) } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index 06ca9a0..511471b 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net" "net/http" "strconv" @@ -18,13 +17,13 @@ import ( // sshClientMsg is a JSON message sent from the browser to the proxy. type sshClientMsg struct { // type: "auth" | "data" | "resize" - Type string `json:"type"` - Password string `json:"password,omitempty"` // used when type="auth" - PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + 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" + 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" } // sshServerMsg is a JSON message sent from the proxy back to the browser. @@ -83,7 +82,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { Subprotocols: []string{"ssh"}, }) if err != nil { - log.Printf("SSH websocket upgrade failed: %v", err) + logger.Debug("SSH websocket upgrade failed: %v", err) return } ws.SetReadLimit(-1) @@ -91,11 +90,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { if nativeSSH { if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil { - log.Printf("SSH native session error: %v", err) + logger.Debug("SSH native session error: %v", err) } } else { if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil { - log.Printf("SSH session error: %v", err) + logger.Debug("SSH session error: %v", err) } } } @@ -112,8 +111,10 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, } password := authMsg.Password privateKey := authMsg.PrivateKey + certificate := authMsg.Certificate - // Build the list of auth methods. Private key takes priority when provided. + // Build the list of auth methods. Certificate+private key takes priority + // when both are provided, then private key, then password. var authMethods []ssh.AuthMethod if privateKey != "" { signer, err := ssh.ParsePrivateKey([]byte(privateKey)) @@ -121,7 +122,29 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err)) return fmt.Errorf("parse private key: %w", err) } - authMethods = append(authMethods, ssh.PublicKeys(signer)) + + if certificate != "" { + certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err)) + return fmt.Errorf("parse certificate: %w", err) + } + + cert, ok := certPubKey.(*ssh.Certificate) + if !ok { + sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate") + return fmt.Errorf("provided certificate is not ssh certificate") + } + + certSigner, err := ssh.NewCertSigner(cert, signer) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err)) + return fmt.Errorf("new cert signer: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(certSigner)) + } else { + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } } if password != "" { authMethods = append(authMethods, ssh.Password(password)) @@ -130,7 +153,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, sendSSHError(ctx, ws, "No authentication credentials provided") return fmt.Errorf("no auth credentials") } - log.Printf("SSH: connecting to %s as %s", target, username) + logger.Debug("SSH: connecting to %s as %s", target, username) sshCfg := &ssh.ClientConfig{ User: username, Auth: authMethods, @@ -183,7 +206,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, return fmt.Errorf("ssh shell: %w", err) } - log.Printf("SSH: session established with %s", target) + logger.Debug("SSH: session established with %s", target) // -- Pump SSH stdout/stderr → WebSocket -- sessCtx, cancelSess := context.WithCancel(ctx) @@ -253,7 +276,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, // sendSSHError sends an error message to the browser and logs it. func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) { - log.Printf("SSH error: %s", msg) + logger.Debug("SSH error: %s", msg) b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg}) _ = ws.Write(ctx, websocket.MessageText, b) } diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index 87adf92..6b923ed 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -6,9 +6,9 @@ import ( "context" "encoding/json" "fmt" - "log" "github.com/coder/websocket" + "github.com/fosrl/newt/logger" "github.com/fosrl/newt/nativessh" ) @@ -36,7 +36,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str return fmt.Errorf("auth for user %q: %w", username, err) } - log.Printf("SSH native: spawning shell as user %q", username) + logger.Debug("SSH native: spawning shell as user %q", username) sess, err := nativessh.NewPTYSessionAs(username) if err != nil { diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go index 5d917a4..f104a8d 100644 --- a/browsergateway/vnc.go +++ b/browsergateway/vnc.go @@ -2,14 +2,15 @@ package browsergateway import ( "context" + "fmt" "io" - "log" "net" "net/http" "strconv" "time" "github.com/coder/websocket" + "github.com/fosrl/newt/logger" ) const ( @@ -44,6 +45,18 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { } target := net.JoinHostPort(host, port) + // Optional HTTP probe used by the web UI to surface backend reachability + // failures before attempting a WebSocket session. + if r.URL.Query().Get("checkOnly") == "1" { + if err := dialVNCBackend(r.Context(), target); err != nil { + logger.Debug("vnc: preflight failed (%s): %v", target, err) + http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; // fall back gracefully when the client sends "base64" as well. ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ @@ -51,7 +64,7 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { Subprotocols: []string{"binary", "base64"}, }) if err != nil { - log.Printf("vnc: websocket upgrade failed: %v", err) + logger.Debug("vnc: websocket upgrade failed: %v", err) return } ws.SetReadLimit(-1) @@ -59,10 +72,24 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if err := serveVNC(ctx, ws, target); err != nil { - log.Printf("vnc: session error (%s): %v", target, err) + logger.Debug("vnc: session error (%s): %v", target, err) } } +func dialVNCBackend(ctx context.Context, target string) error { + dialer := &net.Dialer{ + Timeout: vncDialTimeout, + KeepAlive: vncKeepAlive, + } + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + return nil +} + func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error { // Dial the VNC backend TCP server. dialer := &net.Dialer{ diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go index d75797e..c0e527b 100644 --- a/healthcheck/healthcheck.go +++ b/healthcheck/healthcheck.go @@ -212,6 +212,10 @@ func (m *Monitor) addTargetUnsafe(config Config) error { return nil }(), Transport: &http.Transport{ + // Disable keep-alives so each health check uses a fresh connection. + // Reusing idle connections causes spurious timeouts when the remote + // server closes the connection between long check intervals. + DisableKeepAlives: true, TLSClientConfig: &tls.Config{ // Configure TLS settings based on certificate enforcement InsecureSkipVerify: !m.enforceCert, @@ -432,7 +436,9 @@ func (m *Monitor) performTCPCheck(target *Target) (bool, string) { logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)", target.Config.ID, address, timeout) - conn, err := net.DialTimeout("tcp", address, timeout) + ctx, cancel := context.WithTimeout(target.ctx, timeout) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) if err != nil { msg := fmt.Sprintf("TCP dial failed: %v", err) logger.Warn("Target %d: %s", target.Config.ID, msg) @@ -467,8 +473,9 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) { target.Config.ID, m.enforceCert) } - // Create request with timeout context - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second) + // Create request with timeout context, derived from the target's context so + // that in-flight requests are cancelled immediately when the target is replaced. + ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil) diff --git a/main.go b/main.go index d36b775..8f5d0ce 100644 --- a/main.go +++ b/main.go @@ -1053,7 +1053,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } else { browserGatewayStop = func() { _ = ln.Close() } go func() { - logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) if startErr := browserGateway.Start(ln); startErr != nil { logger.Error("Browser gateway stopped with error: %v", startErr) } @@ -1776,14 +1776,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + ExternalAuthDaemon bool `json:"externalAuthDaemon"` + AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { SudoMode string `json:"sudoMode"` SudoCommands []string `json:"sudoCommands"` Homedir bool `json:"homedir"` @@ -1805,9 +1806,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Error("Error unmarshaling SSH cert data: %v", err) return } + var authDaemonMode = "site" + if certData.AuthDaemonMode != "" { + authDaemonMode = certData.AuthDaemonMode + } else if certData.ExternalAuthDaemon { // this is for backward compatibility with older server versions that don't send authDaemonMode + authDaemonMode = "remote" + } // Use a switch statement for AuthDaemonMode - switch certData.AuthDaemonMode { + switch authDaemonMode { case "site": // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) @@ -1835,7 +1842,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ "messageId": certData.MessageId, "complete": true, - "error": "auth daemon server not initialized", + "error": "auth daemon server not initialized (enable by running Newt site connector as root)", }) if err != nil { logger.Error("Failed to send SSH cert failure response: %v", err) @@ -2028,7 +2035,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } else { browserGatewayStop = func() { _ = ln.Close() } go func() { - logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort) if startErr := browserGateway.Start(ln); startErr != nil { logger.Error("Browser gateway stopped with error: %v", startErr) } diff --git a/nativessh/auth.go b/nativessh/auth.go index 9f9289b..6ecf4c9 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -3,13 +3,13 @@ package nativessh import ( "bufio" "fmt" - "log" "net" "os" "os/user" "path/filepath" "strings" + "github.com/fosrl/newt/logger" "golang.org/x/crypto/ssh" ) @@ -17,12 +17,12 @@ 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 } +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 @@ -80,9 +80,9 @@ func Authenticate(username, password, privateKeyPEM string) error { // 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 != "") + logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { - log.Printf("nativessh: user %q not found on system", username) + logger.Debug("nativessh: user %q not found on system", username) return fmt.Errorf("user %q does not exist", username) } @@ -90,35 +90,35 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri if privateKeyPEM != "" { parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { - log.Printf("nativessh: failed to parse private key for %q: %v", username, err) + logger.Debug("nativessh: failed to parse private key for %q: %v", username, err) } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { - log.Printf("nativessh: private key auth succeeded for %q", username) + logger.Debug("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) + logger.Debug("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) + logger.Debug("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) + logger.Debug("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) + logger.Debug("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) + logger.Debug("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) + logger.Debug("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) + logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username) } else { checker := &ssh.CertChecker{ IsUserAuthority: func(auth ssh.PublicKey) bool { @@ -130,13 +130,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri 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) + logger.Debug("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) + logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr) } } } @@ -146,13 +146,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri if password != "" { if err := VerifySystemPassword(username, password); err != nil { - log.Printf("nativessh: password auth failed for %q: %v", username, err) + logger.Debug("nativessh: password auth failed for %q: %v", username, err) } else { - log.Printf("nativessh: password auth succeeded for %q", username) + logger.Debug("nativessh: password auth succeeded for %q", username) return nil } } else { - log.Printf("nativessh: no password provided for %q", username) + logger.Debug("nativessh: no password provided for %q", username) } return fmt.Errorf("authentication failed for user %q", username) } diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go index f33828b..da02c7c 100644 --- a/nativessh/pam_linux.go +++ b/nativessh/pam_linux.go @@ -7,10 +7,10 @@ import ( "bytes" "errors" "fmt" - "log" "os" "strings" + "github.com/fosrl/newt/logger" "github.com/go-crypt/crypt" "github.com/go-crypt/x/yescrypt" ) @@ -21,7 +21,7 @@ import ( func VerifySystemPassword(username, password string) error { hash, err := readShadowHash(username) if err != nil { - log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err) + logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err) return fmt.Errorf("shadow: %w", err) } @@ -33,17 +33,17 @@ func VerifySystemPassword(username, password string) error { break } } - log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme) + logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme) // Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly. if strings.HasPrefix(hash, "$y$") { computed, err := yescrypt.Hash([]byte(password), []byte(hash)) if err != nil { - log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) + logger.Debug("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) return fmt.Errorf("yescrypt: %w", err) } if !bytes.Equal(computed, []byte(hash)) { - log.Printf("nativessh/pam: yescrypt mismatch for %q", username) + logger.Debug("nativessh/pam: yescrypt mismatch for %q", username) return errors.New("authentication failed") } return nil @@ -56,17 +56,17 @@ func VerifySystemPassword(username, password string) error { digest, err := decoder.Decode(hash) if err != nil { - log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err) + logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err) return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err) } match, err := digest.MatchAdvanced(password) if err != nil { - log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) + logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) return err } if !match { - log.Printf("nativessh/pam: password mismatch for %q", username) + logger.Debug("nativessh/pam: password mismatch for %q", username) return errors.New("authentication failed") } return nil diff --git a/nativessh/server.go b/nativessh/server.go index 20a13d9..b94e626 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -5,13 +5,19 @@ package nativessh import ( "crypto/ed25519" "crypto/rand" + "errors" "fmt" "io" - "log" "net" + "os" + "os/exec" + "os/user" + "strconv" "strings" "sync" + "syscall" + "github.com/fosrl/newt/logger" "golang.org/x/crypto/ssh" ) @@ -126,7 +132,7 @@ func (s *Server) Serve(ln net.Listener) error { if err != nil { return err } - log.Printf("nativessh: server listening on %s", ln.Addr()) + logger.Debug("nativessh: server listening on %s", ln.Addr()) for { conn, err := ln.Accept() if err != nil { @@ -151,11 +157,11 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { defer conn.Close() sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) if err != nil { - log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) + logger.Debug("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) return } defer sshConn.Close() - log.Printf("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User()) + logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User()) go ssh.DiscardRequests(reqs) @@ -166,7 +172,7 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { } ch, requests, err := newChan.Accept() if err != nil { - log.Printf("nativessh: channel accept error: %v", err) + logger.Debug("nativessh: channel accept error: %v", err) return } go s.handleSession(ch, requests, sshConn.User()) @@ -190,7 +196,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use if sess == nil { sess, err = NewPTYSessionAs(username) if err != nil { - log.Printf("nativessh: PTY start error: %v", err) + logger.Debug("nativessh: PTY start error: %v", err) if req.WantReply { _ = req.Reply(false, nil) } @@ -232,6 +238,16 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use _, _ = io.Copy(sess, ch) }() + case "exec": + if req.WantReply { + _ = req.Reply(true, nil) + } + cmd := parseExecPayload(req.Payload) + go s.runExecAs(ch, username, cmd) + // Drain remaining requests; the goroutine owns the channel now. + go ssh.DiscardRequests(requests) + return + case "window-change": if sess != nil { cols, rows := parseWindowChange(req.Payload) @@ -253,6 +269,94 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use } } +// runExecAs runs command as username, wiring stdin/stdout/stderr directly to +// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests. +func (s *Server) runExecAs(ch ssh.Channel, username, command string) { + defer ch.Close() + + u, err := user.Lookup(username) + if err != nil { + logger.Debug("nativessh: exec user lookup %q: %v", username, err) + sendExitStatus(ch, 1) + return + } + + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + logger.Debug("nativessh: exec parse uid for %q: %v", username, err) + sendExitStatus(ch, 1) + return + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + logger.Debug("nativessh: exec parse gid for %q: %v", username, err) + sendExitStatus(ch, 1) + return + } + + groupIDs, _ := u.GroupIds() + var groups []uint32 + for _, g := range groupIDs { + gval, err := strconv.ParseUint(g, 10, 32) + if err == nil { + groups = append(groups, uint32(gval)) + } + } + + homeDir := u.HomeDir + if _, err := os.Stat(homeDir); err != nil { + homeDir = "/" + } + + cmd := exec.Command("/bin/sh", "-c", command) + cmd.Env = []string{ + "HOME=" + u.HomeDir, + "USER=" + username, + "LOGNAME=" + username, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + cmd.Dir = homeDir + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + Groups: groups, + }, + } + cmd.Stdin = ch + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + + exitCode := 0 + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err) + } + + sendExitStatus(ch, exitCode) + _ = ch.CloseWrite() +} + +// sendExitStatus sends an SSH exit-status request on ch. +func sendExitStatus(ch ssh.Channel, code int) { + payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)}) + _, _ = ch.SendRequest("exit-status", false, payload) +} + +// parseExecPayload decodes the command string from an SSH exec request payload. +func parseExecPayload(payload []byte) string { + var msg struct{ Command string } + if err := ssh.Unmarshal(payload, &msg); err != nil { + return "" + } + return msg.Command +} + // makePublicKeyCallback returns a PublicKeyCallback that tries, in order: // 1. Host authorized_keys – matches any key in the OS user's // ~/.ssh/authorized_keys file. @@ -264,7 +368,7 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { // 1. Host authorized_keys. if CheckAuthorizedKeys(meta.User(), key) { - log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + logger.Debug("nativessh: authorized_keys auth for user %q", meta.User()) return &ssh.Permissions{}, nil } @@ -286,14 +390,14 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu for principal := range userPrincipals { perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key) if err == nil { - log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) + logger.Debug("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) return perms, nil } lastErr = err } if lastErr != nil { - log.Printf("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr) + logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr) } } } @@ -309,10 +413,10 @@ func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, er return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { 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) + logger.Debug("nativessh: password auth failed for user %q: %v", meta.User(), err) return nil, fmt.Errorf("permission denied") } - log.Printf("nativessh: password auth for user %q", meta.User()) + logger.Debug("nativessh: password auth for user %q", meta.User()) return &ssh.Permissions{}, nil } } @@ -324,7 +428,7 @@ func generateHostKey() (ssh.Signer, error) { if err != nil { return nil, fmt.Errorf("generate host key: %w", err) } - log.Printf("nativessh: generated ephemeral Ed25519 host key") + logger.Debug("nativessh: generated ephemeral Ed25519 host key") return ssh.NewSignerFromKey(priv) } diff --git a/newt.REMOVED.git-id b/newt.REMOVED.git-id index 2233bac..5d305ff 100644 --- a/newt.REMOVED.git-id +++ b/newt.REMOVED.git-id @@ -1 +1 @@ -34062da7bd1b98e4a7953d2f8b43860d2222add0 \ No newline at end of file +1f46b584aeea8817624dd1fbf6ab1caf24066808 \ No newline at end of file diff --git a/service_windows.go b/service_windows.go index 7102cc2..3206d80 100644 --- a/service_windows.go +++ b/service_windows.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "io" - "log" "os" "os/signal" "path/filepath" @@ -649,7 +648,7 @@ func setupWindowsEventLog() { // Set the custom logger output logger.GetLogger().SetOutput(file) - log.Printf("Newt service logging initialized - log file: %s", logFile) + logger.Debug("Newt service logging initialized - log file: %s", logFile) } // handleServiceCommand checks for service management commands and returns true if handled diff --git a/websocket/config.go b/websocket/config.go index f24f65a..b503449 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -7,7 +7,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "net/url" "os" @@ -38,7 +37,7 @@ func getConfigPath(clientType string, overridePath string) string { } if err := os.MkdirAll(configDir, 0755); err != nil { - log.Printf("Failed to create config directory: %v", err) + logger.Debug("Failed to create config directory: %v", err) } return filepath.Join(configDir, "config.json") @@ -279,4 +278,4 @@ func (c *Client) provisionIfNeeded() error { } return nil -} \ No newline at end of file +}