diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index 37ba830..a0f172b 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -331,7 +331,6 @@ 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 d61c293..63f9182 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -121,7 +121,6 @@ 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 || ""; @@ -145,7 +144,6 @@ 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/assets/js/06-servers.js b/admin/assets/js/06-servers.js index b5f9782..f7b22fb 100644 --- a/admin/assets/js/06-servers.js +++ b/admin/assets/js/06-servers.js @@ -418,6 +418,7 @@ async function loadManagedServerConfig(id) { document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s"; document.getElementById("managedCfgQuiet").checked = !!c.quiet; document.getElementById("managedCfgUserCount").checked = !!c.user_count; + document.getElementById("managedCfgPamAuth").checked = !!c.pam_auth_enabled; document.getElementById("managedCfgBanner").value = c.banner || ""; const hasDnstt = !!c.dnstt; @@ -487,6 +488,7 @@ function managedConfigFromForm() { ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s", quiet: document.getElementById("managedCfgQuiet").checked, user_count: document.getElementById("managedCfgUserCount").checked, + pam_auth_enabled: document.getElementById("managedCfgPamAuth").checked, banner: document.getElementById("managedCfgBanner").value, banner_file: "/opt/sshpanel/banner.txt", dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { diff --git a/admin/assets/js/08-server-config.js b/admin/assets/js/08-server-config.js index 7108f3c..9fbe855 100644 --- a/admin/assets/js/08-server-config.js +++ b/admin/assets/js/08-server-config.js @@ -99,6 +99,7 @@ async function loadServerConfig() { document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s"; document.getElementById("cfgQuiet").checked = !!c.quiet; document.getElementById("cfgUserCount").checked = !!c.user_count; + document.getElementById("cfgPamAuth").checked = !!c.pam_auth_enabled; // Banner document.getElementById("cfgBanner").value = c.banner || ""; @@ -182,6 +183,7 @@ async function saveServerConfig() { ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s", quiet: document.getElementById("cfgQuiet").checked, user_count: document.getElementById("cfgUserCount").checked, + pam_auth_enabled: document.getElementById("cfgPamAuth").checked, banner: document.getElementById("cfgBanner").value, banner_file: "/opt/sshpanel/banner.txt", dnstt: document.getElementById("cfgDnsttEnabled").checked ? { diff --git a/admin/index.html b/admin/index.html index d08c617..233b063 100644 --- a/admin/index.html +++ b/admin/index.html @@ -306,7 +306,6 @@
-
@@ -808,6 +807,7 @@
+ @@ -1269,6 +1269,9 @@ + diff --git a/hotreload.go b/hotreload.go index 231d2bd..171754a 100644 --- a/hotreload.go +++ b/hotreload.go @@ -335,6 +335,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport { setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown) setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout) setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections) + setPAMAuthEnabled(newCfg.PAMAuthEnabled) // Quiet logging / user count display if newCfg.Quiet { diff --git a/main.go b/main.go index 4dc5874..3e2ecaa 100644 --- a/main.go +++ b/main.go @@ -97,6 +97,13 @@ type Config struct { UserCount bool `json:"user_count"` + // PAMAuthEnabled turns on Linux system-password login for this server. When + // true, an SSH login with a username not present in the panel is verified + // against /etc/shadow; on success the account (regular users, UID >= 1000) + // is auto-imported into the panel. When false, only panel-managed accounts + // can log in and previously-imported PAM accounts are refused. + PAMAuthEnabled bool `json:"pam_auth_enabled"` + // SSHIdleTimeout controls how long an authenticated SSH connection may // remain with no bytes moving in either direction before it is closed and // released from the active user count. Empty, "0", or "0s" disables it. @@ -390,6 +397,19 @@ func (m *UserManager) Get(username string) (*UserState, bool) { return u, ok } +// AddIfAbsent inserts u only if no user with the same username exists yet, and +// reports whether it was added. Used by PAM auto-import to register a freshly +// authenticated system account without clobbering an existing runtime state. +func (m *UserManager) AddIfAbsent(u *UserState) bool { + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.users[u.Cfg.Username]; exists { + return false + } + m.users[u.Cfg.Username] = u + return true +} + func (m *UserManager) List() []*UserState { m.mu.RLock() defer m.mu.RUnlock() @@ -2160,8 +2180,16 @@ func matchTOTPPassword(u *UserState, supplied string, now time.Time) bool { // ---------- Auth callbacks ---------- func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + supplied := string(pass) u, ok := userMgr.Get(meta.User()) if !ok { + // Unknown username. If system (PAM) login is enabled for this server, + // verify the password against the Linux account and auto-import the + // user on success so it appears in the panel and future logins are + // tracked normally. + if isPAMAuthEnabled() { + return pamLoginAndImport(meta.User(), supplied) + } return nil, fmt.Errorf("authentication failed") } now := time.Now() @@ -2172,13 +2200,11 @@ func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, err if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil { 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. + // PAM-imported user: verify against the Linux system password each time. if u.Cfg.UsePAM { - if !pamAuthAvailable { - log.Printf("user %s is configured for PAM auth but this build has no PAM support", meta.User()) + if !isPAMAuthEnabled() { + // System login was disabled server-wide; refuse PAM accounts. return nil, fmt.Errorf("authentication failed") } if err := authenticatePAM(meta.User(), supplied); err != nil { @@ -3237,6 +3263,7 @@ func main() { setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown) setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout) setMaxTotalConnsFromConfig(cfg.MaxTotalConnections) + setPAMAuthEnabled(cfg.PAMAuthEnabled) // Initialise listener pools (used for initial startup and hot-reload alike). publicPool = newListenerPool(serveHTTP80) diff --git a/pam_auth.go b/pam_auth.go index dc57468..29186fc 100644 --- a/pam_auth.go +++ b/pam_auth.go @@ -1,11 +1,15 @@ package main import ( + "context" "crypto/subtle" "errors" "fmt" + "log" "os" + "strconv" "strings" + "sync/atomic" "github.com/GehirnInc/crypt" _ "github.com/GehirnInc/crypt/apr1_crypt" @@ -14,6 +18,7 @@ import ( _ "github.com/GehirnInc/crypt/sha512_crypt" "github.com/openwall/yescrypt-go" "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/ssh" ) // pamAuthAvailable reports that system-password ("PAM") auth is compiled in. @@ -21,10 +26,85 @@ import ( // on non-Linux builds /etc/shadow simply does not exist and auth fails closed. const pamAuthAvailable = true -const shadowFile = "/etc/shadow" +const ( + shadowFile = "/etc/shadow" + passwdFile = "/etc/passwd" + + // minLoginUID / nobodyUID bound the accounts eligible for auto-import. + // Regular human login accounts start at UID 1000 on Debian/Ubuntu; system + // and service accounts (and "nobody") are excluded. + minLoginUID = 1000 + nobodyUID = 65534 +) var errNoSystemPassword = errors.New("account has no usable password") +// pamAuthEnabled mirrors Config.PAMAuthEnabled and is toggled live on config +// reload. Guarded atomically so passwordCallback can read it lock-free. +var pamAuthEnabled atomic.Bool + +func setPAMAuthEnabled(v bool) { pamAuthEnabled.Store(v) } +func isPAMAuthEnabled() bool { return pamAuthEnabled.Load() } + +// pamLoginAndImport authenticates an unknown SSH username against the Linux +// system password and, on success, auto-imports it as a panel user. It is only +// called when server-wide PAM login is enabled. Returns nil permissions on +// success (matching the panel's other auth callbacks). +func pamLoginAndImport(username, password string) (*ssh.Permissions, error) { + if !isRegularLoginUser(username) { + // Not a regular human account (system/service account, root, or absent). + return nil, fmt.Errorf("authentication failed") + } + if err := authenticatePAM(username, password); err != nil { + log.Printf("PAM login failed for %s: %v", username, err) + return nil, fmt.Errorf("authentication failed") + } + importPAMUser(username) + return nil, nil +} + +// importPAMUser registers a freshly PAM-authenticated account in the running +// user manager and persists it (marked use_pam) so it shows up in the panel and +// later logins are re-verified against the system password. Idempotent: a +// second concurrent/subsequent login for the same user is a no-op. +func importPAMUser(username string) { + cfg := UserConfig{Username: username, UsePAM: true} + st := &UserState{Cfg: cfg} + if !userMgr.AddIfAbsent(st) { + return // already present in memory + } + log.Printf("PAM: auto-imported system user %s into the panel", username) + if statsStore != nil { + if err := statsStore.UpsertUser(context.Background(), cfg); err != nil { + log.Printf("PAM: failed to persist auto-imported user %s: %v", username, err) + } + } +} + +// isRegularLoginUser reports whether username is a regular human login account +// (UID >= 1000 and not "nobody"), by parsing /etc/passwd. System/service +// accounts and root are excluded from auto-import. +func isRegularLoginUser(username string) bool { + data, err := os.ReadFile(passwdFile) + if err != nil { + log.Printf("PAM: cannot read %s: %v", passwdFile, err) + return false + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + fields := strings.Split(line, ":") + if len(fields) < 3 || fields[0] != username { + continue + } + uid, err := strconv.Atoi(fields[2]) + if err != nil { + return false + } + return uid >= minLoginUID && uid != nobodyUID + } + return false +} + // authenticatePAM verifies password against the Linux system account matching // username. It reads the account's hash from /etc/shadow (the panel runs as // root) and recomputes it with the same algorithm — this is the "just the auth"