No One Can Stop Me Now
This commit is contained in:
243
services/setec-manager/internal/system/firewall.go
Normal file
243
services/setec-manager/internal/system/firewall.go
Normal 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
|
||||
}
|
||||
511
services/setec-manager/internal/system/info.go
Normal file
511
services/setec-manager/internal/system/info.go
Normal 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"
|
||||
}
|
||||
213
services/setec-manager/internal/system/packages.go
Normal file
213
services/setec-manager/internal/system/packages.go
Normal 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
|
||||
}
|
||||
292
services/setec-manager/internal/system/users.go
Normal file
292
services/setec-manager/internal/system/users.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user