293 lines
7.9 KiB
Go
Raw Permalink Normal View History

2026-03-12 20:51:38 -07:00
package system
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type SystemUser struct {
Username string `json:"username"`
UID int `json:"uid"`
GID int `json:"gid"`
Comment string `json:"comment"`
HomeDir string `json:"home_dir"`
Shell string `json:"shell"`
}
type QuotaInfo struct {
Username string `json:"username"`
UsedBytes uint64 `json:"used_bytes"`
UsedHuman string `json:"used_human"`
HomeDir string `json:"home_dir"`
}
// ── Protected accounts ──────────────────────────────────────────────
var protectedUsers = map[string]bool{
"root": true,
"autarch": true,
}
// ── User Management ─────────────────────────────────────────────────
// ListUsers reads /etc/passwd and returns all users with UID >= 1000 and < 65534.
func ListUsers() ([]SystemUser, error) {
f, err := os.Open("/etc/passwd")
if err != nil {
return nil, fmt.Errorf("opening /etc/passwd: %w", err)
}
defer f.Close()
var users []SystemUser
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
continue
}
// Format: username:x:uid:gid:comment:home:shell
fields := strings.Split(line, ":")
if len(fields) < 7 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
// Only normal user accounts (UID 1000-65533)
if uid < 1000 || uid >= 65534 {
continue
}
gid, _ := strconv.Atoi(fields[3])
users = append(users, SystemUser{
Username: fields[0],
UID: uid,
GID: gid,
Comment: fields[4],
HomeDir: fields[5],
Shell: fields[6],
})
}
return users, scanner.Err()
}
// CreateUser creates a new system user with the given username, password, and shell.
func CreateUser(username, password, shell string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
if shell == "" {
shell = "/bin/bash"
}
// Sanitize: only allow alphanumeric, underscore, hyphen, and dot
for _, c := range username {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
return fmt.Errorf("invalid character %q in username", c)
}
}
if len(username) > 32 {
return fmt.Errorf("username too long (max 32 characters)")
}
// Verify the shell exists
if _, err := os.Stat(shell); err != nil {
return fmt.Errorf("shell %q does not exist: %w", shell, err)
}
// Create the user
out, err := exec.Command("useradd", "--create-home", "--shell", shell, username).CombinedOutput()
if err != nil {
return fmt.Errorf("useradd failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
// Set the password via chpasswd
if err := setPasswordViaChpasswd(username, password); err != nil {
// Attempt cleanup on password failure
exec.Command("userdel", "--remove", username).Run()
return fmt.Errorf("user created but password set failed (user removed): %w", err)
}
return nil
}
// DeleteUser removes a system user and their home directory.
func DeleteUser(username string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if protectedUsers[username] {
return fmt.Errorf("cannot delete protected account %q", username)
}
// Verify the user actually exists before attempting deletion
_, err := exec.Command("id", username).CombinedOutput()
if err != nil {
return fmt.Errorf("user %q does not exist", username)
}
// Kill any running processes owned by the user (best effort)
exec.Command("pkill", "-u", username).Run()
out, err := exec.Command("userdel", "--remove", username).CombinedOutput()
if err != nil {
return fmt.Errorf("userdel failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// SetPassword changes the password for an existing user.
func SetPassword(username, password string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
// Verify user exists
_, err := exec.Command("id", username).CombinedOutput()
if err != nil {
return fmt.Errorf("user %q does not exist", username)
}
return setPasswordViaChpasswd(username, password)
}
// setPasswordViaChpasswd pipes "user:password" to chpasswd.
func setPasswordViaChpasswd(username, password string) error {
cmd := exec.Command("chpasswd")
cmd.Stdin = strings.NewReader(fmt.Sprintf("%s:%s", username, password))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("chpasswd failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// AddSSHKey appends a public key to the user's ~/.ssh/authorized_keys file.
func AddSSHKey(username, pubkey string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if pubkey == "" {
return fmt.Errorf("public key is required")
}
// Basic validation: SSH keys should start with a recognized prefix
pubkey = strings.TrimSpace(pubkey)
validPrefixes := []string{"ssh-rsa", "ssh-ed25519", "ssh-dss", "ecdsa-sha2-", "sk-ssh-ed25519", "sk-ecdsa-sha2-"}
valid := false
for _, prefix := range validPrefixes {
if strings.HasPrefix(pubkey, prefix) {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid SSH public key format")
}
// Look up the user's home directory from /etc/passwd
homeDir, err := getUserHome(username)
if err != nil {
return err
}
sshDir := filepath.Join(homeDir, ".ssh")
authKeysPath := filepath.Join(sshDir, "authorized_keys")
// Create .ssh directory if it doesn't exist
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("creating .ssh directory: %w", err)
}
// Check for duplicate keys
if existing, err := os.ReadFile(authKeysPath); err == nil {
for _, line := range strings.Split(string(existing), "\n") {
if strings.TrimSpace(line) == pubkey {
return fmt.Errorf("SSH key already exists in authorized_keys")
}
}
}
// Append the key
f, err := os.OpenFile(authKeysPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("opening authorized_keys: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s\n", pubkey); err != nil {
return fmt.Errorf("writing authorized_keys: %w", err)
}
// Fix ownership: chown user:user .ssh and authorized_keys
exec.Command("chown", "-R", username+":"+username, sshDir).Run()
return nil
}
// GetUserQuota returns disk usage for a user's home directory.
func GetUserQuota(username string) (QuotaInfo, error) {
info := QuotaInfo{Username: username}
homeDir, err := getUserHome(username)
if err != nil {
return info, err
}
info.HomeDir = homeDir
// Use du -sb for total bytes used in home directory
out, err := exec.Command("du", "-sb", homeDir).CombinedOutput()
if err != nil {
return info, fmt.Errorf("du failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) >= 1 {
info.UsedBytes, _ = strconv.ParseUint(fields[0], 10, 64)
}
info.UsedHuman = humanBytes(info.UsedBytes)
return info, nil
}
// getUserHome looks up a user's home directory from /etc/passwd.
func getUserHome(username string) (string, error) {
f, err := os.Open("/etc/passwd")
if err != nil {
return "", fmt.Errorf("opening /etc/passwd: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), ":")
if len(fields) >= 6 && fields[0] == username {
return fields[5], nil
}
}
return "", fmt.Errorf("user %q not found in /etc/passwd", username)
}