Support of PAM

This commit is contained in:
2026-07-13 23:17:05 -03:00
parent dab8b09f0c
commit 2ff7976768
9 changed files with 133 additions and 14 deletions
+1
View File
@@ -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");
+3 -1
View File
@@ -73,7 +73,7 @@ function renderUsers(users) {
const cells = [
u.username,
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
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),
+1
View File
@@ -306,6 +306,7 @@
<div class="field"><label>TOTP Window</label><input id="fTotpWindow" type="number" min="0" placeholder="1"/></div>
<div class="field"><label>TOTP Digits</label><input id="fTotpDigits" type="number" min="6" max="8" placeholder="6"/></div>
<div class="field"><label>Allow static password too</label><input id="fAllowStatic" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
<div class="field"><label>Use Linux PAM auth (legacy)</label><input id="fUsePam" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
<div class="field"><label>Max connections</label><input id="fMaxConn" type="number" min="0" placeholder="0 = unlimited"/></div>
<div class="field"><label>Expires at</label><input id="fExpires" type="datetime-local"/></div>
<div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div>
+1
View File
@@ -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
+2
View File
@@ -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=
+6 -5
View File
@@ -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
+40 -8
View File
@@ -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
+63
View File
@@ -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
}
+16
View File
@@ -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")
}