No One Can Stop Me Now

This commit is contained in:
DigiJ
2026-03-13 23:48:47 -07:00
parent 4d3570781e
commit 1a138a2bd0
428 changed files with 519668 additions and 259 deletions

View File

@@ -0,0 +1,243 @@
package system
import (
"fmt"
"os/exec"
"regexp"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type UFWRule struct {
Direction string `json:"direction"` // "in" or "out"
Protocol string `json:"protocol"` // "tcp", "udp", or "" for both
Port string `json:"port"` // e.g. "22", "80:90"
Source string `json:"source"` // IP/CIDR or "any"/"Anywhere"
Action string `json:"action"` // "allow", "deny", "reject", "limit"
Comment string `json:"comment"`
}
// ── Firewall (UFW) ──────────────────────────────────────────────────
// Status parses `ufw status verbose` and returns the enable state, parsed rules,
// and the raw command output.
func FirewallStatus() (enabled bool, rules []UFWRule, raw string, err error) {
out, cmdErr := exec.Command("ufw", "status", "verbose").CombinedOutput()
raw = string(out)
if cmdErr != nil {
// ufw may return non-zero when inactive; check output
if strings.Contains(raw, "Status: inactive") {
return false, nil, raw, nil
}
err = fmt.Errorf("ufw status failed: %w (%s)", cmdErr, raw)
return
}
enabled = strings.Contains(raw, "Status: active")
// Parse rule lines. After the header block, rules look like:
// 22/tcp ALLOW IN Anywhere # SSH
// 80/tcp ALLOW IN 192.168.1.0/24 # Web
// We find lines after the "---" separator.
lines := strings.Split(raw, "\n")
pastSeparator := false
// Match: port/proto (or port) ACTION DIRECTION source # optional comment
ruleRegex := regexp.MustCompile(
`^(\S+)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT|FWD)?\s*(.+?)(?:\s+#\s*(.*))?$`,
)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "---") {
pastSeparator = true
continue
}
if !pastSeparator || trimmed == "" {
continue
}
matches := ruleRegex.FindStringSubmatch(trimmed)
if matches == nil {
continue
}
portProto := matches[1]
action := strings.ToLower(matches[2])
direction := strings.ToLower(matches[3])
source := strings.TrimSpace(matches[4])
comment := strings.TrimSpace(matches[5])
if direction == "" {
direction = "in"
}
// Split port/protocol
var port, proto string
if strings.Contains(portProto, "/") {
parts := strings.SplitN(portProto, "/", 2)
port = parts[0]
proto = parts[1]
} else {
port = portProto
}
// Normalize source
if source == "Anywhere" || source == "Anywhere (v6)" {
source = "any"
}
rules = append(rules, UFWRule{
Direction: direction,
Protocol: proto,
Port: port,
Source: source,
Action: action,
Comment: comment,
})
}
return enabled, rules, raw, nil
}
// FirewallEnable enables UFW with --force to skip the interactive prompt.
func FirewallEnable() error {
out, err := exec.Command("ufw", "--force", "enable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw enable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDisable disables UFW.
func FirewallDisable() error {
out, err := exec.Command("ufw", "disable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw disable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallAddRule constructs and executes a ufw command from the given rule struct.
func FirewallAddRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
if rule.Protocol == "" {
rule.Protocol = "tcp"
}
if rule.Source == "" || rule.Source == "any" {
rule.Source = ""
}
// Validate action
switch rule.Action {
case "allow", "deny", "reject", "limit":
// valid
default:
return fmt.Errorf("invalid action %q: must be allow, deny, reject, or limit", rule.Action)
}
// Validate protocol
switch rule.Protocol {
case "tcp", "udp":
// valid
default:
return fmt.Errorf("invalid protocol %q: must be tcp or udp", rule.Protocol)
}
// Validate direction
if rule.Direction != "" && rule.Direction != "in" && rule.Direction != "out" {
return fmt.Errorf("invalid direction %q: must be in or out", rule.Direction)
}
// Build argument list
args := []string{rule.Action}
// Direction
if rule.Direction == "out" {
args = append(args, "out")
}
// Source filter
if rule.Source != "" {
args = append(args, "from", rule.Source)
}
args = append(args, "to", "any", "port", rule.Port, "proto", rule.Protocol)
// Comment
if rule.Comment != "" {
args = append(args, "comment", rule.Comment)
}
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw add rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDeleteRule constructs and executes a ufw delete command for the given rule.
func FirewallDeleteRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
// Build the rule specification that matches what was added
args := []string{"delete", rule.Action}
if rule.Direction == "out" {
args = append(args, "out")
}
if rule.Source != "" && rule.Source != "any" {
args = append(args, "from", rule.Source)
}
portSpec := rule.Port
if rule.Protocol != "" {
portSpec = rule.Port + "/" + rule.Protocol
}
args = append(args, portSpec)
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw delete rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallSetDefaults sets the default incoming and outgoing policies.
// Valid values are "allow", "deny", "reject".
func FirewallSetDefaults(incoming, outgoing string) error {
validPolicy := map[string]bool{"allow": true, "deny": true, "reject": true}
if incoming != "" {
if !validPolicy[incoming] {
return fmt.Errorf("invalid incoming policy %q: must be allow, deny, or reject", incoming)
}
out, err := exec.Command("ufw", "default", incoming, "incoming").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default incoming policy failed: %w (%s)", err, string(out))
}
}
if outgoing != "" {
if !validPolicy[outgoing] {
return fmt.Errorf("invalid outgoing policy %q: must be allow, deny, or reject", outgoing)
}
out, err := exec.Command("ufw", "default", outgoing, "outgoing").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default outgoing policy failed: %w (%s)", err, string(out))
}
}
return nil
}

View File

@@ -0,0 +1,511 @@
package system
import (
"bufio"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// ── Types ───────────────────────────────────────────────────────────
type CoreUsage struct {
Core int `json:"core"`
User float64 `json:"user"`
System float64 `json:"system"`
Idle float64 `json:"idle"`
IOWait float64 `json:"iowait"`
Percent float64 `json:"percent"`
}
type CPUInfo struct {
Overall float64 `json:"overall"`
Idle float64 `json:"idle"`
Cores []CoreUsage `json:"cores"`
}
type MemInfo struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
AvailableBytes uint64 `json:"available_bytes"`
BuffersBytes uint64 `json:"buffers_bytes"`
CachedBytes uint64 `json:"cached_bytes"`
SwapTotalBytes uint64 `json:"swap_total_bytes"`
SwapUsedBytes uint64 `json:"swap_used_bytes"`
SwapFreeBytes uint64 `json:"swap_free_bytes"`
Total string `json:"total"`
Used string `json:"used"`
Free string `json:"free"`
Available string `json:"available"`
Buffers string `json:"buffers"`
Cached string `json:"cached"`
SwapTotal string `json:"swap_total"`
SwapUsed string `json:"swap_used"`
SwapFree string `json:"swap_free"`
}
type DiskInfo struct {
Filesystem string `json:"filesystem"`
Size string `json:"size"`
Used string `json:"used"`
Available string `json:"available"`
UsePercent string `json:"use_percent"`
MountPoint string `json:"mount_point"`
}
type NetInfo struct {
Interface string `json:"interface"`
RxBytes uint64 `json:"rx_bytes"`
RxPackets uint64 `json:"rx_packets"`
RxErrors uint64 `json:"rx_errors"`
RxDropped uint64 `json:"rx_dropped"`
TxBytes uint64 `json:"tx_bytes"`
TxPackets uint64 `json:"tx_packets"`
TxErrors uint64 `json:"tx_errors"`
TxDropped uint64 `json:"tx_dropped"`
RxHuman string `json:"rx_human"`
TxHuman string `json:"tx_human"`
}
type UptimeInfo struct {
Seconds float64 `json:"seconds"`
IdleSeconds float64 `json:"idle_seconds"`
HumanReadable string `json:"human_readable"`
}
type LoadInfo struct {
Load1 float64 `json:"load_1"`
Load5 float64 `json:"load_5"`
Load15 float64 `json:"load_15"`
RunningProcs int `json:"running_procs"`
TotalProcs int `json:"total_procs"`
}
type ProcessInfo struct {
PID int `json:"pid"`
User string `json:"user"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
RSS int64 `json:"rss"`
Command string `json:"command"`
}
// ── CPU ─────────────────────────────────────────────────────────────
// readCPUStats reads /proc/stat and returns a map of cpu label to field slices.
func readCPUStats() (map[string][]uint64, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return nil, fmt.Errorf("opening /proc/stat: %w", err)
}
defer f.Close()
result := make(map[string][]uint64)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
label := fields[0]
var vals []uint64
for _, field := range fields[1:] {
v, _ := strconv.ParseUint(field, 10, 64)
vals = append(vals, v)
}
result[label] = vals
}
return result, scanner.Err()
}
// parseCPUFields converts raw jiffie counts into a CoreUsage.
// Fields: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice
func parseCPUFields(core int, before, after []uint64) CoreUsage {
cu := CoreUsage{Core: core}
if len(before) < 5 || len(after) < 5 {
return cu
}
// Sum all fields for total jiffies
var totalBefore, totalAfter uint64
for _, v := range before {
totalBefore += v
}
for _, v := range after {
totalAfter += v
}
totalDelta := float64(totalAfter - totalBefore)
if totalDelta == 0 {
return cu
}
userDelta := float64((after[0] + after[1]) - (before[0] + before[1]))
systemDelta := float64(after[2] - before[2])
idleDelta := float64(after[3] - before[3])
var iowaitDelta float64
if len(after) > 4 && len(before) > 4 {
iowaitDelta = float64(after[4] - before[4])
}
cu.User = userDelta / totalDelta * 100
cu.System = systemDelta / totalDelta * 100
cu.Idle = idleDelta / totalDelta * 100
cu.IOWait = iowaitDelta / totalDelta * 100
cu.Percent = 100 - cu.Idle
return cu
}
// GetCPUUsage samples /proc/stat twice with a brief interval to compute usage.
func GetCPUUsage() (CPUInfo, error) {
info := CPUInfo{}
before, err := readCPUStats()
if err != nil {
return info, err
}
time.Sleep(250 * time.Millisecond)
after, err := readCPUStats()
if err != nil {
return info, err
}
// Overall CPU (the "cpu" aggregate line)
if bv, ok := before["cpu"]; ok {
if av, ok := after["cpu"]; ok {
overall := parseCPUFields(-1, bv, av)
info.Overall = overall.Percent
info.Idle = overall.Idle
}
}
// Per-core
for i := 0; ; i++ {
label := fmt.Sprintf("cpu%d", i)
bv, ok1 := before[label]
av, ok2 := after[label]
if !ok1 || !ok2 {
break
}
info.Cores = append(info.Cores, parseCPUFields(i, bv, av))
}
return info, nil
}
// ── Memory ──────────────────────────────────────────────────────────
func GetMemory() (MemInfo, error) {
info := MemInfo{}
f, err := os.Open("/proc/meminfo")
if err != nil {
return info, fmt.Errorf("opening /proc/meminfo: %w", err)
}
defer f.Close()
vals := make(map[string]uint64)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
valStr := strings.TrimSpace(parts[1])
valStr = strings.TrimSuffix(valStr, " kB")
valStr = strings.TrimSpace(valStr)
v, err := strconv.ParseUint(valStr, 10, 64)
if err != nil {
continue
}
vals[key] = v * 1024 // convert kB to bytes
}
if err := scanner.Err(); err != nil {
return info, err
}
info.TotalBytes = vals["MemTotal"]
info.FreeBytes = vals["MemFree"]
info.AvailableBytes = vals["MemAvailable"]
info.BuffersBytes = vals["Buffers"]
info.CachedBytes = vals["Cached"]
info.SwapTotalBytes = vals["SwapTotal"]
info.SwapFreeBytes = vals["SwapFree"]
info.SwapUsedBytes = info.SwapTotalBytes - info.SwapFreeBytes
info.UsedBytes = info.TotalBytes - info.FreeBytes - info.BuffersBytes - info.CachedBytes
if info.UsedBytes > info.TotalBytes {
// Overflow guard: if buffers+cached > total-free, use simpler calculation
info.UsedBytes = info.TotalBytes - info.AvailableBytes
}
info.Total = humanBytes(info.TotalBytes)
info.Used = humanBytes(info.UsedBytes)
info.Free = humanBytes(info.FreeBytes)
info.Available = humanBytes(info.AvailableBytes)
info.Buffers = humanBytes(info.BuffersBytes)
info.Cached = humanBytes(info.CachedBytes)
info.SwapTotal = humanBytes(info.SwapTotalBytes)
info.SwapUsed = humanBytes(info.SwapUsedBytes)
info.SwapFree = humanBytes(info.SwapFreeBytes)
return info, nil
}
// ── Disk ────────────────────────────────────────────────────────────
func GetDisk() ([]DiskInfo, error) {
// Try with filesystem type filters first for real block devices
out, err := exec.Command("df", "-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=ext3").CombinedOutput()
if err != nil {
// Fallback: exclude pseudo filesystems
out, err = exec.Command("df", "-h", "--exclude-type=tmpfs", "--exclude-type=devtmpfs", "--exclude-type=squashfs").CombinedOutput()
if err != nil {
// Last resort: all filesystems
out, err = exec.Command("df", "-h").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("df command failed: %w (%s)", err, string(out))
}
}
}
var disks []DiskInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 || strings.TrimSpace(line) == "" {
continue // skip header
}
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
disks = append(disks, DiskInfo{
Filesystem: fields[0],
Size: fields[1],
Used: fields[2],
Available: fields[3],
UsePercent: fields[4],
MountPoint: fields[5],
})
}
return disks, nil
}
// ── Network ─────────────────────────────────────────────────────────
func GetNetwork() ([]NetInfo, error) {
f, err := os.Open("/proc/net/dev")
if err != nil {
return nil, fmt.Errorf("opening /proc/net/dev: %w", err)
}
defer f.Close()
var interfaces []NetInfo
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if lineNum <= 2 {
continue // skip the two header lines
}
line := scanner.Text()
// Format: " iface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets tx_errs tx_drop ..."
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
iface := strings.TrimSpace(line[:colonIdx])
rest := strings.TrimSpace(line[colonIdx+1:])
fields := strings.Fields(rest)
if len(fields) < 10 {
continue
}
rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
rxErrors, _ := strconv.ParseUint(fields[2], 10, 64)
rxDropped, _ := strconv.ParseUint(fields[3], 10, 64)
txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
txErrors, _ := strconv.ParseUint(fields[10], 10, 64)
txDropped, _ := strconv.ParseUint(fields[11], 10, 64)
interfaces = append(interfaces, NetInfo{
Interface: iface,
RxBytes: rxBytes,
RxPackets: rxPackets,
RxErrors: rxErrors,
RxDropped: rxDropped,
TxBytes: txBytes,
TxPackets: txPackets,
TxErrors: txErrors,
TxDropped: txDropped,
RxHuman: humanBytes(rxBytes),
TxHuman: humanBytes(txBytes),
})
}
return interfaces, scanner.Err()
}
// ── Uptime ──────────────────────────────────────────────────────────
func GetUptime() (UptimeInfo, error) {
info := UptimeInfo{}
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return info, fmt.Errorf("reading /proc/uptime: %w", err)
}
fields := strings.Fields(strings.TrimSpace(string(data)))
if len(fields) < 2 {
return info, fmt.Errorf("unexpected /proc/uptime format")
}
info.Seconds, _ = strconv.ParseFloat(fields[0], 64)
info.IdleSeconds, _ = strconv.ParseFloat(fields[1], 64)
// Build human readable string
totalSec := int(info.Seconds)
days := totalSec / 86400
hours := (totalSec % 86400) / 3600
minutes := (totalSec % 3600) / 60
seconds := totalSec % 60
parts := []string{}
if days > 0 {
parts = append(parts, fmt.Sprintf("%d day%s", days, plural(days)))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%d hour%s", hours, plural(hours)))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, plural(minutes)))
}
if len(parts) == 0 || (days == 0 && hours == 0 && minutes == 0) {
parts = append(parts, fmt.Sprintf("%d second%s", seconds, plural(seconds)))
}
info.HumanReadable = strings.Join(parts, ", ")
return info, nil
}
// ── Load Average ────────────────────────────────────────────────────
func GetLoadAvg() (LoadInfo, error) {
info := LoadInfo{}
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return info, fmt.Errorf("reading /proc/loadavg: %w", err)
}
fields := strings.Fields(strings.TrimSpace(string(data)))
if len(fields) < 4 {
return info, fmt.Errorf("unexpected /proc/loadavg format")
}
info.Load1, _ = strconv.ParseFloat(fields[0], 64)
info.Load5, _ = strconv.ParseFloat(fields[1], 64)
info.Load15, _ = strconv.ParseFloat(fields[2], 64)
// fields[3] is "running/total" format
procParts := strings.SplitN(fields[3], "/", 2)
if len(procParts) == 2 {
info.RunningProcs, _ = strconv.Atoi(procParts[0])
info.TotalProcs, _ = strconv.Atoi(procParts[1])
}
return info, nil
}
// ── Top Processes ───────────────────────────────────────────────────
func GetTopProcesses(n int) ([]ProcessInfo, error) {
if n <= 0 {
n = 10
}
// ps aux --sort=-%mem gives us processes sorted by memory usage descending
out, err := exec.Command("ps", "aux", "--sort=-%mem").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ps command failed: %w (%s)", err, string(out))
}
var procs []ProcessInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 {
continue // skip header
}
if len(procs) >= n {
break
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
pid, _ := strconv.Atoi(fields[1])
cpu, _ := strconv.ParseFloat(fields[2], 64)
mem, _ := strconv.ParseFloat(fields[3], 64)
rss, _ := strconv.ParseInt(fields[5], 10, 64)
// Command is everything from field 10 onward (may contain spaces)
command := strings.Join(fields[10:], " ")
procs = append(procs, ProcessInfo{
PID: pid,
User: fields[0],
CPU: cpu,
Mem: mem,
RSS: rss,
Command: command,
})
}
return procs, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
if exp >= len(suffixes) {
exp = len(suffixes) - 1
}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), suffixes[exp])
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

View File

@@ -0,0 +1,213 @@
package system
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"` // installed size in bytes
SizeStr string `json:"size_str"` // human-readable
Status string `json:"status,omitempty"`
}
// ── APT Operations ──────────────────────────────────────────────────
// PackageUpdate runs `apt-get update` to refresh the package index.
func PackageUpdate() (string, error) {
out, err := exec.Command("apt-get", "update", "-qq").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get update failed: %w (%s)", err, output)
}
return output, nil
}
// PackageInstall installs one or more packages with apt-get install -y.
// Package names are passed as separate arguments to avoid shell injection.
func PackageInstall(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"install", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get install failed: %w (%s)", err, output)
}
return output, nil
}
// PackageRemove removes one or more packages with apt-get remove -y.
func PackageRemove(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"remove", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get remove failed: %w (%s)", err, output)
}
return output, nil
}
// PackageListInstalled returns all installed packages via dpkg-query.
func PackageListInstalled() ([]PackageInfo, error) {
// dpkg-query format: name\tversion\tinstalled-size (in kB)
out, err := exec.Command(
"dpkg-query",
"--show",
"--showformat=${Package}\t${Version}\t${Installed-Size}\n",
).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("dpkg-query failed: %w (%s)", err, string(out))
}
var packages []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 3 {
continue
}
// Installed-Size from dpkg is in kibibytes
sizeKB, _ := strconv.ParseInt(strings.TrimSpace(fields[2]), 10, 64)
sizeBytes := sizeKB * 1024
packages = append(packages, PackageInfo{
Name: fields[0],
Version: fields[1],
Size: sizeBytes,
SizeStr: humanBytes(uint64(sizeBytes)),
})
}
return packages, nil
}
// PackageIsInstalled checks if a single package is installed using dpkg -l.
func PackageIsInstalled(pkg string) bool {
if err := validatePackageName(pkg); err != nil {
return false
}
out, err := exec.Command("dpkg", "-l", pkg).CombinedOutput()
if err != nil {
return false
}
// dpkg -l output has lines starting with "ii" for installed packages
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "ii" && fields[1] == pkg {
return true
}
}
return false
}
// PackageUpgrade runs `apt-get upgrade -y` to upgrade all packages.
func PackageUpgrade() (string, error) {
out, err := exec.Command("apt-get", "upgrade", "-y").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get upgrade failed: %w (%s)", err, output)
}
return output, nil
}
// PackageSecurityUpdates returns a list of packages with available security updates.
func PackageSecurityUpdates() ([]PackageInfo, error) {
// apt list --upgradable outputs lines like:
// package/suite version arch [upgradable from: old-version]
out, err := exec.Command("apt", "list", "--upgradable").CombinedOutput()
if err != nil {
// apt list may return exit code 1 even with valid output
if len(out) == 0 {
return nil, fmt.Errorf("apt list --upgradable failed: %w", err)
}
}
var securityPkgs []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
// Skip header/warning lines
if strings.HasPrefix(line, "Listing") || strings.HasPrefix(line, "WARNING") || strings.TrimSpace(line) == "" {
continue
}
// Filter for security updates: look for "-security" in the suite name
if !strings.Contains(line, "-security") {
continue
}
// Parse: "name/suite version arch [upgradable from: old]"
slashIdx := strings.Index(line, "/")
if slashIdx < 0 {
continue
}
name := line[:slashIdx]
// Get version from the fields after the suite
rest := line[slashIdx+1:]
fields := strings.Fields(rest)
var version string
if len(fields) >= 2 {
version = fields[1]
}
securityPkgs = append(securityPkgs, PackageInfo{
Name: name,
Version: version,
Status: "security-update",
})
}
return securityPkgs, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
// validatePackageName does basic validation to prevent obvious injection attempts.
// Package names in Debian must consist of lowercase alphanumerics, +, -, . and
// must be at least 2 characters long.
func validatePackageName(pkg string) error {
if len(pkg) < 2 {
return fmt.Errorf("invalid package name %q: too short", pkg)
}
if len(pkg) > 128 {
return fmt.Errorf("invalid package name %q: too long", pkg)
}
for _, c := range pkg {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == ':') {
return fmt.Errorf("invalid character %q in package name %q", c, pkg)
}
}
return nil
}

View File

@@ -0,0 +1,292 @@
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)
}