diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81c5fb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Binaries and build output +theta-agent +theta-agent-* +dist/ +*.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 5520809..0c80da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to the `theta-agent` daemon will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.7.0] - 2026-08-08 + +### Added +- **Multi-Architecture & Multi-OS Binaries.** Built cross-platform targets for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon M1/M2/M3/M4). +- **Cross-Compilation Pipeline (`build_all.sh`).** Automated Go build toolchain generating static binaries for all 7 target platforms. +- **Installer OS & Architecture Auto-Detection.** Updated `install.sh` to auto-detect `uname -s` and `uname -m` to download matching release binaries. + +### Fixed +- **Systemd & Docker Command Dispatching.** Handled systemd actions (`start`, `stop`, `restart`, `reload`) and Docker container metrics/actions cleanly across Linux distributions. + ## [v1.6.0] - 2026-08-07 ### Added diff --git a/build_all.sh b/build_all.sh new file mode 100755 index 0000000..9964cb5 --- /dev/null +++ b/build_all.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -e + +# Cross-compilation script for Theta Agent across Linux (amd64, arm64, armv7), Windows (amd64, arm64), and macOS (amd64, arm64). + +DIST_DIR="./dist" +mkdir -p "$DIST_DIR" + +LDFLAGS="-s -w" + +echo "Building Theta Agent binaries..." + +echo " -> linux/amd64..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-amd64" + +echo " -> linux/arm64..." +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-arm64" + +echo " -> linux/armv7..." +CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-armv7" + +echo " -> windows/amd64..." +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-amd64.exe" + +echo " -> windows/arm64..." +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe" + +echo " -> darwin/amd64 (macOS Intel)..." +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64" + +echo " -> darwin/arm64 (macOS Apple Silicon)..." +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64" + +echo "" +echo "Build complete! Artifacts in $DIST_DIR:" +ls -lh "$DIST_DIR" diff --git a/cli.go b/cli.go index cd930d0..befc315 100644 --- a/cli.go +++ b/cli.go @@ -32,7 +32,7 @@ func handleCLI(args []string) bool { runReinitialize(args[1:]) return true case "--version", "version", "-v": - fmt.Println("Theta Agent v1.2.0") + fmt.Println("Theta Agent v1.7.0") return true case "--help", "help", "-h": printUsage() diff --git a/install.sh b/install.sh index 5f3e57c..8b2d750 100644 --- a/install.sh +++ b/install.sh @@ -110,9 +110,40 @@ fi log "Starting Theta Agent installation..." +# Architecture and OS detection +OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH_NAME="$(uname -m)" +BINARY_NAME="theta-agent-linux-amd64" + +case "$OS_NAME" in + linux*) + case "$ARCH_NAME" in + x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;; + aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;; + armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;; + *) BINARY_NAME="theta-agent-linux-amd64" ;; + esac + ;; + darwin*) + case "$ARCH_NAME" in + x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;; + arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;; + *) BINARY_NAME="theta-agent-darwin-arm64" ;; + esac + ;; + mingw*|msys*|cygwin*) + case "$ARCH_NAME" in + aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;; + *) BINARY_NAME="theta-agent-windows-amd64.exe" ;; + esac + ;; +esac + +BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}" + # 3. Install binary -log "Downloading binary from $BINARY_URL..." -curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary." +log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..." +curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL" chmod +x "$BIN_PATH.tmp" mv -f "$BIN_PATH.tmp" "$BIN_PATH" diff --git a/telemetry.go b/telemetry.go index 72d2911..f70be22 100644 --- a/telemetry.go +++ b/telemetry.go @@ -7,6 +7,9 @@ import ( "log" "net" "net/http" + "os" + "path/filepath" + "runtime" "strings" "time" @@ -17,6 +20,34 @@ import ( "github.com/shirou/gopsutil/v3/mem" ) +type CPUDetails struct { + Model string `json:"model"` + Cores int `json:"cores"` + Threads int `json:"threads"` + MHz float64 `json:"mhz"` +} + +type RAMDetails struct { + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` + BuffersCacheBytes uint64 `json:"buffers_cache_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsedPercent float64 `json:"used_percent"` + BuffersCachePercent float64 `json:"buffers_cache_percent"` + FreePercent float64 `json:"free_percent"` +} + +type DiskItem struct { + Mountpoint string `json:"mountpoint"` + Device string `json:"device"` + FSType string `json:"fstype"` + DriveType string `json:"drivetype"` + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsagePercent float64 `json:"usage_percent"` +} + type DiscoveryData struct { Hostname string `json:"hostname"` IPs []string `json:"ip_addresses"` @@ -24,19 +55,25 @@ type DiscoveryData struct { OS string `json:"os"` Kernel string `json:"kernel"` CPUModel string `json:"cpu"` + CPUDetails CPUDetails `json:"cpu_details"` RAMTotalGB float64 `json:"ram_total_gb"` + RAMDetails RAMDetails `json:"ram_details"` DiskTotalGB float64 `json:"disk_total_gb"` + Disks []DiskItem `json:"disks"` Location string `json:"location"` Capabilities map[string]interface{} `json:"capabilities"` } type TelemetryData struct { - CPUUsagePercent float64 `json:"cpu_usage_percent"` - RAMUsagePercent float64 `json:"ram_usage_percent"` - DiskUsagePercent float64 `json:"disk_usage_percent"` - ZFSHealth string `json:"zfs_health,omitempty"` - GPUUsage float64 `json:"gpu_usage_percent,omitempty"` - Timestamp string `json:"timestamp"` + CPUUsagePercent float64 `json:"cpu_usage_percent"` + CPUDetails CPUDetails `json:"cpu_details"` + RAMUsagePercent float64 `json:"ram_usage_percent"` + RAMDetails RAMDetails `json:"ram_details"` + DiskUsagePercent float64 `json:"disk_usage_percent"` + Disks []DiskItem `json:"disks"` + ZFSHealth string `json:"zfs_health,omitempty"` + GPUUsage float64 `json:"gpu_usage_percent,omitempty"` + Timestamp string `json:"timestamp"` } func getPublicIP() string { @@ -62,6 +99,146 @@ func getPublicIP() string { return "" } +func collectCPUDetails() CPUDetails { + cpuInfo, _ := cpu.Info() + model := "Unknown" + cores := 0 + mhz := 0.0 + if len(cpuInfo) > 0 { + model = cpuInfo[0].ModelName + if model == "" { + model = cpuInfo[0].Model + } + cores = int(cpuInfo[0].Cores) + mhz = cpuInfo[0].Mhz + } + threads := runtime.NumCPU() + if t, err := cpu.Counts(true); err == nil && t > 0 { + threads = t + } + if cores <= 0 { + if c, err := cpu.Counts(false); err == nil && c > 0 { + cores = c + } else { + cores = threads + } + } + return CPUDetails{ + Model: model, + Cores: cores, + Threads: threads, + MHz: mhz, + } +} + +func collectRAMDetails() RAMDetails { + vm, err := mem.VirtualMemory() + if err != nil || vm == nil { + return RAMDetails{} + } + bufCache := vm.Buffers + vm.Cached + total := float64(vm.Total) + usedPct := 0.0 + bufPct := 0.0 + freePct := 0.0 + if total > 0 { + usedPct = (float64(vm.Used) / total) * 100.0 + bufPct = (float64(bufCache) / total) * 100.0 + freePct = (float64(vm.Free) / total) * 100.0 + } + return RAMDetails{ + TotalBytes: vm.Total, + UsedBytes: vm.Used, + BuffersCacheBytes: bufCache, + FreeBytes: vm.Free, + UsedPercent: usedPct, + BuffersCachePercent: bufPct, + FreePercent: freePct, + } +} + +func getDriveType(device string) string { + devName := filepath.Base(device) + devName = strings.TrimRight(devName, "0123456789p") + if strings.HasPrefix(devName, "nvme") { + return "NVMe" + } + rotPath := filepath.Join("/sys/block", devName, "queue/rotational") + data, err := os.ReadFile(rotPath) + if err == nil { + val := strings.TrimSpace(string(data)) + if val == "0" { + return "SSD" + } else if val == "1" { + return "HDD" + } + } + return "SSD/HDD" +} + +func collectDiskItems() []DiskItem { + var items []DiskItem + partitions, err := disk.Partitions(false) + if err != nil || len(partitions) == 0 { + d, err2 := disk.Usage("/") + if err2 == nil { + items = append(items, DiskItem{ + Mountpoint: "/", + FSType: d.Fstype, + DriveType: getDriveType(d.Path), + TotalBytes: d.Total, + UsedBytes: d.Used, + FreeBytes: d.Free, + UsagePercent: d.UsedPercent, + }) + } + return items + } + seen := make(map[string]bool) + for _, p := range partitions { + if strings.HasPrefix(p.Mountpoint, "/proc") || strings.HasPrefix(p.Mountpoint, "/sys") || strings.HasPrefix(p.Mountpoint, "/dev") { + continue + } + if seen[p.Mountpoint] { + continue + } + seen[p.Mountpoint] = true + u, err := disk.Usage(p.Mountpoint) + if err != nil || u.Total == 0 { + continue + } + fstype := p.Fstype + if fstype == "" { + fstype = u.Fstype + } + items = append(items, DiskItem{ + Mountpoint: p.Mountpoint, + Device: p.Device, + FSType: fstype, + DriveType: getDriveType(p.Device), + TotalBytes: u.Total, + UsedBytes: u.Used, + FreeBytes: u.Free, + UsagePercent: u.UsedPercent, + }) + } + if len(items) == 0 { + d, err2 := disk.Usage("/") + if err2 == nil { + items = append(items, DiskItem{ + Mountpoint: "/", + FSType: d.Fstype, + DriveType: getDriveType(d.Path), + TotalBytes: d.Total, + UsedBytes: d.Used, + FreeBytes: d.Free, + UsagePercent: d.UsedPercent, + }) + } + } + return items +} + // CollectDiscoveryData gathers static host information. func CollectDiscoveryData(cfg *Config) DiscoveryData { h, _ := host.Info() @@ -76,27 +253,36 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData { } } - vm, _ := mem.VirtualMemory() - d, _ := disk.Usage("/") - - cpuInfo, _ := cpu.Info() - cpuModel := "Unknown" - if len(cpuInfo) > 0 { - cpuModel = cpuInfo[0].Model - } + vm := collectRAMDetails() + disks := collectDiskItems() + cpuDet := collectCPUDetails() pubIP := getPublicIP() + diskTotalGB := 0.0 + for _, d := range disks { + if d.Mountpoint == "/" { + diskTotalGB = float64(d.TotalBytes) / (1024 * 1024 * 1024) + break + } + } + if diskTotalGB == 0 && len(disks) > 0 { + diskTotalGB = float64(disks[0].TotalBytes) / (1024 * 1024 * 1024) + } + return DiscoveryData{ - Hostname: h.Hostname, - IPs: ips, - PublicIP: pubIP, - OS: fmt.Sprintf("%s %s", h.OS, h.Platform), - Kernel: h.KernelVersion, - CPUModel: cpuModel, - RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024), - DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024), - Location: cfg.Location, + Hostname: h.Hostname, + IPs: ips, + PublicIP: pubIP, + OS: fmt.Sprintf("%s %s", h.OS, h.Platform), + Kernel: h.KernelVersion, + CPUModel: cpuDet.Model, + CPUDetails: cpuDet, + RAMTotalGB: float64(vm.TotalBytes) / (1024 * 1024 * 1024), + RAMDetails: vm, + DiskTotalGB: diskTotalGB, + Disks: disks, + Location: cfg.Location, Capabilities: map[string]interface{}{ "telemetry": cfg.Capabilities.Telemetry, "configure_ldap": cfg.Capabilities.ConfigureLDAP, @@ -104,6 +290,7 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData { "secrets": cfg.Capabilities.Secrets, "iam": cfg.Capabilities.IAM, "reboot": cfg.Capabilities.Reboot, + "shutdown": true, "service_control": cfg.Capabilities.ServiceControl, "arbitrary_bash": cfg.Capabilities.ArbitraryBash, }, @@ -113,21 +300,36 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData { // CollectTelemetryData gathers real-time performance metrics including ZFS and GPU. func CollectTelemetryData(exec Executor) TelemetryData { cpuPerc, _ := cpu.Percent(time.Second, false) - vm, _ := mem.VirtualMemory() - d, _ := disk.Usage("/") + vm := collectRAMDetails() + disks := collectDiskItems() + cpuDet := collectCPUDetails() cpuVal := 0.0 if len(cpuPerc) > 0 { cpuVal = cpuPerc[0] } + diskVal := 0.0 + for _, d := range disks { + if d.Mountpoint == "/" { + diskVal = d.UsagePercent + break + } + } + if diskVal == 0 && len(disks) > 0 { + diskVal = disks[0].UsagePercent + } + return TelemetryData{ CPUUsagePercent: cpuVal, + CPUDetails: cpuDet, RAMUsagePercent: vm.UsedPercent, - DiskUsagePercent: d.UsedPercent, - ZFSHealth: collectZFSHealth(exec), - GPUUsage: collectGPUUsage(exec), - Timestamp: time.Now().Format(time.RFC3339), + RAMDetails: vm, + DiskUsagePercent: diskVal, + Disks: disks, + ZFSHealth: collectZFSHealth(exec), + GPUUsage: collectGPUUsage(exec), + Timestamp: time.Now().Format(time.RFC3339), } } @@ -194,10 +396,13 @@ func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopC Type: "telemetry", Payload: map[string]interface{}{ "cpu_usage_percent": telemetry.CPUUsagePercent, + "cpu_details": telemetry.CPUDetails, "ram_usage_percent": telemetry.RAMUsagePercent, + "ram_details": telemetry.RAMDetails, "disk_usage_percent": telemetry.DiskUsagePercent, + "disks": telemetry.Disks, "zfs_health": telemetry.ZFSHealth, - "gpu_usage_percent": telemetry.GPUUsage, + "gpu_usage_percent": telemetry.GPUUsage, "timestamp": telemetry.Timestamp, }, }) diff --git a/theta-agent b/theta-agent index 92c74b7..bc23c08 100755 Binary files a/theta-agent and b/theta-agent differ diff --git a/theta-agent-linux-amd64 b/theta-agent-linux-amd64 index 92c74b7..bc23c08 100755 Binary files a/theta-agent-linux-amd64 and b/theta-agent-linux-amd64 differ diff --git a/websocket.go b/websocket.go index 811c769..707d360 100644 --- a/websocket.go +++ b/websocket.go @@ -372,6 +372,52 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu return } sendResponse("ok", "system rebooting") + case "shutdown": + if !verifySignature(cfg, msg) { + sendResponse("error", "signature verification failed") + return + } + if !cfg.Capabilities.Reboot { + log.Println("Shutdown rejected: capability disabled in agent.yml") + sendResponse("error", "shutdown capability disabled") + return + } + log.Printf("Executing shutdown...") + sendResponse("ok", "system shutting down") + if _, err := exec.Execute("shutdown", "-h", "now"); err != nil { + exec.Execute("poweroff") + } + return + case "systemd_action": + serviceName, _ := msg.Payload["service"].(string) + action, _ := msg.Payload["action"].(string) + if serviceName == "" { + sendResponse("error", "service name required") + return + } + if action == "" { + action = "status" + } + if action != "status" && !verifySignature(cfg, msg) { + sendResponse("error", "signature verification failed") + return + } + log.Printf("Executing systemctl %s %s...", action, serviceName) + out, err := exec.Execute("systemctl", action, serviceName) + errMsg := "" + if err != nil { + errMsg = err.Error() + } + respMap := map[string]interface{}{ + "status": "ok", + "service": serviceName, + "action": action, + "output": string(out), + "error": errMsg, + } + respPayload, _ := json.Marshal(respMap) + c.WriteMessage(websocket.TextMessage, respPayload) + return case "service_restart": serviceName, ok := msg.Payload["service"].(string) if !ok || !cfg.Capabilities.CanManageService(serviceName) {