init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,112 @@
|
||||
package sysadm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// managedMarker delimits the block we own inside authorized_keys, so the
|
||||
// file can in principle carry other content without us clobbering it.
|
||||
const managedMarker = "# ssh-access-tui managed key — do not edit by hand, use the TUI"
|
||||
|
||||
// BuildKeyOptions renders the authorized_keys option string that is the
|
||||
// actual security boundary for a jump/forward-only account:
|
||||
// - restrict disables pty, X11, agent-forwarding, user-rc, etc.
|
||||
// - port-forwarding re-enables (only) local/dynamic forwarding
|
||||
// - permitopen=... whitelists exactly the host:port pairs this key
|
||||
// may forward to — this is what makes "only these hosts" a protocol-level
|
||||
// guarantee rather than a client-side convention.
|
||||
//
|
||||
// Reverse forwarding (-R) is intentionally never granted here.
|
||||
func BuildKeyOptions(hosts []model.Host) string {
|
||||
if len(hosts) == 0 {
|
||||
// No hosts assigned yet: restrict everything, permit nothing, so the
|
||||
// key authenticates but cannot open forwarding channels anywhere.
|
||||
return "restrict"
|
||||
}
|
||||
opts := []string{"restrict", "port-forwarding"}
|
||||
for _, h := range hosts {
|
||||
opts = append(opts, fmt.Sprintf(`permitopen="%s:%s"`, h.Address, strconv.Itoa(h.Port)))
|
||||
}
|
||||
return strings.Join(opts, ",")
|
||||
}
|
||||
|
||||
// WriteAuthorizedKeys writes the single managed key line (with its
|
||||
// permitopen restrictions) into ~<username>/.ssh/authorized_keys, replacing
|
||||
// any previous ssh-access-tui-managed line for that user.
|
||||
func WriteAuthorizedKeys(username, pubKeyLine string, hosts []model.Host) error {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
sshDir := filepath.Join(u.HomeDir, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create .ssh dir: %w", err)
|
||||
}
|
||||
line := fmt.Sprintf("%s %s", BuildKeyOptions(hosts), strings.TrimSpace(pubKeyLine))
|
||||
content := managedMarker + "\n" + line + "\n"
|
||||
|
||||
akPath := filepath.Join(sshDir, "authorized_keys")
|
||||
if err := os.WriteFile(akPath, []byte(content), 0o600); err != nil {
|
||||
return fmt.Errorf("write authorized_keys: %w", err)
|
||||
}
|
||||
if err := chownRecursive(sshDir, u); err != nil {
|
||||
return fmt.Errorf("chown .ssh dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableAuthorizedKeys comments out the managed key so the account cannot
|
||||
// authenticate at all, without deleting the record of what the key was
|
||||
// (useful when re-enabling a returning employee later).
|
||||
func DisableAuthorizedKeys(username string) error {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
akPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys")
|
||||
data, err := os.ReadFile(akPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read authorized_keys: %w", err)
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "restrict") {
|
||||
lines[i] = "# LOCKED: " + l
|
||||
}
|
||||
}
|
||||
return os.WriteFile(akPath, []byte(strings.Join(lines, "\n")), 0o600)
|
||||
}
|
||||
|
||||
func chownRecursive(dir string, u *user.User) error {
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(dir, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := os.Chown(filepath.Join(dir, e.Name()), uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package sysadm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// dropInPath is the sshd config snippet this tool owns. Modern OpenSSH
|
||||
// (Debian/Ubuntu/RHEL defaults) includes /etc/ssh/sshd_config.d/*.conf from
|
||||
// the main sshd_config automatically; DropInIncluded checks that assumption.
|
||||
const dropInPath = "/etc/ssh/sshd_config.d/50-ssh-access-tui.conf"
|
||||
|
||||
const dropInTemplate = `# Managed by ssh-access-tui. Do not edit by hand — re-run
|
||||
# "ssh-access-tui install" to regenerate this file.
|
||||
#
|
||||
# Baseline (defense-in-depth) restrictions for all jump/forward-only
|
||||
# accounts. The authoritative per-user host whitelist lives in each
|
||||
# account's authorized_keys "permitopen=" options, not here — this block
|
||||
# only backstops that in case a key's options were ever mis-set.
|
||||
Match Group ` + JumpGroup + `
|
||||
PermitTTY no
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
AllowTcpForwarding local
|
||||
PermitTunnel no
|
||||
Match all
|
||||
|
||||
# VERBOSE logging records which destination host:port each forwarded
|
||||
# channel was opened to — needed for the audit trail of "who connected
|
||||
# where", since plain port-forward sessions don't show up in ` + "`who`" + `.
|
||||
LogLevel VERBOSE
|
||||
`
|
||||
|
||||
// DropInIncluded reports whether the main sshd_config appears to include
|
||||
// the sshd_config.d drop-in directory. If not, the caller should tell the
|
||||
// admin to add "Include /etc/ssh/sshd_config.d/*.conf" manually.
|
||||
func DropInIncluded() bool {
|
||||
data, err := os.ReadFile("/etc/ssh/sshd_config")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return contains(string(data), "sshd_config.d")
|
||||
}
|
||||
|
||||
// InstallSSHDConfig writes the managed drop-in, validates it with
|
||||
// `sshd -t`, and reloads the running sshd. It refuses to reload (leaving the
|
||||
// previous config live) if validation fails.
|
||||
func InstallSSHDConfig() error {
|
||||
if err := RequireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dropInPath, []byte(dropInTemplate), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dropInPath, err)
|
||||
}
|
||||
if out, err := exec.Command("sshd", "-t").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sshd config now invalid, NOT reloaded — fix and rerun: %s", out)
|
||||
}
|
||||
if err := reloadSSHD(); err != nil {
|
||||
return fmt.Errorf("config valid but reload failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadSSHD() error {
|
||||
// Service name differs across distros; try both, ignore first failure.
|
||||
if err := exec.Command("systemctl", "reload", "ssh").Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
return exec.Command("systemctl", "reload", "sshd").Run()
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user