From 2ff79767683502a075e9f328e02d42285eef446a Mon Sep 17 00:00:00 2001 From: penguinehis Date: Mon, 13 Jul 2026 23:17:05 -0300 Subject: [PATCH] Support of PAM --- admin/assets/js/01-core.js | 1 + admin/assets/js/03-ssh-users.js | 4 ++- admin/index.html | 1 + go.mod | 1 + go.sum | 2 ++ install.sh | 11 +++--- main.go | 48 ++++++++++++++++++++----- pam_auth_linux.go | 63 +++++++++++++++++++++++++++++++++ pam_auth_stub.go | 16 +++++++++ 9 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 pam_auth_linux.go create mode 100644 pam_auth_stub.go diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index a0f172b..37ba830 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -331,6 +331,7 @@ const fTotpPeriod = document.getElementById("fTotpPeriod"); const fTotpWindow = document.getElementById("fTotpWindow"); const fTotpDigits = document.getElementById("fTotpDigits"); const fAllowStatic = document.getElementById("fAllowStatic"); +const fUsePam = document.getElementById("fUsePam"); const fMaxConn = document.getElementById("fMaxConn"); const fExpires = document.getElementById("fExpires"); const fUp = document.getElementById("fUp"); diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index 2c46719..d61c293 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -73,7 +73,7 @@ function renderUsers(users) { const cells = [ u.username, on ? `${t("online")}` : `${t("idle")}`, - u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password", + u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"), u.active_conns ?? 0, u.max_connections || 0, u.limit_mbps_up || 0, @@ -121,6 +121,7 @@ function fillUserForm(u) { fTotpWindow.value = u.totp_window ?? 1; fTotpDigits.value = u.totp_digits || 6; fAllowStatic.checked = !!u.allow_static_password; + if (fUsePam) fUsePam.checked = !!u.use_pam; fMaxConn.value = u.max_connections || ""; fUp.value = u.limit_mbps_up || ""; fDown.value = u.limit_mbps_down || ""; @@ -144,6 +145,7 @@ userForm.addEventListener("submit", async e => { totp_window: parseInt(fTotpWindow.value||"1",10), totp_digits: parseInt(fTotpDigits.value||"6",10), allow_static_password: !!fAllowStatic.checked, + use_pam: !!(fUsePam && fUsePam.checked), max_connections: parseInt(fMaxConn.value||"0",10), expires_at: isoFromLocal(fExpires.value), limit_mbps_up: parseInt(fUp.value||"0",10), diff --git a/admin/index.html b/admin/index.html index b39502d..d08c617 100644 --- a/admin/index.html +++ b/admin/index.html @@ -306,6 +306,7 @@
+
diff --git a/go.mod b/go.mod index 5908e01..f99dc7d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.4 require ( github.com/lib/pq v1.10.9 + github.com/msteinert/pam/v2 v2.1.0 github.com/xtaci/kcp-go/v5 v5.6.61 github.com/xtaci/smux v1.5.50 golang.org/x/crypto v0.45.0 diff --git a/go.sum b/go.sum index b277c72..5f05854 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY= +github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/install.sh b/install.sh index dabfac9..6fae9cf 100644 --- a/install.sh +++ b/install.sh @@ -61,23 +61,24 @@ detect_pkg_manager() { set_package_deps() { case "$PKG_MANAGER" in apt) - PKG_DEPS=(curl wget git rsync build-essential postgresql ca-certificates unzip openssh-client openssl python3 tar gzip) + # libpam0g-dev: PAM headers for the optional PAM auth build (cgo). + PKG_DEPS=(curl wget git rsync build-essential libpam0g-dev postgresql ca-certificates unzip openssh-client openssl python3 tar gzip) PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables) ;; dnf|yum) - PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh-clients openssl python3 tar gzip) + PKG_DEPS=(curl wget git rsync gcc make pam-devel postgresql-server ca-certificates unzip openssh-clients openssl python3 tar gzip) PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables) ;; zypper) - PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh openssl python3 tar gzip) + PKG_DEPS=(curl wget git rsync gcc make pam-devel postgresql-server ca-certificates unzip openssh openssl python3 tar gzip) PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables) ;; pacman) - PKG_DEPS=(curl wget git rsync base-devel postgresql ca-certificates unzip openssh openssl python tar gzip) + PKG_DEPS=(curl wget git rsync base-devel pam postgresql ca-certificates unzip openssh openssl python tar gzip) PKG_OPTIONAL_DEPS=(iptables-nft nftables) ;; apk) - PKG_DEPS=(curl wget git rsync build-base postgresql ca-certificates unzip openssh-client openssl python3 tar gzip) + PKG_DEPS=(curl wget git rsync build-base linux-pam-dev postgresql ca-certificates unzip openssh-client openssl python3 tar gzip) PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables) ;; esac diff --git a/main.go b/main.go index 38e0468..4dc5874 100644 --- a/main.go +++ b/main.go @@ -351,6 +351,12 @@ type UserConfig struct { // When false and totp_secret is set, only the TOTP code is accepted. AllowStaticPassword bool `json:"allow_static_password"` + // UsePAM is a legacy opt-in: when true, the supplied SSH password is + // verified against the Linux PAM auth stack (auth phase only) for the + // system account matching this username, instead of the panel-managed + // Password/TOTP. New users leave this false and keep the script's own auth. + UsePAM bool `json:"use_pam"` + MaxConnections int `json:"max_connections"` ExpiresAt string `json:"expires_at"` // RFC3339 or empty @@ -1351,13 +1357,15 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error { totp_period INT NOT NULL DEFAULT 60, totp_window INT NOT NULL DEFAULT 1, totp_digits INT NOT NULL DEFAULT 6, - allow_static_password BOOLEAN NOT NULL DEFAULT FALSE + allow_static_password BOOLEAN NOT NULL DEFAULT FALSE, + use_pam BOOLEAN NOT NULL DEFAULT FALSE )`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS use_pam BOOLEAN NOT NULL DEFAULT FALSE`, `ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''`, } for _, stmt := range stmts { @@ -1407,7 +1415,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { SELECT username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, COALESCE(totp_secret, ''), COALESCE(totp_period, 60), COALESCE(totp_window, 1), COALESCE(totp_digits, 6), COALESCE(allow_static_password, FALSE), - COALESCE(owner_username, '') + COALESCE(use_pam, FALSE), COALESCE(owner_username, '') FROM ssh_users`) if err != nil { return nil, err @@ -1428,10 +1436,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { totpWindow int totpDigits int allowStaticPassword bool + usePAM bool ownerUsername string ) if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown, - &totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil { + &totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil { return nil, err } password, err = openSSHPassword(password) @@ -1450,6 +1459,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { TOTPWindow: totpWindow, TOTPDigits: totpDigits, AllowStaticPassword: allowStaticPassword, + UsePAM: usePAM, OwnerUsername: ownerUsername, } @@ -1480,9 +1490,9 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error { _, err = s.db.ExecContext(ctx, ` INSERT INTO ssh_users ( username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, - totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username + totp_secret, totp_period, totp_window, totp_digits, allow_static_password, use_pam, owner_username ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, max_connections = EXCLUDED.max_connections, @@ -1493,10 +1503,11 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error { totp_period = EXCLUDED.totp_period, totp_window = EXCLUDED.totp_window, totp_digits = EXCLUDED.totp_digits, - allow_static_password = EXCLUDED.allow_static_password`, + allow_static_password = EXCLUDED.allow_static_password, + use_pam = EXCLUDED.use_pam`, // owner_username is intentionally excluded from UPDATE — ownership is set at creation only. u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown, - u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername) + u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername) return err } @@ -1713,6 +1724,7 @@ type UserDTO struct { TOTPWindow int `json:"totp_window"` TOTPDigits int `json:"totp_digits"` AllowStaticPassword bool `json:"allow_static_password"` + UsePAM bool `json:"use_pam"` TOTPEnabled bool `json:"totp_enabled"` OwnerUsername string `json:"owner_username,omitempty"` ServerID string `json:"server_id,omitempty"` @@ -1759,6 +1771,7 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) { TOTPWindow: cfg.TOTPWindow, TOTPDigits: cfg.TOTPDigits, AllowStaticPassword: cfg.AllowStaticPassword, + UsePAM: cfg.UsePAM, TOTPEnabled: strings.TrimSpace(cfg.TOTPSecret) != "", OwnerUsername: cfg.OwnerUsername, }) @@ -1781,6 +1794,7 @@ type UserPayload struct { TOTPWindow int `json:"totp_window"` TOTPDigits int `json:"totp_digits"` AllowStaticPassword bool `json:"allow_static_password"` + UsePAM bool `json:"use_pam"` OwnerUsername string `json:"owner_username,omitempty"` ServerID string `json:"server_id,omitempty"` } @@ -1882,7 +1896,9 @@ func handleCreateUser(store *Store) http.HandlerFunc { ).Scan(&existing) if err == sql.ErrNoRows { - if strings.TrimSpace(p.TOTPSecret) == "" { + // PAM users authenticate against the system account, so they + // need neither a panel password nor a TOTP secret. + if strings.TrimSpace(p.TOTPSecret) == "" && !p.UsePAM { http.Error(w, "password or totp_secret required for new user", http.StatusBadRequest) return } @@ -1933,6 +1949,7 @@ func handleCreateUser(store *Store) http.HandlerFunc { TOTPWindow: p.TOTPWindow, TOTPDigits: p.TOTPDigits, AllowStaticPassword: p.AllowStaticPassword, + UsePAM: p.UsePAM, OwnerUsername: ownerUsername, } @@ -2156,6 +2173,21 @@ func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, err return nil, fmt.Errorf("authentication failed: %w", err) } supplied := string(pass) + + // Legacy PAM mode: authenticate against the Linux system account via PAM's + // auth phase only. This bypasses the panel-managed password/TOTP entirely. + if u.Cfg.UsePAM { + if !pamAuthAvailable { + log.Printf("user %s is configured for PAM auth but this build has no PAM support", meta.User()) + return nil, fmt.Errorf("authentication failed") + } + if err := authenticatePAM(meta.User(), supplied); err != nil { + log.Printf("PAM auth failed for user %s: %v", meta.User(), err) + return nil, fmt.Errorf("authentication failed") + } + return nil, nil + } + if strings.TrimSpace(u.Cfg.TOTPSecret) != "" { if matchTOTPPassword(u, supplied, now) { return nil, nil diff --git a/pam_auth_linux.go b/pam_auth_linux.go new file mode 100644 index 0000000..69258c7 --- /dev/null +++ b/pam_auth_linux.go @@ -0,0 +1,63 @@ +//go:build linux && cgo + +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/msteinert/pam/v2" +) + +// pamAuthAvailable reports that PAM authentication is compiled into this build. +const pamAuthAvailable = true + +// pamServiceName returns the PAM service to use for the auth-only check. This +// is the name of a file under /etc/pam.d. Override with SSHPANEL_PAM_SERVICE; +// it defaults to "login", which exists on every mainstream distro and runs the +// standard unix auth stack (common-auth / system-auth). +func pamServiceName() string { + if s := strings.TrimSpace(os.Getenv("SSHPANEL_PAM_SERVICE")); s != "" { + return s + } + return "login" +} + +// authenticatePAM verifies password against the Linux PAM stack for username. +// +// It runs ONLY the auth phase (pam_authenticate) — no account management +// (pam_acct_mgmt), no session, no credential setup, and nothing to do with the +// system SSH daemon. This is the "just the auth" behaviour: the supplied +// password is checked against the system account exactly as PAM's auth modules +// would, and nothing else. Returns nil on success, an error on failure. +// +// The panel runs as root, so pam_unix can read /etc/shadow to verify the hash +// (including yescrypt/sha512crypt) for any local account. +func authenticatePAM(username, password string) error { + if username == "" { + return errors.New("pam: empty username") + } + t, err := pam.StartFunc(pamServiceName(), username, func(s pam.Style, msg string) (string, error) { + switch s { + case pam.PromptEchoOff, pam.PromptEchoOn: + return password, nil + case pam.ErrorMsg, pam.TextInfo: + // Informational messages from modules; nothing to return. + return "", nil + default: + return "", fmt.Errorf("pam: unsupported conversation style %v", s) + } + }) + if err != nil { + return fmt.Errorf("pam start: %w", err) + } + defer func() { _ = t.End() }() + + // Silent keeps modules from writing to stdout/syslog noise; auth phase only. + if err := t.Authenticate(pam.Silent); err != nil { + return fmt.Errorf("pam auth: %w", err) + } + return nil +} diff --git a/pam_auth_stub.go b/pam_auth_stub.go new file mode 100644 index 0000000..6da0566 --- /dev/null +++ b/pam_auth_stub.go @@ -0,0 +1,16 @@ +//go:build !linux || !cgo + +package main + +import "errors" + +// pamAuthAvailable is false when the binary is built without cgo/PAM support +// (for example the Windows development build, or any CGO_ENABLED=0 build). +// In that case a user flagged for PAM auth can never authenticate. +const pamAuthAvailable = false + +func pamServiceName() string { return "" } + +func authenticatePAM(username, password string) error { + return errors.New("pam authentication is not supported in this build") +}