140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
// Package sysadm performs the actual privileged system operations: creating
|
|
// and locking Linux accounts, writing authorized_keys with the restrictions
|
|
// that make an account jump/forward-only, and installing the sshd Match
|
|
// block that provides defense-in-depth on top of those per-key options.
|
|
//
|
|
// Everything here shells out to standard coreutils/openssh tools rather than
|
|
// re-implementing user management, so behavior matches what any admin
|
|
// inspecting the box by hand would expect.
|
|
package sysadm
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
)
|
|
|
|
// JumpGroup is the Linux group every managed account is placed in. The sshd
|
|
// Match block in sshdconfig.go applies baseline restrictions to this group
|
|
// as defense-in-depth alongside the per-key authorized_keys options.
|
|
const JumpGroup = "ssh-jump-users"
|
|
|
|
// NologinShell is assigned to every managed account. Combined with
|
|
// authorized_keys "restrict,port-forwarding", this is what actually blocks
|
|
// interactive shell access: any session/exec channel request runs nologin,
|
|
// while direct-tcpip (jump/forward) channels are handled separately by sshd
|
|
// and are governed by the permitopen list, not by the login shell.
|
|
var NologinShell = firstExisting([]string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false"})
|
|
|
|
func firstExisting(paths []string) string {
|
|
for _, p := range paths {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return "/bin/false"
|
|
}
|
|
|
|
// RequireRoot fails fast with a clear message instead of letting individual
|
|
// useradd/usermod calls fail one by one with confusing permission errors.
|
|
func RequireRoot() error {
|
|
if os.Geteuid() != 0 {
|
|
return fmt.Errorf("this operation requires root; run ssh-access-tui with sudo")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureJumpGroup creates the shared group for managed accounts if missing.
|
|
func EnsureJumpGroup() error {
|
|
if _, err := user.LookupGroup(JumpGroup); err == nil {
|
|
return nil
|
|
}
|
|
return runCmd("groupadd", JumpGroup)
|
|
}
|
|
|
|
// UserExists reports whether a Linux account with this username exists.
|
|
func UserExists(username string) bool {
|
|
_, err := user.Lookup(username)
|
|
return err == nil
|
|
}
|
|
|
|
// CreateSystemUser provisions a new Linux account for username: no password
|
|
// login, no interactive shell, home directory for the .ssh dir, and
|
|
// membership in JumpGroup for the sshd-side baseline restrictions.
|
|
func CreateSystemUser(username string) error {
|
|
if err := RequireRoot(); err != nil {
|
|
return err
|
|
}
|
|
if UserExists(username) {
|
|
return fmt.Errorf("system user %q already exists", username)
|
|
}
|
|
if err := EnsureJumpGroup(); err != nil {
|
|
return fmt.Errorf("ensure jump group: %w", err)
|
|
}
|
|
if err := runCmd("useradd",
|
|
"--create-home",
|
|
"--shell", NologinShell,
|
|
"--comment", "managed by ssh-access-tui",
|
|
"--groups", JumpGroup,
|
|
username,
|
|
); err != nil {
|
|
return fmt.Errorf("useradd %s: %w", username, err)
|
|
}
|
|
// No password login is possible at all (key-only account).
|
|
if err := runCmd("passwd", "-l", username); err != nil {
|
|
return fmt.Errorf("lock password for %s: %w", username, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LockSystemUser disables an account belonging to a departed user. It layers
|
|
// three independent mechanisms so the lock does not depend on any single one:
|
|
// password lock, immediate account expiry, and (by the caller, separately)
|
|
// removal of the key from authorized_keys.
|
|
func LockSystemUser(username string) error {
|
|
if err := RequireRoot(); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("usermod", "-L", username); err != nil {
|
|
return fmt.Errorf("lock account %s: %w", username, err)
|
|
}
|
|
if err := runCmd("chage", "-E", "1", username); err != nil {
|
|
return fmt.Errorf("expire account %s: %w", username, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnlockSystemUser reverses LockSystemUser.
|
|
func UnlockSystemUser(username string) error {
|
|
if err := RequireRoot(); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("usermod", "-U", username); err != nil {
|
|
return fmt.Errorf("unlock account %s: %w", username, err)
|
|
}
|
|
if err := runCmd("chage", "-E", "-1", username); err != nil {
|
|
return fmt.Errorf("un-expire account %s: %w", username, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteSystemUser fully removes the Linux account and its home directory.
|
|
// Use for permanent offboarding cleanup, distinct from LockSystemUser which
|
|
// is the reversible day-to-day "employee left" action.
|
|
func DeleteSystemUser(username string) error {
|
|
if err := RequireRoot(); err != nil {
|
|
return err
|
|
}
|
|
return runCmd("userdel", "-r", username)
|
|
}
|
|
|
|
func runCmd(name string, args ...string) error {
|
|
cmd := exec.Command(name, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%s %v: %w: %s", name, args, err, out)
|
|
}
|
|
return nil
|
|
}
|