293 lines
7.9 KiB
Go
293 lines
7.9 KiB
Go
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)
|
|
}
|