// 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 }