Support of PAM
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user