init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,75 @@
|
||||
// Package audit maintains an append-only record of connect/disconnect
|
||||
// events (independent of journald, which may rotate) and answers "who is
|
||||
// online right now" — which for jump/forward-only sessions is not visible
|
||||
// via `who`/`loginctl`, since those rely on pty allocation that this tool
|
||||
// deliberately disables.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
// Event is one line of the audit trail.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
User string `json:"user"`
|
||||
Action string `json:"action"` // "connect", "disconnect", "admin"
|
||||
Detail string `json:"detail,omitempty"`
|
||||
SrcIP string `json:"src_ip,omitempty"`
|
||||
}
|
||||
|
||||
// Append adds one event to the JSONL audit log at path, under a file lock
|
||||
// so the interactive TUI and the background journal-tailer (see tail.go)
|
||||
// can write concurrently without interleaving corrupt lines.
|
||||
func Append(path string, ev Event) error {
|
||||
fl := flock.New(path + ".lock")
|
||||
if err := fl.Lock(); err != nil {
|
||||
return fmt.Errorf("lock audit log: %w", err)
|
||||
}
|
||||
defer fl.Unlock()
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open audit log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadRecent returns the last n events from the audit log, oldest first.
|
||||
func ReadRecent(path string, n int) ([]Event, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var all []Event
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
var ev Event
|
||||
if err := json.Unmarshal(sc.Bytes(), &ev); err == nil {
|
||||
all = append(all, ev)
|
||||
}
|
||||
}
|
||||
if len(all) > n {
|
||||
all = all[len(all)-n:]
|
||||
}
|
||||
return all, sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OnlineSession is one currently-connected sshd process for a managed user.
|
||||
type OnlineSession struct {
|
||||
User string
|
||||
PID int
|
||||
Elapsed string // e.g. "01:23:45" as reported by ps etime
|
||||
}
|
||||
|
||||
// ListOnline reports users currently connected. Port-forwarding-only
|
||||
// sessions never allocate a pty, so they are invisible to `who`/`loginctl`;
|
||||
// what *is* always true is that an authenticated connection keeps a
|
||||
// "sshd: <user> [priv]"-style privilege-separated process alive for as long
|
||||
// as the session lasts, so we read that from `ps` instead.
|
||||
func ListOnline(managedUsers map[string]bool) ([]OnlineSession, error) {
|
||||
out, err := exec.Command("ps", "-eo", "user:32,pid,etime,args", "--no-headers").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ps: %w", err)
|
||||
}
|
||||
var sessions []OnlineSession
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
user, pidStr, etime := fields[0], fields[1], fields[2]
|
||||
args := strings.Join(fields[3:], " ")
|
||||
if !strings.Contains(args, "sshd:") {
|
||||
continue
|
||||
}
|
||||
if !managedUsers[user] {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, OnlineSession{User: user, PID: pid, Elapsed: etime})
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func now() time.Time { return time.Now() }
|
||||
|
||||
var (
|
||||
reAccepted = regexp.MustCompile(`Accepted publickey for (\S+) from (\S+) port \d+`)
|
||||
reDisconnect = regexp.MustCompile(`Disconnected from user (\S+) (\S+) port \d+`)
|
||||
reSessClosed = regexp.MustCompile(`session closed for user (\S+)`)
|
||||
)
|
||||
|
||||
// TailJournal runs indefinitely (intended to be run under systemd — see
|
||||
// scripts/ssh-access-tui-audit.service), following sshd's journal output and
|
||||
// appending normalized connect/disconnect events to the audit log at
|
||||
// logPath. This is what makes "who logged in/out" survive independently of
|
||||
// journald's own retention/rotation policy.
|
||||
func TailJournal(logPath string) error {
|
||||
cmd := exec.Command("journalctl", "-u", "ssh", "-u", "sshd", "-f", "-n", "0", "-o", "cat")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("journalctl pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start journalctl: %w", err)
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(stdout)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case reAccepted.MatchString(line):
|
||||
m := reAccepted.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "connect", SrcIP: m[2]})
|
||||
case reDisconnect.MatchString(line):
|
||||
m := reDisconnect.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect", SrcIP: m[2]})
|
||||
case reSessClosed.MatchString(line):
|
||||
m := reSessClosed.FindStringSubmatch(line)
|
||||
_ = Append(logPath, Event{Time: now(), User: m[1], Action: "disconnect"})
|
||||
}
|
||||
}
|
||||
return cmd.Wait()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package config holds the small set of deployment-specific settings the
|
||||
// tool needs: how this box is reached from the outside, and where its state
|
||||
// lives on disk.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the tool's deployment configuration, normally loaded from
|
||||
// /etc/ssh-access-tui/config.yaml (see scripts/install.sh).
|
||||
type Config struct {
|
||||
// JumpHost is the address end users' ssh clients connect to — usually
|
||||
// this box's public hostname/IP, used only to render client configs.
|
||||
JumpHost string `yaml:"jump_host"`
|
||||
JumpPort int `yaml:"jump_port"`
|
||||
|
||||
StateDir string `yaml:"state_dir"` // git-backed YAML state (groups/users)
|
||||
ExportDir string `yaml:"export_dir"` // where finished .7z archives are written
|
||||
ScratchDir string `yaml:"scratch_dir"` // ephemeral working dir, wiped after each export
|
||||
AuditLog string `yaml:"audit_log"` // append-only JSONL connect/disconnect log
|
||||
}
|
||||
|
||||
// Default returns sane defaults for an install following scripts/install.sh.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
JumpPort: 22,
|
||||
StateDir: "/var/lib/ssh-access-tui/state",
|
||||
ExportDir: "/var/lib/ssh-access-tui/exports",
|
||||
ScratchDir: "/var/lib/ssh-access-tui/scratch",
|
||||
AuditLog: "/var/lib/ssh-access-tui/audit/audit.jsonl",
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads path if it exists, overlaying it on Default(); missing fields
|
||||
// keep their default. A missing file is not an error (first run).
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes cfg to path as YAML, creating parent directories as needed.
|
||||
func Save(path string, cfg Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// RenderClientConfig produces an OpenSSH client config (works identically on
|
||||
// Windows' built-in OpenSSH client, macOS, and Linux) that gives the user a
|
||||
// "jump" alias plus one alias per host they're allowed to reach.
|
||||
//
|
||||
// This file is convenience only, not a security boundary: nothing stops a
|
||||
// user from editing it or invoking ssh by hand. The actual restriction to
|
||||
// these hosts is enforced server-side via authorized_keys permitopen=
|
||||
// (see internal/sysadm/authorizedkeys.go) independent of what this file says.
|
||||
func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName string, hosts []model.Host) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
|
||||
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed host list changes.\n\n", genTimestamp())
|
||||
|
||||
fmt.Fprintf(&b, "Host jump\n")
|
||||
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
|
||||
fmt.Fprintf(&b, " Port %d\n", jumpPort)
|
||||
fmt.Fprintf(&b, " User %s\n", username)
|
||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
|
||||
|
||||
for _, h := range hosts {
|
||||
fmt.Fprintf(&b, "Host %s\n", h.Name)
|
||||
fmt.Fprintf(&b, " HostName %s\n", h.Address)
|
||||
fmt.Fprintf(&b, " Port %d\n", h.Port)
|
||||
fmt.Fprintf(&b, " User %s\n", username)
|
||||
fmt.Fprintf(&b, " ProxyJump jump\n")
|
||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n")
|
||||
if h.Description != "" {
|
||||
fmt.Fprintf(&b, " # %s\n", h.Description)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
func genTimestamp() string {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
// RenderInstructions produces a plain-text README aimed at a Windows user
|
||||
// whose only guaranteed tool is the OpenSSH client bundled with Windows
|
||||
// 10/11 ("Optional Features" -> OpenSSH Client, or already present).
|
||||
func RenderInstructions(username, jumpHost, keyFileName string, hosts []model.Host) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("SSH ACCESS — SETUP INSTRUCTIONS (Windows)\n")
|
||||
b.WriteString("==========================================\n\n")
|
||||
fmt.Fprintf(&b, "Account: %s\n", username)
|
||||
fmt.Fprintf(&b, "Jump host: %s\n\n", jumpHost)
|
||||
|
||||
b.WriteString("This archive contains:\n")
|
||||
fmt.Fprintf(&b, " - %s your PRIVATE key (keep secret, do not share or send onward)\n", keyFileName)
|
||||
fmt.Fprintf(&b, " - %s.pub the matching public key (for reference only)\n", keyFileName)
|
||||
b.WriteString(" - config SSH client config with ready-made host shortcuts\n")
|
||||
b.WriteString(" - INSTRUCTIONS.txt this file\n\n")
|
||||
|
||||
b.WriteString("1. Check you have the SSH client\n")
|
||||
b.WriteString(" Open PowerShell and run:\n")
|
||||
b.WriteString(" ssh -V\n")
|
||||
b.WriteString(" If this fails: Settings -> Apps -> Optional Features -> Add a feature ->\n")
|
||||
b.WriteString(" \"OpenSSH Client\" -> Install. (Windows 10 1809+/11 ship this by default.)\n\n")
|
||||
|
||||
b.WriteString("2. Put the files in place\n")
|
||||
b.WriteString(" Create the folder (if it doesn't exist) and copy the 3 files into it:\n")
|
||||
b.WriteString(" %USERPROFILE%\\.ssh\\\n")
|
||||
b.WriteString(" i.e. C:\\Users\\<you>\\.ssh\\\n")
|
||||
b.WriteString(" The \"config\" file should be merged with (or placed as) that exact filename,\n")
|
||||
b.WriteString(" with no file extension: C:\\Users\\<you>\\.ssh\\config\n\n")
|
||||
|
||||
b.WriteString("3. Fix the private key permissions (Windows OpenSSH refuses keys that are\n")
|
||||
b.WriteString(" readable by other users). In PowerShell:\n")
|
||||
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /inheritance:r\n", keyFileName)
|
||||
fmt.Fprintf(&b, " icacls $env:USERPROFILE\\.ssh\\%s /grant:r \"$env:USERNAME:(R)\"\n\n", keyFileName)
|
||||
|
||||
b.WriteString("4. Connect\n")
|
||||
b.WriteString(" You have shortcuts for each host you're allowed to reach. To open a local\n")
|
||||
b.WriteString(" port forward to one of them (example: a web service on port 8443):\n\n")
|
||||
if len(hosts) > 0 {
|
||||
h := hosts[0]
|
||||
fmt.Fprintf(&b, " ssh -N -L 8443:localhost:%d %s\n", h.Port, h.Name)
|
||||
b.WriteString(" Then browse to https://localhost:8443 (or whatever local port you chose)\n")
|
||||
b.WriteString(" while that command keeps running. Ctrl+C to stop.\n\n")
|
||||
} else {
|
||||
b.WriteString(" ssh -N -L <local-port>:localhost:<remote-port> <host-alias>\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(" Your allowed hosts (aliases from the config file):\n")
|
||||
for _, h := range hosts {
|
||||
fmt.Fprintf(&b, " - %-20s -> %s:%d", h.Name, h.Address, h.Port)
|
||||
if h.Description != "" {
|
||||
fmt.Fprintf(&b, " (%s)", h.Description)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("5. You will NOT get an interactive shell\n")
|
||||
b.WriteString(" Running plain \"ssh jump\" will connect and immediately close — this account\n")
|
||||
b.WriteString(" is jump/port-forwarding only, by design. Use the -L/-D forwarding commands\n")
|
||||
b.WriteString(" above (via the host aliases, which already route through the jump host).\n\n")
|
||||
|
||||
b.WriteString("If access stops working, contact your administrator — your account may have\n")
|
||||
b.WriteString("been locked, or the allowed host list may have changed (ask for an updated\n")
|
||||
b.WriteString("config in that case).\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package keymgr generates per-user SSH keypairs and packages them, together
|
||||
// with a ready-to-use client config and Windows-oriented instructions, into
|
||||
// a password-protected 7z archive for delivery.
|
||||
//
|
||||
// The private key only ever exists on disk for the lifetime of one export
|
||||
// operation: generated into a temp directory, packed into the archive, then
|
||||
// securely wiped (see Wipe in pack.go). It is never written into the state
|
||||
// store and therefore never ends up in a backup.
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyPair is the result of generating one user's SSH keypair.
|
||||
type KeyPair struct {
|
||||
PrivateKeyPath string
|
||||
PublicKeyLine string // full "ssh-ed25519 AAAA... comment" line for authorized_keys
|
||||
Fingerprint string // "SHA256:xxxxx" as reported by ssh-keygen -lf
|
||||
}
|
||||
|
||||
// GeneratePair creates a new passphrase-less ed25519 keypair for username in
|
||||
// dir. The key itself has no passphrase because the archive it ships in is
|
||||
// already encrypted (see pack.go); a second passphrase would only add
|
||||
// friction without a corresponding threat it defends against once the
|
||||
// archive's own encryption and the server-side no-shell restriction are in
|
||||
// place.
|
||||
func GeneratePair(dir, username string) (*KeyPair, error) {
|
||||
priv := filepath.Join(dir, username+"_jump_key")
|
||||
pub := priv + ".pub"
|
||||
|
||||
cmd := exec.Command("ssh-keygen",
|
||||
"-t", "ed25519",
|
||||
"-N", "", // no passphrase
|
||||
"-C", username+"@jump",
|
||||
"-f", priv,
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("ssh-keygen: %w: %s", err, out)
|
||||
}
|
||||
|
||||
pubData, err := os.ReadFile(pub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read public key: %w", err)
|
||||
}
|
||||
|
||||
fpOut, err := exec.Command("ssh-keygen", "-lf", pub).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fingerprint: %w: %s", err, fpOut)
|
||||
}
|
||||
fields := strings.Fields(string(fpOut))
|
||||
fingerprint := ""
|
||||
if len(fields) >= 2 {
|
||||
fingerprint = fields[1]
|
||||
}
|
||||
|
||||
return &KeyPair{
|
||||
PrivateKeyPath: priv,
|
||||
PublicKeyLine: strings.TrimSpace(string(pubData)),
|
||||
Fingerprint: fingerprint,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// BuildResult describes one user's finished delivery archive.
|
||||
type BuildResult struct {
|
||||
Username string
|
||||
ArchivePath string
|
||||
Fingerprint string
|
||||
PublicKeyLine string // capture before the scratch dir (and its .pub file) is wiped
|
||||
}
|
||||
|
||||
// BuildUserArchive generates a fresh keypair for username, writes the
|
||||
// client config and Windows instructions alongside it in a scratch
|
||||
// directory, packs everything into a single AES-256-encrypted 7z archive
|
||||
// under outDir, and then wipes the scratch directory (including the
|
||||
// plaintext private key) from disk. password is shared across one export
|
||||
// batch and must be communicated to the recipient out-of-band.
|
||||
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, hosts []model.Host) (*BuildResult, error) {
|
||||
userScratch := filepath.Join(scratchDir, username)
|
||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||
}
|
||||
defer Wipe(userScratch)
|
||||
|
||||
kp, err := GeneratePair(userScratch, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate keypair: %w", err)
|
||||
}
|
||||
keyFileName := filepath.Base(kp.PrivateKeyPath)
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, hosts)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
instr := RenderInstructions(username, jumpHost, keyFileName, hosts)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write instructions: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
archivePath := filepath.Join(outDir, username+"_ssh_access.7z")
|
||||
_ = os.Remove(archivePath) // 7z refuses to overwrite silently-appends; start clean
|
||||
|
||||
cmd := exec.Command("7z", "a",
|
||||
"-p"+password,
|
||||
"-mhe=on", // encrypt file names/headers too, not just contents
|
||||
"-mx=9",
|
||||
archivePath,
|
||||
filepath.Join(userScratch, keyFileName),
|
||||
filepath.Join(userScratch, keyFileName+".pub"),
|
||||
filepath.Join(userScratch, "config"),
|
||||
filepath.Join(userScratch, "INSTRUCTIONS.txt"),
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("7z: %w: %s", err, out)
|
||||
}
|
||||
|
||||
return &BuildResult{
|
||||
Username: username,
|
||||
ArchivePath: archivePath,
|
||||
Fingerprint: kp.Fingerprint,
|
||||
PublicKeyLine: kp.PublicKeyLine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Wipe securely removes a scratch directory (best-effort shred of file
|
||||
// contents, then unlink) so the private key does not linger in free disk
|
||||
// space or in any backup that might sweep the tool's working directory.
|
||||
func Wipe(dir string) {
|
||||
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
_ = exec.Command("shred", "-u", "-n", "1", path).Run()
|
||||
return nil
|
||||
})
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// charset avoids visually ambiguous characters (0/O, 1/l/I) since this
|
||||
// password is normally read aloud or typed by hand on the recipient side.
|
||||
const passwordCharset = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"
|
||||
|
||||
// GenerateBatchPassword returns a cryptographically random password shared
|
||||
// by every archive produced in one export batch. It is shown to the admin
|
||||
// exactly once (see the export result screen) and must be relayed to each
|
||||
// recipient over a channel separate from the archive itself.
|
||||
func GenerateBatchPassword(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 20
|
||||
}
|
||||
out := make([]byte, length)
|
||||
max := big.NewInt(int64(len(passwordCharset)))
|
||||
for i := range out {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out[i] = passwordCharset[n.Int64()]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package model defines the data types persisted as the tool's state:
|
||||
// hosts grouped into access groups, and users assigned to those groups.
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Host is one jump-target reachable via port forwarding through the bastion.
|
||||
type Host struct {
|
||||
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
||||
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
||||
Port int `yaml:"port"` // TCP port allowed for forwarding
|
||||
Description string `yaml:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Group bundles hosts that share the same access policy (e.g. "team-billing").
|
||||
// Users are assigned to one or more groups; their effective allowed hosts are
|
||||
// the union of all hosts in their groups.
|
||||
type Group struct {
|
||||
Name string `yaml:"name"`
|
||||
Hosts []Host `yaml:"hosts"`
|
||||
}
|
||||
|
||||
// UserStatus is the lifecycle state of a managed account.
|
||||
type UserStatus string
|
||||
|
||||
const (
|
||||
StatusActive UserStatus = "active"
|
||||
StatusLocked UserStatus = "locked"
|
||||
)
|
||||
|
||||
// User is one managed Linux/SSH account on the jump host.
|
||||
type User struct {
|
||||
Username string `yaml:"username"`
|
||||
Groups []string `yaml:"groups"`
|
||||
Status UserStatus `yaml:"status"`
|
||||
Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key
|
||||
PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's host list changes) without re-issuing the keypair
|
||||
Comment string `yaml:"comment,omitempty"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
||||
}
|
||||
|
||||
// State is the full persisted configuration of the tool.
|
||||
type State struct {
|
||||
Groups []Group `yaml:"groups"`
|
||||
Users []User `yaml:"users"`
|
||||
}
|
||||
|
||||
// FindGroup returns a pointer to the group with the given name, or nil.
|
||||
func (s *State) FindGroup(name string) *Group {
|
||||
for i := range s.Groups {
|
||||
if s.Groups[i].Name == name {
|
||||
return &s.Groups[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindUser returns a pointer to the user with the given username, or nil.
|
||||
func (s *State) FindUser(username string) *User {
|
||||
for i := range s.Users {
|
||||
if s.Users[i].Username == username {
|
||||
return &s.Users[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowedHosts returns the de-duplicated union of hosts across all groups
|
||||
// the user belongs to, sorted by "name" for stable authorized_keys output.
|
||||
func (s *State) AllowedHosts(u *User) []Host {
|
||||
seen := map[string]Host{}
|
||||
order := []string{}
|
||||
for _, gname := range u.Groups {
|
||||
g := s.FindGroup(gname)
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
for _, h := range g.Hosts {
|
||||
key := h.Address + ":" + itoa(h.Port)
|
||||
if _, ok := seen[key]; !ok {
|
||||
order = append(order, key)
|
||||
}
|
||||
seen[key] = h
|
||||
}
|
||||
}
|
||||
out := make([]Host, 0, len(order))
|
||||
for _, k := range order {
|
||||
out = append(out, seen[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package store persists model.State to disk as YAML under a file lock and
|
||||
// keeps a git history of every change, so the whole configuration (groups,
|
||||
// hosts, user metadata) can be restored on a fresh host after a disaster.
|
||||
//
|
||||
// Deliberately NOT stored here: private keys. Those are generated, packed
|
||||
// into an export archive, and wiped from disk in the same operation — only
|
||||
// public-key fingerprints are kept in state, so a stolen backup cannot leak
|
||||
// anyone's private key.
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFileName = "state.yaml"
|
||||
lockFileName = "state.lock"
|
||||
)
|
||||
|
||||
// Store wraps a state directory (git repo) holding state.yaml.
|
||||
type Store struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
// New returns a Store rooted at dir, creating the directory and git repo
|
||||
// (via Init) if they do not exist yet.
|
||||
func New(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create state dir: %w", err)
|
||||
}
|
||||
s := &Store{Dir: dir}
|
||||
if err := s.ensureGitRepo(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) statePath() string { return filepath.Join(s.Dir, stateFileName) }
|
||||
func (s *Store) lockPath() string { return filepath.Join(s.Dir, lockFileName) }
|
||||
|
||||
func (s *Store) ensureGitRepo() error {
|
||||
if _, err := os.Stat(filepath.Join(s.Dir, ".git")); err == nil {
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("git", "init", "-q", s.Dir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git init: %w: %s", err, out)
|
||||
}
|
||||
// Local commit identity so this works even with no global gitconfig.
|
||||
_ = runGit(s.Dir, "config", "user.name", "ssh-access-tui")
|
||||
_ = runGit(s.Dir, "config", "user.email", "ssh-access-tui@localhost")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads state.yaml, returning an empty State if it does not exist yet.
|
||||
// Callers should hold a lock (see WithLock) around Load+mutate+Save.
|
||||
func (s *Store) Load() (*model.State, error) {
|
||||
data, err := os.ReadFile(s.statePath())
|
||||
if os.IsNotExist(err) {
|
||||
return &model.State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read state: %w", err)
|
||||
}
|
||||
var st model.State
|
||||
if err := yaml.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("parse state: %w", err)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// Save writes state.yaml and commits the change to the local git repo with
|
||||
// the given message (should describe the admin action, e.g. "add user bob").
|
||||
func (s *Store) Save(st *model.State, commitMsg string) error {
|
||||
data, err := yaml.Marshal(st)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
tmp := s.statePath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o640); err != nil {
|
||||
return fmt.Errorf("write state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.statePath()); err != nil {
|
||||
return fmt.Errorf("commit state file: %w", err)
|
||||
}
|
||||
if err := runGit(s.Dir, "add", stateFileName); err != nil {
|
||||
return fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
// A no-op commit (nothing changed) is not an error.
|
||||
if err := runGit(s.Dir, "commit", "-q", "-m", commitMsg); err != nil {
|
||||
if !isNothingToCommit(err) {
|
||||
return fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithLock takes an exclusive file lock for the duration of fn, protecting
|
||||
// against a second concurrent admin session corrupting state.yaml. The tool
|
||||
// is designed for one admin operating at a time; this lock turns an
|
||||
// accidental second session into a clear "someone else is editing" error
|
||||
// instead of silent data loss.
|
||||
func (s *Store) WithLock(fn func() error) error {
|
||||
fl := flock.New(s.lockPath())
|
||||
locked, err := fl.TryLock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire lock: %w", err)
|
||||
}
|
||||
if !locked {
|
||||
return fmt.Errorf("state is locked by another ssh-access-tui session (lock file: %s)", s.lockPath())
|
||||
}
|
||||
defer fl.Unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func runGit(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNothingToCommit(err error) bool {
|
||||
// git exits non-zero with "nothing to commit" when the working tree is
|
||||
// unchanged; treat that as success rather than surfacing it as failure.
|
||||
return err != nil && (contains(err.Error(), "nothing to commit") || contains(err.Error(), "nothing added"))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (func() bool {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
// Now is a var so tests can override it; production code just wants time.Now.
|
||||
var Now = time.Now
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package ui implements the interactive terminal UI: a single bubbletea
|
||||
// Model with a small screen stack, backed by the real system/store/keymgr
|
||||
// operations — every action taken here (create user, lock, edit group
|
||||
// hosts, export) performs the actual privileged operation immediately, then
|
||||
// records it to the audit log and (for state changes) commits it to the
|
||||
// git-backed state store.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"ssh-access-tui/internal/audit"
|
||||
"ssh-access-tui/internal/config"
|
||||
"ssh-access-tui/internal/keymgr"
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/store"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
scrMenu screen = iota
|
||||
scrUsers
|
||||
scrUserNew
|
||||
scrUserNewGroups
|
||||
scrGroups
|
||||
scrGroupHosts
|
||||
scrHostNew
|
||||
scrExportSelect
|
||||
scrExportResult
|
||||
scrAudit
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1)
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
||||
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2)
|
||||
)
|
||||
|
||||
// Model is the root bubbletea model for the whole application.
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
|
||||
screen screen
|
||||
err string
|
||||
status string
|
||||
|
||||
menu list.Model
|
||||
|
||||
usersList list.Model
|
||||
|
||||
userNewName textinput.Model
|
||||
userNewComment textinput.Model
|
||||
userNewFocus int
|
||||
userNewGroupsSel map[string]bool
|
||||
userNewGroupsOrder []string
|
||||
userNewCursor int
|
||||
|
||||
groupsList list.Model
|
||||
groupNewName textinput.Model
|
||||
awaitingGroup bool
|
||||
selectedGroup string
|
||||
hostsList list.Model
|
||||
hostForm [4]textinput.Model // name, address, port, description
|
||||
hostFormFocus int
|
||||
|
||||
exportSel map[string]bool
|
||||
exportOrder []string
|
||||
exportCursor int
|
||||
exportRes []keymgr.BuildResult
|
||||
exportPass string
|
||||
exportErr string
|
||||
|
||||
onlineSessions []audit.OnlineSession
|
||||
recentEvents []audit.Event
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
// New builds the initial Model rooted at the given config and store.
|
||||
//
|
||||
// All list.Model fields are initialized here via list.New (not left as the
|
||||
// zero value), even for screens the user hasn't visited yet: bubbletea
|
||||
// delivers a WindowSizeMsg immediately on startup, which fans out to
|
||||
// SetSize on every list in Update — and list.Model.SetSize on an
|
||||
// uninitialized zero-value list panics (nil pagination model).
|
||||
func New(cfg config.Config, st *store.Store) Model {
|
||||
m := Model{cfg: cfg, st: st}
|
||||
m.menu = newMenuList()
|
||||
m.usersList = emptyList("Пользователи")
|
||||
m.groupsList = emptyList("Группы доступа")
|
||||
m.hostsList = emptyList("Хосты")
|
||||
return m
|
||||
}
|
||||
|
||||
func emptyList(title string) list.Model {
|
||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *Model) loadState() *model.State {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
return &model.State{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (m *Model) mutate(commitMsg string, fn func(st *model.State) error) {
|
||||
err := m.st.WithLock(func() error {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(st); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.st.Save(st, commitMsg)
|
||||
})
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.status = commitMsg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) logAudit(user, action, detail string) {
|
||||
_ = audit.Append(m.cfg.AuditLog, audit.Event{Time: time.Now(), User: user, Action: action, Detail: detail})
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
listW, listH := msg.Width-4, msg.Height-8
|
||||
m.menu.SetSize(listW, listH)
|
||||
m.usersList.SetSize(listW, listH)
|
||||
m.groupsList.SetSize(listW, listH)
|
||||
m.hostsList.SetSize(listW, listH)
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
return m.updateMenu(msg)
|
||||
case scrUsers:
|
||||
return m.updateUsers(msg)
|
||||
case scrUserNew:
|
||||
return m.updateUserNew(msg)
|
||||
case scrUserNewGroups:
|
||||
return m.updateUserNewGroups(msg)
|
||||
case scrGroups:
|
||||
return m.updateGroups(msg)
|
||||
case scrGroupHosts:
|
||||
return m.updateGroupHosts(msg)
|
||||
case scrHostNew:
|
||||
return m.updateHostNew(msg)
|
||||
case scrExportSelect:
|
||||
return m.updateExportSelect(msg)
|
||||
case scrExportResult:
|
||||
return m.updateExportResult(msg)
|
||||
case scrAudit:
|
||||
return m.updateAudit(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
var body string
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
body = m.viewMenu()
|
||||
case scrUsers:
|
||||
body = m.viewUsers()
|
||||
case scrUserNew:
|
||||
body = m.viewUserNew()
|
||||
case scrUserNewGroups:
|
||||
body = m.viewUserNewGroups()
|
||||
case scrGroups:
|
||||
body = m.viewGroups()
|
||||
case scrGroupHosts:
|
||||
body = m.viewGroupHosts()
|
||||
case scrHostNew:
|
||||
body = m.viewHostNew()
|
||||
case scrExportSelect:
|
||||
body = m.viewExportSelect()
|
||||
case scrExportResult:
|
||||
body = m.viewExportResult()
|
||||
case scrAudit:
|
||||
body = m.viewAudit()
|
||||
}
|
||||
|
||||
footer := ""
|
||||
if m.err != "" {
|
||||
footer = errStyle.Render("ошибка: " + m.err)
|
||||
} else if m.status != "" {
|
||||
footer = okStyle.Render(m.status)
|
||||
}
|
||||
return fmt.Sprintf("%s\n\n%s\n", body, footer)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/audit"
|
||||
)
|
||||
|
||||
func (m *Model) refreshAudit() {
|
||||
st := m.loadState()
|
||||
managed := map[string]bool{}
|
||||
for _, u := range st.Users {
|
||||
managed[u.Username] = true
|
||||
}
|
||||
sessions, err := audit.ListOnline(managed)
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
}
|
||||
m.onlineSessions = sessions
|
||||
|
||||
events, err := audit.ReadRecent(m.cfg.AuditLog, 25)
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
}
|
||||
m.recentEvents = events
|
||||
}
|
||||
|
||||
func (m Model) updateAudit(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "r":
|
||||
m.refreshAudit()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewAudit() string {
|
||||
body := "Сейчас в сети (сессии с активным sshd-процессом):\n\n"
|
||||
if len(m.onlineSessions) == 0 {
|
||||
body += " (никого)\n"
|
||||
}
|
||||
for _, s := range m.onlineSessions {
|
||||
body += fmt.Sprintf(" %-16s pid=%-8d в сети %s\n", s.User, s.PID, s.Elapsed)
|
||||
}
|
||||
|
||||
body += "\nПоследние события (журнал подключений):\n\n"
|
||||
if len(m.recentEvents) == 0 {
|
||||
body += " (пока пусто — фоновый сборщик журнала ещё не запущен или не было событий)\n"
|
||||
}
|
||||
for i := len(m.recentEvents) - 1; i >= 0; i-- {
|
||||
e := m.recentEvents[i]
|
||||
line := fmt.Sprintf(" %s %-10s %-16s", e.Time.Local().Format("2006-01-02 15:04:05"), e.Action, e.User)
|
||||
if e.SrcIP != "" {
|
||||
line += " from " + e.SrcIP
|
||||
}
|
||||
if e.Detail != "" {
|
||||
line += " " + e.Detail
|
||||
}
|
||||
body += line + "\n"
|
||||
}
|
||||
|
||||
body += "\n[r] обновить [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/keymgr"
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
func (m *Model) initExportSelect() {
|
||||
st := m.loadState()
|
||||
m.exportSel = map[string]bool{}
|
||||
m.exportOrder = nil
|
||||
for _, u := range st.Users {
|
||||
if u.Status == model.StatusActive {
|
||||
m.exportOrder = append(m.exportOrder, u.Username)
|
||||
}
|
||||
}
|
||||
m.exportCursor = 0
|
||||
m.exportErr = ""
|
||||
}
|
||||
|
||||
func (m Model) updateExportSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.exportCursor > 0 {
|
||||
m.exportCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.exportCursor < len(m.exportOrder)-1 {
|
||||
m.exportCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
if len(m.exportOrder) > 0 {
|
||||
u := m.exportOrder[m.exportCursor]
|
||||
m.exportSel[u] = !m.exportSel[u]
|
||||
}
|
||||
return m, nil
|
||||
case "a":
|
||||
for _, u := range m.exportOrder {
|
||||
m.exportSel[u] = true
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
var chosen []string
|
||||
for _, u := range m.exportOrder {
|
||||
if m.exportSel[u] {
|
||||
chosen = append(chosen, u)
|
||||
}
|
||||
}
|
||||
if len(chosen) == 0 {
|
||||
m.err = "выберите хотя бы одного пользователя (space)"
|
||||
return m, nil
|
||||
}
|
||||
return m.runExport(chosen)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// runExport performs the actual batch export: for each chosen user it
|
||||
// (re)generates a keypair, rewrites authorized_keys with that user's
|
||||
// currently-allowed hosts, builds the encrypted archive, and records the new
|
||||
// public key/fingerprint in state. Re-exporting an existing user rotates
|
||||
// their key — that is deliberate: it is also how a config update ("new host
|
||||
// added to their group") gets delivered to the client side.
|
||||
func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
||||
password, err := keymgr.GenerateBatchPassword(20)
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
st := m.loadState()
|
||||
var results []keymgr.BuildResult
|
||||
var failures []string
|
||||
|
||||
for _, username := range usernames {
|
||||
u := st.FindUser(username)
|
||||
if u == nil {
|
||||
failures = append(failures, username+": не найден в состоянии")
|
||||
continue
|
||||
}
|
||||
hosts := st.AllowedHosts(u)
|
||||
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, hosts)
|
||||
if err != nil {
|
||||
failures = append(failures, username+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(username, res.PublicKeyLine, hosts); err != nil {
|
||||
failures = append(failures, username+": authorized_keys: "+err.Error())
|
||||
continue
|
||||
}
|
||||
u.PublicKey = res.PublicKeyLine
|
||||
u.Fingerprint = res.Fingerprint
|
||||
results = append(results, *res)
|
||||
m.logAudit(username, "admin", "key issued/rotated, fingerprint="+res.Fingerprint)
|
||||
}
|
||||
|
||||
if err := m.st.WithLock(func() error {
|
||||
return m.st.Save(st, fmt.Sprintf("export: выпущены/обновлены ключи для %v", usernames))
|
||||
}); err != nil {
|
||||
failures = append(failures, "сохранение состояния: "+err.Error())
|
||||
}
|
||||
|
||||
m.exportRes = results
|
||||
m.exportPass = password
|
||||
m.exportErr = ""
|
||||
for _, f := range failures {
|
||||
m.exportErr += f + "\n"
|
||||
}
|
||||
m.screen = scrExportResult
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewExportSelect() string {
|
||||
body := fmt.Sprintf("Экспорт архивов — выберите пользователей (общий пароль архива на всю партию)\n\n")
|
||||
if len(m.exportOrder) == 0 {
|
||||
body += warnStyle.Render("Нет активных пользователей. Сначала создайте пользователя.") + "\n"
|
||||
}
|
||||
for i, u := range m.exportOrder {
|
||||
cursor := " "
|
||||
if i == m.exportCursor {
|
||||
cursor = "> "
|
||||
}
|
||||
check := "[ ]"
|
||||
if m.exportSel[u] {
|
||||
check = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, u)
|
||||
}
|
||||
body += "\n[space] отметить [a] отметить всех [enter] сгенерировать архивы [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
func (m Model) updateExportResult(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if key.String() == "esc" || key.String() == "enter" {
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewExportResult() string {
|
||||
body := "Готово. Пароль архива (один на всю партию, покажется только сейчас!):\n\n"
|
||||
body += titleStyle.Render(m.exportPass) + "\n\n"
|
||||
body += warnStyle.Render("Сообщите этот пароль каждому получателю отдельным каналом (не тем же, что архив).") + "\n\n"
|
||||
for _, r := range m.exportRes {
|
||||
body += fmt.Sprintf(" %-20s -> %s (fingerprint %s)\n", r.Username, r.ArchivePath, r.Fingerprint)
|
||||
}
|
||||
if m.exportErr != "" {
|
||||
body += "\n" + errStyle.Render("Ошибки:\n"+m.exportErr)
|
||||
}
|
||||
body += "\n[enter/esc] в меню"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
func (m *Model) refreshGroupsList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Groups))
|
||||
for _, g := range st.Groups {
|
||||
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: fmt.Sprintf("%d хостов", len(g.Hosts))})
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height())
|
||||
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад"
|
||||
l.SetShowStatusBar(false)
|
||||
m.groupsList = l
|
||||
m.awaitingGroup = false
|
||||
}
|
||||
|
||||
// contains a small state flag reused from Model: awaitingGroup + groupNewName input
|
||||
|
||||
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.awaitingGroup {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.awaitingGroup = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.groupNewName.Value()
|
||||
if name == "" {
|
||||
m.err = "имя группы не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
if st.FindGroup(name) != nil {
|
||||
m.err = fmt.Sprintf("группа %q уже существует", name)
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("создана группа "+name, func(st *model.State) error {
|
||||
st.Groups = append(st.Groups, model.Group{Name: name})
|
||||
return nil
|
||||
})
|
||||
m.awaitingGroup = false
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.groupNewName, cmd = m.groupNewName.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "n":
|
||||
m.awaitingGroup = true
|
||||
m.groupNewName = textinput.New()
|
||||
m.groupNewName.Placeholder = "имя группы, например team-billing"
|
||||
m.groupNewName.Focus()
|
||||
m.err = ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
sel, ok := m.groupsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedGroup = sel.id
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.groupsList, cmd = m.groupsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewGroups() string {
|
||||
if m.awaitingGroup {
|
||||
return borderStyle.Render("Новая группа:\n\n" + m.groupNewName.View() + "\n\n[enter] создать [esc] отмена")
|
||||
}
|
||||
return borderStyle.Render(m.groupsList.View())
|
||||
}
|
||||
|
||||
// --- hosts within a group ---
|
||||
|
||||
func (m *Model) refreshHostsList() {
|
||||
st := m.loadState()
|
||||
g := st.FindGroup(m.selectedGroup)
|
||||
var items []list.Item
|
||||
if g != nil {
|
||||
for _, h := range g.Hosts {
|
||||
items = append(items, simpleItem{
|
||||
id: h.Name,
|
||||
title: h.Name,
|
||||
desc: fmt.Sprintf("%s:%d %s", h.Address, h.Port, h.Description),
|
||||
})
|
||||
}
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
|
||||
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [d]удалить [esc]назад", m.selectedGroup)
|
||||
l.SetShowStatusBar(false)
|
||||
m.hostsList = l
|
||||
}
|
||||
|
||||
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
|
||||
// every active user whose groups include groupName, reflecting the group's
|
||||
// current host list — the only place server-side enforcement is updated
|
||||
// when the allowed-host list changes over time.
|
||||
func resyncGroupUsers(st *model.State, groupName string) error {
|
||||
for i := range st.Users {
|
||||
u := &st.Users[i]
|
||||
if u.PublicKey == "" || u.Status != model.StatusActive {
|
||||
continue
|
||||
}
|
||||
inGroup := false
|
||||
for _, g := range u.Groups {
|
||||
if g == groupName {
|
||||
inGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedHosts(u)); err != nil {
|
||||
return fmt.Errorf("resync %s: %w", u.Username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
return m, nil
|
||||
case "n":
|
||||
m.screen = scrHostNew
|
||||
for i := range m.hostForm {
|
||||
m.hostForm[i] = textinput.New()
|
||||
}
|
||||
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1"
|
||||
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
|
||||
m.hostForm[2].Placeholder = "порт, например 5432"
|
||||
m.hostForm[3].Placeholder = "описание (необязательно)"
|
||||
m.hostFormFocus = 0
|
||||
m.hostForm[0].Focus()
|
||||
return m, nil
|
||||
case "d":
|
||||
sel, ok := m.hostsList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
groupName := m.selectedGroup
|
||||
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", sel.id, groupName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for i := range g.Hosts {
|
||||
if g.Hosts[i].Name == sel.id {
|
||||
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return resyncGroupUsers(st, groupName)
|
||||
})
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostsList, cmd = m.hostsList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewGroupHosts() string {
|
||||
return borderStyle.Render(m.hostsList.View())
|
||||
}
|
||||
|
||||
func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrGroupHosts
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.hostForm[m.hostFormFocus].Blur()
|
||||
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
|
||||
m.hostForm[m.hostFormFocus].Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.hostForm[0].Value()
|
||||
addr := m.hostForm[1].Value()
|
||||
portStr := m.hostForm[2].Value()
|
||||
desc := m.hostForm[3].Value()
|
||||
if name == "" || addr == "" || portStr == "" {
|
||||
m.err = "имя, адрес и порт обязательны"
|
||||
return m, nil
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
m.err = "порт должен быть числом от 1 до 65535"
|
||||
return m, nil
|
||||
}
|
||||
groupName := m.selectedGroup
|
||||
m.err = ""
|
||||
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
|
||||
g := st.FindGroup(groupName)
|
||||
if g == nil {
|
||||
return fmt.Errorf("group %s not found", groupName)
|
||||
}
|
||||
for _, h := range g.Hosts {
|
||||
if h.Name == name {
|
||||
return fmt.Errorf("host %q already exists in group %q", name, groupName)
|
||||
}
|
||||
}
|
||||
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr, Port: port, Description: desc})
|
||||
return resyncGroupUsers(st, groupName)
|
||||
})
|
||||
m.screen = scrGroupHosts
|
||||
m.refreshHostsList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewHostNew() string {
|
||||
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
|
||||
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View(), m.hostForm[3].View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ui
|
||||
|
||||
// simpleItem is a minimal bubbles/list.DefaultItem implementation reused
|
||||
// across every list screen (users, groups, hosts, export selection) so we
|
||||
// don't need a bespoke type per data kind.
|
||||
type simpleItem struct {
|
||||
id, title, desc string
|
||||
}
|
||||
|
||||
func (i simpleItem) Title() string { return i.title }
|
||||
func (i simpleItem) Description() string { return i.desc }
|
||||
func (i simpleItem) FilterValue() string { return i.title }
|
||||
@@ -0,0 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func newMenuList() list.Model {
|
||||
items := []list.Item{
|
||||
simpleItem{id: "users", title: "Пользователи", desc: "Создать, заблокировать, удалить учётные записи"},
|
||||
simpleItem{id: "groups", title: "Группы и хосты", desc: "Кому какие хосты разрешены для прыжка/форвардинга"},
|
||||
simpleItem{id: "export", title: "Экспорт архивов", desc: "Сгенерировать ключи и .7z архивы для доставки пользователям"},
|
||||
simpleItem{id: "audit", title: "Аудит / кто в сети", desc: "Активные сессии и журнал подключений"},
|
||||
simpleItem{id: "quit", title: "Выход", desc: ""},
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = "ssh-access-tui"
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "enter":
|
||||
sel, ok := m.menu.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.err, m.status = "", ""
|
||||
switch sel.id {
|
||||
case "users":
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
case "groups":
|
||||
m.screen = scrGroups
|
||||
m.refreshGroupsList()
|
||||
case "export":
|
||||
m.screen = scrExportSelect
|
||||
m.initExportSelect()
|
||||
case "audit":
|
||||
m.screen = scrAudit
|
||||
m.refreshAudit()
|
||||
case "quit":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.menu, cmd = m.menu.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewMenu() string {
|
||||
return borderStyle.Render(m.menu.View())
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/sysadm"
|
||||
)
|
||||
|
||||
func (m *Model) refreshUsersList() {
|
||||
st := m.loadState()
|
||||
items := make([]list.Item, 0, len(st.Users))
|
||||
for _, u := range st.Users {
|
||||
fp := u.Fingerprint
|
||||
if fp == "" {
|
||||
fp = "ключ не выдан"
|
||||
}
|
||||
desc := fmt.Sprintf("%s | группы: %v | %s", u.Status, u.Groups, fp)
|
||||
items = append(items, simpleItem{id: u.Username, title: u.Username, desc: desc})
|
||||
}
|
||||
l := list.New(items, list.NewDefaultDelegate(), m.usersList.Width(), m.usersList.Height())
|
||||
l.Title = "Пользователи [n]новый [l]заблокировать [u]разблокировать [d]удалить [esc]назад"
|
||||
l.SetShowStatusBar(false)
|
||||
m.usersList = l
|
||||
}
|
||||
|
||||
func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrMenu
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "n":
|
||||
m.screen = scrUserNew
|
||||
m.userNewName = textinput.New()
|
||||
m.userNewName.Placeholder = "имя пользователя (латиница, как логин Linux)"
|
||||
m.userNewName.Focus()
|
||||
m.userNewComment = textinput.New()
|
||||
m.userNewComment.Placeholder = "комментарий (например ФИО) — необязательно"
|
||||
m.userNewFocus = 0
|
||||
m.err, m.status = "", ""
|
||||
return m, nil
|
||||
case "l", "u", "d":
|
||||
sel, ok := m.usersList.SelectedItem().(simpleItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
username := sel.id
|
||||
switch key.String() {
|
||||
case "l":
|
||||
if err := sysadm.LockSystemUser(username); err != nil {
|
||||
m.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
_ = sysadm.DisableAuthorizedKeys(username)
|
||||
m.mutate("заблокирован пользователь "+username, func(st *model.State) error {
|
||||
u := st.FindUser(username)
|
||||
if u == nil {
|
||||
return fmt.Errorf("user %s not found in state", username)
|
||||
}
|
||||
now := time.Now()
|
||||
u.Status = model.StatusLocked
|
||||
u.LockedAt = &now
|
||||
return nil
|
||||
})
|
||||
m.logAudit(username, "admin", "locked")
|
||||
case "u":
|
||||
if err := sysadm.UnlockSystemUser(username); err != nil {
|
||||
m.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("разблокирован пользователь "+username, func(st *model.State) error {
|
||||
u := st.FindUser(username)
|
||||
if u == nil {
|
||||
return fmt.Errorf("user %s not found in state", username)
|
||||
}
|
||||
u.Status = model.StatusActive
|
||||
u.LockedAt = nil
|
||||
if u.PublicKey != "" {
|
||||
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedHosts(u)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
m.logAudit(username, "admin", "unlocked")
|
||||
case "d":
|
||||
if err := sysadm.DeleteSystemUser(username); err != nil {
|
||||
m.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("удалён пользователь "+username, func(st *model.State) error {
|
||||
for i := range st.Users {
|
||||
if st.Users[i].Username == username {
|
||||
st.Users = append(st.Users[:i], st.Users[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
m.logAudit(username, "admin", "deleted")
|
||||
}
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.usersList, cmd = m.usersList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) viewUsers() string {
|
||||
return borderStyle.Render(m.usersList.View())
|
||||
}
|
||||
|
||||
// --- new user wizard: step 1, username/comment ---
|
||||
|
||||
func (m Model) updateUserNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrUsers
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.userNewFocus = (m.userNewFocus + 1) % 2
|
||||
m.applyUserNewFocus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
name := m.userNewName.Value()
|
||||
if name == "" {
|
||||
m.err = "имя пользователя не может быть пустым"
|
||||
return m, nil
|
||||
}
|
||||
if sysadm.UserExists(name) {
|
||||
m.err = fmt.Sprintf("системный пользователь %q уже существует", name)
|
||||
return m, nil
|
||||
}
|
||||
st := m.loadState()
|
||||
if st.FindUser(name) != nil {
|
||||
m.err = fmt.Sprintf("пользователь %q уже есть в состоянии tool'а", name)
|
||||
return m, nil
|
||||
}
|
||||
m.err = ""
|
||||
m.userNewGroupsSel = map[string]bool{}
|
||||
m.userNewGroupsOrder = nil
|
||||
for _, g := range st.Groups {
|
||||
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
|
||||
}
|
||||
m.userNewCursor = 0
|
||||
m.screen = scrUserNewGroups
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
if m.userNewFocus == 0 {
|
||||
m.userNewName, cmd = m.userNewName.Update(msg)
|
||||
} else {
|
||||
m.userNewComment, cmd = m.userNewComment.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) applyUserNewFocus() {
|
||||
if m.userNewFocus == 0 {
|
||||
m.userNewName.Focus()
|
||||
m.userNewComment.Blur()
|
||||
} else {
|
||||
m.userNewName.Blur()
|
||||
m.userNewComment.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewUserNew() string {
|
||||
body := fmt.Sprintf("Новый пользователь\n\nИмя: %s\nКомментарий: %s\n\n[tab] переключить поле [enter] далее: выбор групп [esc] отмена",
|
||||
m.userNewName.View(), m.userNewComment.View())
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
|
||||
// --- new user wizard: step 2, group checkboxes ---
|
||||
|
||||
func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.screen = scrUserNew
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if m.userNewCursor > 0 {
|
||||
m.userNewCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.userNewCursor < len(m.userNewGroupsOrder)-1 {
|
||||
m.userNewCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
if len(m.userNewGroupsOrder) > 0 {
|
||||
g := m.userNewGroupsOrder[m.userNewCursor]
|
||||
m.userNewGroupsSel[g] = !m.userNewGroupsSel[g]
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
username := m.userNewName.Value()
|
||||
comment := m.userNewComment.Value()
|
||||
var groups []string
|
||||
for _, g := range m.userNewGroupsOrder {
|
||||
if m.userNewGroupsSel[g] {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
}
|
||||
if err := sysadm.CreateSystemUser(username); err != nil {
|
||||
m.err = err.Error()
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
m.mutate("создан пользователь "+username, func(st *model.State) error {
|
||||
st.Users = append(st.Users, model.User{
|
||||
Username: username,
|
||||
Groups: groups,
|
||||
Status: model.StatusActive,
|
||||
Comment: comment,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
m.logAudit(username, "admin", "created, groups="+fmt.Sprint(groups))
|
||||
m.status = fmt.Sprintf("пользователь %s создан. Ключ ещё не выдан — сгенерируйте его в разделе «Экспорт архивов».", username)
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) viewUserNewGroups() string {
|
||||
body := "Группы доступа для нового пользователя (union хостов всех выбранных групп)\n\n"
|
||||
if len(m.userNewGroupsOrder) == 0 {
|
||||
body += warnStyle.Render("Группы ещё не созданы — сначала создайте хотя бы одну в разделе «Группы и хосты».") + "\n"
|
||||
}
|
||||
for i, g := range m.userNewGroupsOrder {
|
||||
cursor := " "
|
||||
if i == m.userNewCursor {
|
||||
cursor = "> "
|
||||
}
|
||||
check := "[ ]"
|
||||
if m.userNewGroupsSel[g] {
|
||||
check = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
|
||||
}
|
||||
body += "\n[space] отметить [enter] создать пользователя [esc] назад"
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
Reference in new issue
Block a user