Merge pull request #380 from fosrl/dev

1.13.0

Former-commit-id: 04abc80e6c
This commit is contained in:
Owen Schwartz
2026-06-10 11:12:40 -07:00
committed by GitHub
14 changed files with 269 additions and 103 deletions

View File

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

View File

@@ -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 <key> or X-Preshared-Key: <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 <key> or X-Preshared-Key: <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

View File

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

View File

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

View File

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

View File

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

View File

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

31
main.go
View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
34062da7bd1b98e4a7953d2f8b43860d2222add0
1f46b584aeea8817624dd1fbf6ab1caf24066808

View File

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

View File

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