feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy

This commit is contained in:
Pepe44DEV
2026-09-06 22:09:28 +00:00
commit db16ad5cc1
154 changed files with 22920 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"github.com/fleetforge/forge-agent/internal/agent"
"github.com/fleetforge/forge-agent/internal/client"
"github.com/fleetforge/forge-agent/internal/config"
"github.com/fleetforge/forge-agent/internal/inventory"
"github.com/fleetforge/forge-agent/internal/winsvc"
)
const AgentVersion = "0.1.0"
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Forge Agent v%s\n", AgentVersion)
fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, " forge-agent enroll -token <token> -server <url> Enroll this device\n")
fmt.Fprintf(os.Stderr, " forge-agent run [--service] Run the agent\n")
fmt.Fprintf(os.Stderr, " forge-agent install -server <url> -token <token> Install as Windows Service\n")
fmt.Fprintf(os.Stderr, " forge-agent uninstall Uninstall Windows Service\n")
fmt.Fprintf(os.Stderr, " forge-agent version Print version\n")
}
if len(os.Args) < 2 {
flag.Usage()
os.Exit(1)
}
switch os.Args[1] {
case "enroll":
cmdEnroll(os.Args[2:])
case "run":
cmdRun(os.Args[2:])
case "install":
cmdInstall(os.Args[2:])
case "uninstall":
cmdUninstall()
case "version":
fmt.Printf("Forge Agent v%s\n", AgentVersion)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
flag.Usage()
os.Exit(1)
}
}
func cmdEnroll(args []string) {
fs := flag.NewFlagSet("enroll", flag.ExitOnError)
token := fs.String("token", "", "Enrollment token")
server := fs.String("server", "http://localhost:3002", "Server URL")
label := fs.String("label", "", "Device label")
fs.Parse(args)
if *token == "" {
log.Fatal("Error: -token is required")
}
performEnrollment(*server, *token, *label)
}
func cmdRun(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
serviceMode := fs.Bool("service", false, "Run as Windows Service (use with --service flag)")
fs.Parse(args)
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v\nRun 'forge-agent enroll' or 'forge-agent install' first.", err)
}
if cfg.EnrollmentToken != "" && cfg.DeviceID == "" {
log.Println("First run detected: attempting auto-enrollment...")
if err := performEnrollment(cfg.ServerURL, cfg.EnrollmentToken, cfg.Label); err != nil {
log.Printf("WARNING: Auto-enrollment failed: %v", err)
log.Println("Agent will retry enrollment on next heartbeat cycle.")
} else {
if loaded, loadErr := config.Load(); loadErr == nil {
cfg = loaded
}
log.Println("Auto-enrollment successful.")
}
}
if *serviceMode {
if err := winsvc.RunAsService(); err != nil {
log.Fatalf("Service error: %v", err)
}
return
}
a := agent.New(cfg)
if err := a.Run(); err != nil {
log.Fatalf("Agent error: %v", err)
}
}
func cmdInstall(args []string) {
fs := flag.NewFlagSet("install", flag.ExitOnError)
server := fs.String("server", "", "Server URL")
token := fs.String("token", "", "Enrollment token")
label := fs.String("label", "", "Device label")
fs.Parse(args)
if *server == "" || *token == "" {
log.Fatal("Error: -server and -token are required")
}
if err := winsvc.InstallService(*server, *token, *label); err != nil {
log.Fatalf("Install failed: %v", err)
}
}
func cmdUninstall() {
if err := winsvc.UninstallService(); err != nil {
log.Fatalf("Uninstall failed: %v", err)
}
}
func performEnrollment(serverURL, token, label string) error {
fmt.Println("Collecting system information...")
info, err := inventory.CollectSystemInfo()
if err != nil {
return fmt.Errorf("failed to collect system info: %w", err)
}
fmt.Printf("Hostname: %s\n", info.Hostname)
fmt.Printf("OS: %s %s\n", info.OSName, info.OSVersion)
fmt.Printf("Arch: %s\n", info.Architecture)
fmt.Printf("Server: %s\n", serverURL)
fmt.Println("Enrolling...")
c := client.New(serverURL, "")
resp, err := c.Enroll(client.EnrollRequest{
Token: token,
Hostname: info.Hostname,
Platform: info.Platform,
OSName: info.OSName,
OSVersion: info.OSVersion,
Architecture: info.Architecture,
KernelVersion: info.KernelVersion,
CPUModel: info.CPUModel,
CPUCores: info.CPUCores,
TotalMemoryBytes: info.TotalMemory,
TotalDiskBytes: info.TotalDisk,
IPAddress: info.IPAddress,
AgentVersion: AgentVersion,
})
if err != nil {
return fmt.Errorf("enrollment failed: %w", err)
}
cfg, err := config.Load()
if err != nil {
cfg = &config.Config{}
}
cfg.ServerURL = serverURL
cfg.DeviceID = resp.DeviceID
cfg.Credential = resp.Credential
cfg.Label = label
cfg.EnrollmentToken = ""
if cfg.HeartbeatSec <= 0 {
cfg.HeartbeatSec = 30
}
if err := config.Save(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("\nEnrollment successful!\n")
fmt.Printf("Device ID: %s\n", resp.DeviceID)
fmt.Printf("Config saved to: %s\n", config.ConfigPath())
return nil
}
+19
View File
@@ -0,0 +1,19 @@
module github.com/fleetforge/forge-agent
go 1.22
require (
github.com/gorilla/websocket v1.5.3
github.com/shirou/gopsutil/v3 v3.24.5
golang.org/x/sys v0.20.0
)
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+544
View File
@@ -0,0 +1,544 @@
package agent
import (
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/fleetforge/forge-agent/internal/client"
"github.com/fleetforge/forge-agent/internal/config"
"github.com/fleetforge/forge-agent/internal/inventory"
"github.com/fleetforge/forge-agent/internal/metrics"
"github.com/fleetforge/forge-agent/internal/pkgmanager"
"github.com/fleetforge/forge-agent/internal/svcmgr"
"github.com/fleetforge/forge-agent/internal/tunnel"
"github.com/fleetforge/forge-agent/internal/vnc"
)
const AgentVersion = "0.1.6"
var (
tunnelMu sync.Mutex
tunnels = map[string]*tunnel.Tunnel{}
)
func storeTunnel(sessionID string, t *tunnel.Tunnel) {
tunnelMu.Lock()
defer tunnelMu.Unlock()
tunnels[sessionID] = t
}
func removeTunnel(sessionID string) {
tunnelMu.Lock()
defer tunnelMu.Unlock()
delete(tunnels, sessionID)
}
func stopTunnel(sessionID string) {
tunnelMu.Lock()
t := tunnels[sessionID]
tunnelMu.Unlock()
if t != nil {
t.Stop()
}
}
type Agent struct {
cfg *config.Config
client *client.Client
}
func New(cfg *config.Config) *Agent {
return &Agent{
cfg: cfg,
client: client.New(cfg.ServerURL, cfg.Credential),
}
}
func (a *Agent) Run() error {
log.Println("Forge Agent starting...")
log.Printf("Server: %s", a.cfg.ServerURL)
log.Printf("Device ID: %s", a.cfg.DeviceID)
ticker := time.NewTicker(time.Duration(a.cfg.HeartbeatSec) * time.Second)
defer ticker.Stop()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
if a.cfg.DeviceID == "" {
log.Println("No DeviceID configured - waiting for enrollment")
} else {
if err := pkgmanager.BootstrapManagers(); err != nil {
log.Printf("WARNING: Manager bootstrap failed: %v", err)
}
a.sendHeartbeat()
}
for {
select {
case <-ticker.C:
if a.cfg.DeviceID == "" || a.cfg.Credential == "" {
a.tryEnroll()
} else {
a.sendHeartbeat()
a.checkJobs()
}
case sig := <-sigCh:
log.Printf("Received signal %v, shutting down...", sig)
return nil
}
}
}
func (a *Agent) tryEnroll() {
if a.cfg.EnrollmentToken == "" {
return
}
log.Println("Attempting auto-enrollment...")
info, err := inventory.CollectSystemInfo()
if err != nil {
log.Printf("Enrollment failed (inventory): %v", err)
return
}
c := client.New(a.cfg.ServerURL, "")
resp, err := c.Enroll(client.EnrollRequest{
Token: a.cfg.EnrollmentToken,
Hostname: info.Hostname,
Platform: info.Platform,
OSName: info.OSName,
OSVersion: info.OSVersion,
Architecture: info.Architecture,
KernelVersion: info.KernelVersion,
CPUModel: info.CPUModel,
CPUCores: info.CPUCores,
TotalMemoryBytes: info.TotalMemory,
TotalDiskBytes: info.TotalDisk,
IPAddress: info.IPAddress,
AgentVersion: AgentVersion,
})
if err != nil {
log.Printf("Enrollment failed: %v", err)
return
}
a.cfg.DeviceID = resp.DeviceID
a.cfg.Credential = resp.Credential
a.cfg.EnrollmentToken = ""
if err := config.Save(a.cfg); err != nil {
log.Printf("Failed to save config after enrollment: %v", err)
return
}
a.client = client.New(a.cfg.ServerURL, a.cfg.Credential)
log.Printf("Enrollment successful! Device ID: %s", resp.DeviceID)
}
func (a *Agent) sendHeartbeat() {
info, err := inventory.CollectSystemInfo()
if err != nil {
log.Printf("Failed to collect system info: %v", err)
return
}
met, err := metrics.Collect()
if err != nil {
log.Printf("Failed to collect metrics: %v", err)
return
}
req := client.HeartbeatRequest{
Hostname: info.Hostname,
OSVersion: info.OSVersion,
KernelVersion: info.KernelVersion,
AgentVersion: AgentVersion,
IPAddress: info.IPAddress,
Metrics: &client.MetricsData{
CPUPercent: met.CPUPercent,
MemoryPercent: met.MemoryPercent,
MemoryUsed: met.MemoryUsed,
MemoryTotal: met.MemoryTotal,
DiskPercent: met.DiskPercent,
DiskUsed: met.DiskUsed,
DiskTotal: met.DiskTotal,
Load1: met.Load1,
Load5: met.Load5,
Load15: met.Load15,
UptimeSeconds: met.UptimeSeconds,
},
}
if len(info.Packages) > 0 || len(info.Services) > 0 || len(info.NetworkInterfaces) > 0 {
inv := &client.InventoryData{}
for _, p := range info.Packages {
inv.Packages = append(inv.Packages, client.PackageData{
Name: p.Name,
Version: p.Version,
Manager: p.Manager,
})
}
for _, s := range info.Services {
inv.Services = append(inv.Services, client.ServiceData{
Name: s.Name,
Status: s.Status,
Enabled: s.Enabled,
})
}
for _, ni := range info.NetworkInterfaces {
inv.NetworkInterfaces = append(inv.NetworkInterfaces, client.NetworkInterfaceData{
Name: ni.Name,
Addresses: ni.Addresses,
MacAddress: ni.MAC,
})
}
req.Inventory = inv
}
if err := a.client.Heartbeat(req); err != nil {
log.Printf("Heartbeat failed: %v", err)
} else {
log.Printf("Heartbeat sent successfully")
}
}
func (a *Agent) checkJobs() {
jobs, err := a.client.FetchJobs()
if err != nil {
log.Printf("Failed to fetch jobs: %v", err)
return
}
for _, job := range jobs {
a.executeJob(job)
}
}
func (a *Agent) executeJob(job client.JobInfo) {
log.Printf("Executing job %s (type: %s)", job.JobID, job.JobType)
result := client.JobResult{
Status: "succeeded",
Result: map[string]interface{}{},
}
result.TargetID = job.TargetID
if err := a.client.ReportStart(job.JobID, job.TargetID); err != nil {
log.Printf("Failed to report start for job %s: %v", job.JobID, err)
}
switch job.JobType {
case "update_inventory":
a.sendHeartbeat()
result.Result["inventory_refreshed"] = true
case "update_system_info":
met, err := metrics.Collect()
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
} else {
result.Result["cpu_percent"] = met.CPUPercent
result.Result["memory_percent"] = met.MemoryPercent
result.Result["disk_percent"] = met.DiskPercent
result.Result["uptime"] = met.UptimeSeconds
}
case "install_package":
pkgName, _ := job.Parameters["package_name"].(string)
if pkgName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: package_name"
break
}
output, err := pkgmanager.InstallPackage(pkgName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["package"] = pkgName
result.Result["output"] = truncateOutput(output)
case "update_package":
pkgName, _ := job.Parameters["package_name"].(string)
if pkgName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: package_name"
break
}
output, err := pkgmanager.UpdatePackage(pkgName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["package"] = pkgName
result.Result["output"] = truncateOutput(output)
case "remove_package":
pkgName, _ := job.Parameters["package_name"].(string)
if pkgName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: package_name"
break
}
source, _ := job.Parameters["source"].(string)
var manager string
switch source {
case "winget":
manager = "winget"
case "chocolatey":
manager = "choco"
}
var output string
var err error
if manager != "" {
output, err = pkgmanager.RemovePackageWithManager(pkgName, manager)
} else {
output, err = pkgmanager.RemovePackage(pkgName)
}
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["package"] = pkgName
result.Result["output"] = truncateOutput(output)
case "start_service":
svcName, _ := job.Parameters["service_name"].(string)
if svcName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: service_name"
break
}
output, err := svcmgr.StartService(svcName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["service"] = svcName
result.Result["output"] = truncateOutput(output)
case "stop_service":
svcName, _ := job.Parameters["service_name"].(string)
if svcName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: service_name"
break
}
output, err := svcmgr.StopService(svcName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["service"] = svcName
result.Result["output"] = truncateOutput(output)
case "restart_service":
svcName, _ := job.Parameters["service_name"].(string)
if svcName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: service_name"
break
}
output, err := svcmgr.RestartService(svcName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["service"] = svcName
result.Result["output"] = truncateOutput(output)
case "check_service_status":
svcName, _ := job.Parameters["service_name"].(string)
if svcName == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: service_name"
break
}
status, enabled, err := svcmgr.GetServiceStatus(svcName)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["service"] = svcName
result.Result["status"] = status
result.Result["enabled"] = enabled
case "reboot_system":
log.Println("System reboot requested, initiating reboot...")
result.Result["message"] = "reboot initiated"
if err := a.client.SubmitResult(job.JobID, result); err != nil {
log.Printf("Failed to submit result for reboot job: %v", err)
return
}
log.Println("Rebooting system in 5 seconds...")
time.Sleep(5 * time.Second)
svcmgr.Reboot()
return
case "shutdown_system":
log.Println("System shutdown requested, initiating shutdown...")
result.Result["message"] = "shutdown initiated"
if err := a.client.SubmitResult(job.JobID, result); err != nil {
log.Printf("Failed to submit result for shutdown job: %v", err)
return
}
log.Println("Shutting down system in 5 seconds...")
time.Sleep(5 * time.Second)
svcmgr.Shutdown()
return
case "install_registry_package":
source, _ := job.Parameters["source"].(string)
pkgID, _ := job.Parameters["package_id"].(string)
if source == "" || pkgID == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: source/package_id"
break
}
var manager string
switch source {
case "winget":
manager = "winget"
case "chocolatey":
manager = "choco"
default:
result.Status = "failed"
result.ErrorMessage = fmt.Sprintf("unsupported source: %s", source)
break
}
if result.Status == "failed" {
break
}
version, _ := job.Parameters["package_version"].(string)
output, err := pkgmanager.InstallPackageWithManager(pkgID, manager, version)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["package"] = pkgID
result.Result["source"] = source
result.Result["output"] = truncateOutput(output)
case "start_remote_session":
sessionID, _ := job.Parameters["session_id"].(string)
if sessionID == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: session_id"
break
}
port := 5900
if p, ok := job.Parameters["vnc_port"].(float64); ok && p > 0 {
port = int(p)
}
password, _ := job.Parameters["vnc_password"].(string)
vncServer, err := vnc.Ensure(port, password)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
break
}
log.Printf("VNC server started on port %d for session %s", port, sessionID)
result.Result["session_id"] = sessionID
result.Result["vnc_port"] = port
result.Result["vnc_started"] = true
// Keep the session alive in a background goroutine; VNC is stopped when
// the tunnel closes.
go func(sid string, p int, vs *vnc.Server) {
t := tunnel.New(a.cfg.ServerURL, a.cfg.Credential, sid, p)
storeTunnel(sid, t)
defer func() {
removeTunnel(sid)
vs.Stop()
log.Printf("VNC server stopped after tunnel close for session %s", sid)
}()
if err := t.Run(); err != nil {
log.Printf("Tunnel for session %s closed: %v", sid, err)
}
}(sessionID, port, vncServer)
case "stop_remote_session":
sessionID, _ := job.Parameters["session_id"].(string)
if sessionID == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: session_id"
break
}
stopTunnel(sessionID)
result.Result["session_id"] = sessionID
result.Result["vnc_stopped"] = true
case "install_from_file":
filePath, _ := job.Parameters["file_path"].(string)
if filePath == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: file_path"
break
}
output, err := pkgmanager.InstallPackageFromFile(filePath)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
result.Result["file_path"] = filePath
result.Result["output"] = truncateOutput(output)
case "download_and_install":
downloadURL, _ := job.Parameters["download_url"].(string)
packageId, _ := job.Parameters["package_id"].(string)
if downloadURL == "" && packageId == "" {
result.Status = "failed"
result.ErrorMessage = "missing parameter: download_url or package_id"
break
}
if downloadURL == "" && packageId != "" {
downloadURL = a.cfg.ServerURL + "/api/v1/agent/software/" + packageId + "/download"
}
tmpDir := os.TempDir()
destPath := tmpDir + "/forge-download-" + packageId + ".pkg"
log.Printf("Downloading package from %s to %s", downloadURL, destPath)
if err := pkgmanager.DownloadFile(downloadURL, destPath); err != nil {
result.Status = "failed"
result.ErrorMessage = fmt.Sprintf("download failed: %v", err)
break
}
output, err := pkgmanager.InstallPackageFromFile(destPath)
if err != nil {
result.Status = "failed"
result.ErrorMessage = err.Error()
}
os.Remove(destPath)
result.Result["download_url"] = downloadURL
result.Result["output"] = truncateOutput(output)
default:
result.Status = "failed"
result.ErrorMessage = fmt.Sprintf("unsupported job type: %s", job.JobType)
}
if a.client.IsJobCancelled(job.JobID) {
log.Printf("Job %s cancelled on server; discarding result", job.JobID)
result.Status = "cancelled"
}
if err := a.client.SubmitResult(job.JobID, result); err != nil {
log.Printf("Failed to submit result for job %s: %v", job.JobID, err)
} else {
log.Printf("Job %s completed with status: %s", job.JobID, result.Status)
}
}
func truncateOutput(output string) string {
const maxLen = 4096
if len(output) > maxLen {
return output[:maxLen] + "... (truncated)"
}
return output
}
@@ -0,0 +1,230 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Client struct {
serverURL string
credential string
httpClient *http.Client
}
func New(serverURL, credential string) *Client {
return &Client{
serverURL: serverURL,
credential: credential,
httpClient: &http.Client{},
}
}
func (c *Client) doPost(path string, body interface{}, out interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest("POST", c.serverURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.credential != "" {
req.Header.Set("Authorization", "Bearer "+c.credential)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody))
}
if out != nil {
var apiResp struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if !apiResp.Success {
return fmt.Errorf("api returned success=false")
}
if err := json.Unmarshal(apiResp.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
}
}
return nil
}
func (c *Client) doGet(path string, out interface{}) error {
req, err := http.NewRequest("GET", c.serverURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.credential)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody))
}
if out != nil {
var apiResp struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if err := json.Unmarshal(apiResp.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
}
}
return nil
}
type EnrollRequest struct {
Token string `json:"token"`
Hostname string `json:"hostname"`
Platform string `json:"platform"`
OSName string `json:"os_name"`
OSVersion string `json:"os_version"`
Architecture string `json:"architecture"`
KernelVersion string `json:"kernel_version,omitempty"`
CPUModel string `json:"cpu_model,omitempty"`
CPUCores int `json:"cpu_cores,omitempty"`
TotalMemoryBytes uint64 `json:"total_memory_bytes,omitempty"`
TotalDiskBytes uint64 `json:"total_disk_bytes,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
AgentVersion string `json:"agent_version"`
}
type EnrollResponse struct {
DeviceID string `json:"device_id"`
Credential string `json:"credential"`
}
func (c *Client) Enroll(req EnrollRequest) (*EnrollResponse, error) {
var resp EnrollResponse
if err := c.doPost("/api/v1/agent/enroll", &req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
type MetricsData struct {
CPUPercent float64 `json:"cpu_usage_percent"`
MemoryPercent float64 `json:"memory_usage_percent"`
MemoryUsed uint64 `json:"memory_used_bytes"`
MemoryTotal uint64 `json:"memory_total_bytes"`
DiskPercent float64 `json:"disk_usage_percent"`
DiskUsed uint64 `json:"disk_used_bytes"`
DiskTotal uint64 `json:"disk_total_bytes"`
Load1 float64 `json:"load_average_1m"`
Load5 float64 `json:"load_average_5m"`
Load15 float64 `json:"load_average_15m"`
UptimeSeconds uint64 `json:"uptime_seconds"`
}
type HeartbeatRequest struct {
Hostname string `json:"hostname"`
OSVersion string `json:"os_version"`
KernelVersion string `json:"kernel_version"`
AgentVersion string `json:"agent_version"`
IPAddress string `json:"ip_address"`
Metrics *MetricsData `json:"metrics,omitempty"`
Inventory *InventoryData `json:"inventory,omitempty"`
}
type InventoryData struct {
Packages []PackageData `json:"packages,omitempty"`
Services []ServiceData `json:"services,omitempty"`
NetworkInterfaces []NetworkInterfaceData `json:"network_interfaces,omitempty"`
}
type PackageData struct {
Name string `json:"name"`
Version string `json:"version"`
Manager string `json:"manager"`
}
type ServiceData struct {
Name string `json:"name"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
}
type NetworkInterfaceData struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MacAddress string `json:"mac_address,omitempty"`
}
func (c *Client) Heartbeat(req HeartbeatRequest) error {
return c.doPost("/api/v1/agent/heartbeat", &req, nil)
}
type JobInfo struct {
JobID string `json:"jobId"`
TargetID string `json:"targetId"`
JobType string `json:"jobType"`
Parameters map[string]interface{} `json:"parameters"`
}
func (c *Client) FetchJobs() ([]JobInfo, error) {
var jobs []JobInfo
if err := c.doGet("/api/v1/agent/jobs", &jobs); err != nil {
return nil, err
}
return jobs, nil
}
type JobResult struct {
Status string `json:"status"`
TargetID string `json:"target_id"`
Result map[string]interface{} `json:"result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
func (c *Client) ReportStart(jobID, targetID string) error {
path := fmt.Sprintf("/api/v1/agent/jobs/%s/start", jobID)
body := map[string]string{"target_id": targetID}
return c.doPost(path, &body, nil)
}
func (c *Client) IsJobCancelled(jobID string) bool {
var status struct {
JobStatus string `json:"job_status"`
TargetStatus string `json:"target_status"`
}
path := fmt.Sprintf("/api/v1/agent/jobs/%s/status", jobID)
if err := c.doGet(path, &status); err != nil {
return false
}
return status.JobStatus == "cancelled" || status.TargetStatus == "cancelled"
}
func (c *Client) SubmitResult(jobID string, result JobResult) error {
path := fmt.Sprintf("/api/v1/agent/jobs/%s/result", jobID)
return c.doPost(path, &result, nil)
}
@@ -0,0 +1,68 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
)
type Config struct {
ServerURL string `json:"server_url"`
EnrollmentToken string `json:"enrollment_token,omitempty"`
DeviceID string `json:"device_id"`
Credential string `json:"credential"`
Label string `json:"label"`
HeartbeatSec int `json:"heartbeat_interval_seconds"`
}
func configDir() string {
switch runtime.GOOS {
case "windows":
if p := os.Getenv("ProgramData"); p != "" {
return filepath.Join(p, "FleetForge")
}
return `C:\ProgramData\FleetForge`
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "FleetForge")
default:
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "forge-agent")
}
}
func ConfigPath() string {
return filepath.Join(configDir(), "config.json")
}
func Load() (*Config, error) {
data, err := os.ReadFile(ConfigPath())
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.HeartbeatSec <= 0 {
cfg.HeartbeatSec = 30
}
return &cfg, nil
}
func Save(cfg *Config) error {
dir := filepath.Dir(ConfigPath())
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(ConfigPath(), data, 0600)
}
func Delete() error {
return os.Remove(ConfigPath())
}
@@ -0,0 +1,188 @@
package inventory
import (
"os"
"os/exec"
"runtime"
"strings"
"github.com/fleetforge/forge-agent/internal/pkgmanager"
"github.com/fleetforge/forge-agent/internal/svcmgr"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/net"
)
func execCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.Output()
return string(out), err
}
type NetworkInterface struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MAC string `json:"mac_address,omitempty"`
}
type SystemInfo struct {
Hostname string
Platform string
OSName string
OSVersion string
Architecture string
KernelVersion string
CPUModel string
CPUCores int
TotalMemory uint64
TotalDisk uint64
IPAddress string
NetworkInterfaces []NetworkInterface
Packages []pkgmanager.PackageInfo
Services []svcmgr.ServiceInfo
}
func CollectSystemInfo() (*SystemInfo, error) {
hostname, _ := os.Hostname()
info := &SystemInfo{
Hostname: hostname,
Platform: runtime.GOOS,
Architecture: runtime.GOARCH,
}
switch runtime.GOOS {
case "linux":
info.OSName = "Linux"
if data, err := os.ReadFile("/etc/os-release"); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "PRETTY_NAME=") {
info.OSName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
}
if strings.HasPrefix(line, "VERSION_ID=") {
info.OSVersion = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
}
}
}
if data, err := os.ReadFile("/proc/version"); err == nil {
parts := strings.Fields(string(data))
if len(parts) >= 3 {
info.KernelVersion = parts[2]
}
}
case "darwin":
info.OSName = "macOS"
if out, err := execCmd("sw_vers", "-productVersion"); err == nil {
info.OSVersion = strings.TrimSpace(out)
}
if out, err := execCmd("uname", "-r"); err == nil {
info.KernelVersion = strings.TrimSpace(out)
}
case "windows":
info.OSName = "Windows"
if out, err := execCmd("cmd", "/c", "ver"); err == nil {
out = strings.TrimSpace(out)
if start := strings.Index(out, "[Version "); start != -1 {
ver := out[start+len("[Version "):]
if end := strings.Index(ver, "]"); end != -1 {
info.OSVersion = ver[:end]
}
}
if info.OSVersion == "" {
info.OSVersion = out
}
}
if out, err := execCmd("cmd", "/c", "ver"); err == nil {
_ = out
}
if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command",
"(Get-CimInstance Win32_OperatingSystem).Caption"); err == nil {
caption := strings.TrimSpace(out)
if caption != "" {
info.OSName = caption
}
}
if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command",
"$k = (Get-CimInstance Win32_OperatingSystem).Version; $b = (Get-CimInstance Win32_ComputerSystem).BuildNumber; $k"); err == nil {
if kb := strings.TrimSpace(out); kb != "" {
info.KernelVersion = kb
}
}
}
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
info.CPUModel = cpuInfo[0].ModelName
info.CPUCores = len(cpuInfo)
}
if v, err := mem.VirtualMemory(); err == nil {
info.TotalMemory = v.Total
}
if d, err := disk.Usage("/"); err == nil {
info.TotalDisk = d.Total
}
populateNetwork(info)
if info.OSVersion == "" {
info.OSVersion = "unknown"
}
if pkgs, err := pkgmanager.ListPackages(); err == nil {
info.Packages = pkgs
}
if svcs, err := svcmgr.ListServices(); err == nil {
info.Services = svcs
}
return info, nil
}
func populateNetwork(info *SystemInfo) {
ifs, err := net.Interfaces()
if err != nil {
return
}
var primaryIP string
for _, ifc := range ifs {
if !containsFlag(ifc.Flags, "up") || containsFlag(ifc.Flags, "loopback") {
continue
}
ni := NetworkInterface{
Name: ifc.Name,
Addresses: []string{},
MAC: ifc.HardwareAddr,
}
for _, addr := range ifc.Addrs {
if addr.Addr == "" {
continue
}
ip := strings.TrimSpace(addr.Addr)
ni.Addresses = append(ni.Addresses, ip)
if primaryIP == "" && !strings.Contains(ip, ":") {
primaryIP = strings.Split(ip, "/")[0]
}
}
if len(ni.Addresses) > 0 {
info.NetworkInterfaces = append(info.NetworkInterfaces, ni)
}
}
if primaryIP != "" {
info.IPAddress = primaryIP
}
}
func containsFlag(flags []string, flag string) bool {
for _, f := range flags {
if strings.EqualFold(f, flag) {
return true
}
}
return false
}
@@ -0,0 +1,71 @@
package metrics
import (
"os"
"runtime"
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/host"
)
type Snapshot struct {
CPUPercent float64
MemoryPercent float64
MemoryUsed uint64
MemoryTotal uint64
DiskPercent float64
DiskUsed uint64
DiskTotal uint64
Load1 float64
Load5 float64
Load15 float64
UptimeSeconds uint64
}
func Collect() (*Snapshot, error) {
s := &Snapshot{}
if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
s.CPUPercent = percents[0]
}
if v, err := mem.VirtualMemory(); err == nil {
s.MemoryPercent = v.UsedPercent
s.MemoryUsed = v.Used
s.MemoryTotal = v.Total
}
if d, err := disk.Usage("/"); err == nil {
s.DiskPercent = d.UsedPercent
s.DiskUsed = d.Used
s.DiskTotal = d.Total
}
if avg, err := load.Avg(); err == nil {
s.Load1 = avg.Load1
s.Load5 = avg.Load5
s.Load15 = avg.Load15
}
if up, err := host.Uptime(); err == nil {
s.UptimeSeconds = up
}
if runtime.GOOS == "linux" {
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(data))
if len(fields) >= 3 {
s.Load1, _ = strconv.ParseFloat(fields[0], 64)
s.Load5, _ = strconv.ParseFloat(fields[1], 64)
s.Load15, _ = strconv.ParseFloat(fields[2], 64)
}
}
}
return s, nil
}
@@ -0,0 +1,354 @@
package pkgmanager
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Manager string `json:"manager"`
}
type ServiceInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
}
func DetectManager() string {
if runtime.GOOS == "windows" {
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
return "winget"
}
if _, err := exec.LookPath(resolveManagerPath("choco", interactiveProfile())); err == nil {
return "choco"
}
if _, err := exec.LookPath("msiexec"); err == nil {
return "msiexec"
}
return "unknown"
}
if _, err := exec.LookPath("apt"); err == nil {
return "apt"
}
if _, err := exec.LookPath("dnf"); err == nil {
return "dnf"
}
if _, err := exec.LookPath("yum"); err == nil {
return "yum"
}
return "unknown"
}
func ListPackages() ([]PackageInfo, error) {
if runtime.GOOS == "windows" {
return listWindowsPackages()
}
manager := DetectManager()
switch manager {
case "apt":
return listAptPackages()
case "yum", "dnf":
return listYumPackages()
case "winget":
return listWingetPackages()
}
return nil, fmt.Errorf("unsupported package manager: %s", manager)
}
func listAptPackages() ([]PackageInfo, error) {
cmd := exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Version}\n")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("dpkg-query failed: %w", err)
}
var packages []PackageInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) == 2 {
packages = append(packages, PackageInfo{
Name: parts[0],
Version: parts[1],
Manager: "apt",
})
}
}
return packages, nil
}
func listYumPackages() ([]PackageInfo, error) {
cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("rpm query failed: %w", err)
}
var packages []PackageInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) == 2 {
packages = append(packages, PackageInfo{
Name: parts[0],
Version: parts[1],
Manager: "yum",
})
}
}
return packages, nil
}
func listWingetPackages() ([]PackageInfo, error) {
cmd := exec.Command(resolveManagerPath("winget", interactiveProfile()), "list", "--accept-source-agreements", "--disable-interactivity")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("winget list failed: %w", err)
}
var packages []PackageInfo
lines := strings.Split(string(out), "\n")
inTable := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Name") && strings.Contains(trimmed, "Id") {
inTable = true
continue
}
if inTable && strings.Contains(trimmed, "---") {
continue
}
if inTable && trimmed != "" {
fields := strings.Fields(trimmed)
if len(fields) >= 3 {
pkg := PackageInfo{
Name: fields[0],
Version: fields[len(fields)-1],
Manager: "winget",
}
packages = append(packages, pkg)
}
}
}
return packages, nil
}
func InstallPackage(name string) (string, error) {
return installPackageWithManager(name, DetectManager(), "")
}
func InstallPackageWithManager(name, manager, version string) (string, error) {
return installPackageWithManager(name, manager, version)
}
func installPackageWithManager(name, manager, version string) (string, error) {
var executable string
var args []string
switch manager {
case "apt":
executable = "sudo"
args = []string{"apt-get", "install", "-y", name}
case "yum":
executable = "sudo"
args = []string{"yum", "install", "-y", name}
case "dnf":
executable = "sudo"
args = []string{"dnf", "install", "-y", name}
case "winget":
executable = "winget"
args = []string{"install", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
if version != "" {
args = append(args, "--version", version)
}
case "choco":
executable = "choco"
args = []string{"install", "-y", name}
if version != "" {
args = append(args, "--version", version)
}
case "msiexec":
executable = "msiexec"
args = []string{"/i", name, "/quiet", "/norestart"}
default:
return "", fmt.Errorf("unsupported package manager: %s", manager)
}
out, err := runManagerCommand(executable, args)
if err != nil {
return out, err
}
return out, nil
}
func InstallPackageFromFile(filePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch runtime.GOOS {
case "windows":
switch ext {
case ".msi":
cmd := exec.Command("msiexec", "/i", filePath, "/quiet", "/norestart")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("msi install failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
case ".exe":
cmd := exec.Command(filePath, "/S")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("exe install failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
case ".msix", ".appx":
cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Add-AppxPackage -Path '%s'", filePath))
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("msix install failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
}
case "linux":
switch ext {
case ".deb":
cmd := exec.Command("sudo", "dpkg", "-i", filePath)
out, err := cmd.CombinedOutput()
if err != nil {
cmd2 := exec.Command("sudo", "apt-get", "install", "-f", "-y")
out2, _ := cmd2.CombinedOutput()
return string(out) + "\n" + string(out2), fmt.Errorf("dpkg install failed: %w", err)
}
return string(out), nil
case ".rpm":
cmd := exec.Command("sudo", "rpm", "-i", filePath)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("rpm install failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
}
case "darwin":
switch ext {
case ".dmg":
cmd := exec.Command("hdiutil", "attach", filePath, "-nobrowse", "-quiet")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("dmg mount failed: %w", err)
}
return string(out), nil
case ".pkg":
cmd := exec.Command("sudo", "installer", "-pkg", filePath, "-target", "/")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("pkg install failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
}
}
return "", fmt.Errorf("unsupported file type %s for %s", ext, runtime.GOOS)
}
func UpdatePackage(name string) (string, error) {
manager := DetectManager()
var executable string
var args []string
switch manager {
case "apt":
executable = "sudo"
args = []string{"apt-get", "install", "-y", "--only-upgrade", name}
case "yum":
executable = "sudo"
args = []string{"yum", "update", "-y", name}
case "dnf":
executable = "sudo"
args = []string{"dnf", "update", "-y", name}
case "winget":
executable = "winget"
args = []string{"upgrade", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"}
case "choco":
executable = "choco"
args = []string{"upgrade", "-y", name}
default:
return "", fmt.Errorf("unsupported package manager: %s", manager)
}
out, err := runManagerCommand(executable, args)
if err != nil {
return out, err
}
return out, nil
}
func RemovePackage(name string) (string, error) {
return removePackageWithManager(name, DetectManager())
}
func removePackageWithManager(name, manager string) (string, error) {
var executable string
var args []string
switch manager {
case "apt":
executable = "sudo"
args = []string{"apt-get", "remove", "-y", name}
case "yum":
executable = "sudo"
args = []string{"yum", "remove", "-y", name}
case "dnf":
executable = "sudo"
args = []string{"dnf", "remove", "-y", name}
case "winget":
executable = "winget"
args = []string{"uninstall", "--id", name, "--accept-source-agreements", "--disable-interactivity"}
case "choco":
executable = "choco"
args = []string{"uninstall", "-y", name}
default:
return "", fmt.Errorf("unsupported package manager: %s", manager)
}
out, err := runManagerCommand(executable, args)
if err != nil {
return out, err
}
return out, nil
}
func RemovePackageWithManager(name, manager string) (string, error) {
return removePackageWithManager(name, manager)
}
func DownloadFile(url string, destPath string) error {
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
var cmd *exec.Cmd
if _, err := exec.LookPath("curl"); err == nil {
cmd = exec.Command("curl", "-L", "-o", destPath, url)
} else if _, err := exec.LookPath("wget"); err == nil {
cmd = exec.Command("wget", "-O", destPath, url)
} else {
return fmt.Errorf("neither curl nor wget found")
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %w\nOutput: %s", err, string(out))
}
return nil
}
@@ -0,0 +1,17 @@
//go:build !windows
package pkgmanager
import "fmt"
// BootstrapManagers is a no-op on non-Windows platforms.
// On Windows it installs the Microsoft App Installer (winget) and
// Chocolatey if they are not already present.
func BootstrapManagers() error {
return nil
}
// listWindowsPackages is only implemented on Windows.
func listWindowsPackages() ([]PackageInfo, error) {
return nil, fmt.Errorf("windows package listing only available on Windows")
}
@@ -0,0 +1,163 @@
//go:build windows
package pkgmanager
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/sys/windows/registry"
)
func listWindowsPackages() ([]PackageInfo, error) {
seen := make(map[string]PackageInfo)
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
return nil, fmt.Errorf("enumerate current user uninstall failed: %w", err)
}
if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
// non-fatal; 32-bit view may not exist
_ = err
}
if err := enumerateRegistryUninstall(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil {
_ = err
}
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
if wingetPkgs, err := listWingetPackages(); err == nil {
for _, p := range wingetPkgs {
key := p.Name + "@" + p.Manager
if _, ok := seen[key]; !ok {
seen[key] = p
}
}
}
}
packages := make([]PackageInfo, 0, len(seen))
for _, p := range seen {
packages = append(packages, p)
}
return packages, nil
}
func enumerateRegistryUninstall(root registry.Key, path string, seen map[string]PackageInfo) error {
k, err := registry.OpenKey(root, path, registry.READ)
if err != nil {
return err
}
defer k.Close()
subKeys, err := k.ReadSubKeyNames(0)
if err != nil {
return err
}
for _, name := range subKeys {
sk, err := registry.OpenKey(k, name, registry.READ)
if err != nil {
continue
}
displayName, _, err := sk.GetStringValue("DisplayName")
if err != nil || displayName == "" {
sk.Close()
continue
}
displayVersion, _, _ := sk.GetStringValue("DisplayVersion")
version := strings.TrimSpace(displayVersion)
if version == "" {
version = "unknown"
}
key := displayName + "@windows"
if _, ok := seen[key]; !ok {
seen[key] = PackageInfo{
Name: displayName,
Version: version,
Manager: "windows",
}
}
sk.Close()
}
return nil
}
func BootstrapManagers() error {
var errs []string
if !lookupWinget() {
if err := installWingetViaAppInstaller(); err != nil {
errs = append(errs, "winget: "+err.Error())
}
}
if _, err := exec.LookPath("choco"); err != nil {
if err := installChocolatey(); err != nil {
errs = append(errs, "chocolatey: "+err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("bootstrap failed: %s", strings.Join(errs, "; "))
}
return nil
}
func installChocolatey() error {
// Official Chocolatey install script (requires admin; agent runs as LocalSystem).
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"Set-ExecutionPolicy Bypass -Scope Process -Force; "+
"[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; "+
"iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("chocolatey install script failed: %w\nOutput: %s", err, string(out))
}
return nil
}
func lookupWinget() bool {
if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil {
return true
}
appInstaller := `Microsoft.DesktopAppInstaller_8wekyb3d8bbwe`
appxReg, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore`,
registry.READ|registry.ENUMERATE_SUB_KEYS)
if err == nil {
defer appxReg.Close()
if keys, err := appxReg.ReadSubKeyNames(0); err == nil {
for _, keyName := range keys {
if strings.Contains(keyName, "DesktopAppInstaller") {
_ = appInstaller
return true
}
}
}
}
return false
}
func installWingetViaAppInstaller() error {
// Microsoft App Installer download link (redirects to latest winget bundle)
installerURL := "https://aka.ms/getwinget"
appInstallerPath := filepath.Join(os.TempDir(), "DesktopAppInstaller.msixbundle")
// Download App Installer bundle
downloadCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
"[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; "+
"Invoke-WebRequest -Uri '"+installerURL+"' -OutFile '"+appInstallerPath+"' -UseBasicParsing")
if out, err := downloadCmd.CombinedOutput(); err != nil {
return fmt.Errorf("download App Installer failed: %w\nOutput: %s", err, string(out))
}
// Install bundle (dependencies may need to be allowed; add-AppxPackage on msixbundle handles it)
installCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
"Add-AppxPackage -Path '"+appInstallerPath+"' -ForceUpdateFromAnyVersion")
if out, err := installCmd.CombinedOutput(); err != nil {
return fmt.Errorf("install App Installer failed: %w\nOutput: %s", err, string(out))
}
return nil
}
@@ -0,0 +1,33 @@
//go:build !windows
package pkgmanager
import (
"fmt"
"os/exec"
)
func resolveManagerPath(executable, profile string) string {
return executable
}
func interactiveProfile() string {
return ""
}
func runManagerCommand(executable string, args []string) (string, error) {
cmd := exec.Command(executable, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
}
func LaunchInUserSession(exe string, args []string) (uint32, error) {
return 0, fmt.Errorf("LaunchInUserSession is not supported on this platform")
}
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
return "", -1, fmt.Errorf("RunInUserSessionCapture is not supported on this platform")
}
@@ -0,0 +1,346 @@
//go:build windows
package pkgmanager
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
// wingetPath returns a usable winget.exe for the given user profile, falling
// back to SYSTEM/global locations. winget is a UWP app execution alias that is
// commonly missing from the PATH of service accounts (e.g. LocalSystem).
func wingetPath(profile string) string {
if profile != "" {
if p := fileExists(filepath.Join(profile, "AppData", "Local", "Microsoft", "WindowsApps", "winget.exe")); p != "" {
return p
}
}
if p, err := exec.LookPath("winget"); err == nil {
return p
}
for _, base := range []string{
os.ExpandEnv(`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`),
os.ExpandEnv(`%SystemRoot%\System32\config\systemprofile\AppData\Local\Microsoft\WindowsApps\winget.exe`),
} {
if p := fileExists(base); p != "" {
return p
}
}
matches, _ := filepath.Glob(filepath.Join(os.ExpandEnv(`%ProgramFiles%`), "WindowsApps", "Microsoft.DesktopAppInstaller_*", "winget.exe"))
if len(matches) > 0 {
sort.Strings(matches)
return matches[len(matches)-1]
}
return ""
}
func chocoPath() string {
if p, err := exec.LookPath("choco"); err == nil {
return p
}
if p := fileExists(filepath.Join(os.ExpandEnv(`%ProgramData%`), "chocolatey", "bin", "choco.exe")); p != "" {
return p
}
return ""
}
func resolveManagerPath(executable, profile string) string {
switch executable {
case "winget":
if p := wingetPath(profile); p != "" {
return p
}
case "choco":
if profile != "" {
if p := fileExists(filepath.Join(profile, "AppData", "Local", "chocolatey", "bin", "choco.exe")); p != "" {
return p
}
}
if p := chocoPath(); p != "" {
return p
}
}
return ""
}
// interactiveProfile returns the profile directory of the logged-on user, or ""
// when no interactive session exists.
func interactiveProfile() string {
token, profile, err := interactiveUser()
if err != nil {
return ""
}
token.Close()
return profile
}
// runManagerCommand runs a package manager command. winget and choco runs are
// executed in the context of the interactive (logged-on) user so that the winget
// app execution alias resolves and per-user installers land in the user's own
// profile (and therefore are visible to them). If the user context lacks
// elevation, the command is retried in the SYSTEM context.
func runManagerCommand(executable string, args []string) (string, error) {
switch executable {
case "choco", "winget":
token, profile, err := interactiveUser()
if err == nil {
defer token.Close()
exe := resolveManagerPath(executable, profile)
if exe == "" {
return "", fmt.Errorf("%s not found in user context or system locations", executable)
}
out, code, spawnErr := runAsUser(token, profile, exe, args)
if spawnErr == nil {
if code == 0 {
return out, nil
}
if !needsElevation(out) {
return out, fmt.Errorf("%s failed with exit code %d\nOutput: %s", executable, code, out)
}
}
// user context failed to launch or needs elevation: retry as SYSTEM
}
}
exe := resolveManagerPath(executable, "")
if exe == "" {
return "", fmt.Errorf("%s not found in any known location", executable)
}
cmd := exec.Command(exe, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out))
}
return string(out), nil
}
// interactiveUser returns a duplicated primary token for the logged-on user and
// their profile directory. It fails when no interactive session exists.
func interactiveUser() (windows.Token, string, error) {
var sessionID uint32
var count uint32
var sessions *windows.WTS_SESSION_INFO
if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil {
sessionID = windows.WTSGetActiveConsoleSessionId()
} else {
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
info := unsafe.Slice(sessions, count)
for _, s := range info {
if s.SessionID != 0 && (s.State == windows.WTSActive || s.State == windows.WTSConnected) {
sessionID = s.SessionID
break
}
}
}
if sessionID == 0 {
return 0, "", fmt.Errorf("no interactive session")
}
var token windows.Token
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return 0, "", fmt.Errorf("WTSQueryUserToken: %w", err)
}
var dup windows.Token
if err := windows.DuplicateTokenEx(token, windows.TOKEN_ALL_ACCESS, nil, windows.SecurityImpersonation, windows.TokenPrimary, &dup); err != nil {
token.Close()
return 0, "", fmt.Errorf("DuplicateTokenEx: %w", err)
}
token.Close()
profile, err := userProfileDir(dup)
if err != nil {
dup.Close()
return 0, "", fmt.Errorf("GetUserProfileDirectory: %w", err)
}
return dup, profile, nil
}
func userProfileDir(token windows.Token) (string, error) {
buf := make([]uint16, 1024)
for {
size := uint32(len(buf))
err := windows.GetUserProfileDirectory(token, &buf[0], &size)
if err == nil {
return windows.UTF16ToString(buf), nil
}
if err != windows.ERROR_INSUFFICIENT_BUFFER {
return "", err
}
buf = make([]uint16, size)
}
}
// runAsUser runs executable in the given user's context, redirecting stdout and
// stderr to a temp file via inherited handles. Returns combined output and the
// process exit code.
func runAsUser(token windows.Token, profile, exe string, args []string) (string, int, error) {
outFile, err := os.CreateTemp("", "ff-agent-out-*.txt")
if err != nil {
return "", -1, fmt.Errorf("CreateTemp: %w", err)
}
outPath := outFile.Name()
outFile.Close()
defer os.Remove(outPath)
var sa windows.SecurityAttributes
sa.Length = uint32(unsafe.Sizeof(sa))
sa.InheritHandle = 1
shareMode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE)
outHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(outPath), windows.GENERIC_WRITE, shareMode, &sa, windows.CREATE_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return "", -1, fmt.Errorf("CreateFile output: %w", err)
}
defer windows.CloseHandle(outHandle)
nulHandle, err := windows.CreateFile(windows.StringToUTF16Ptr("NUL"), windows.GENERIC_READ, shareMode, &sa, windows.OPEN_EXISTING, 0, 0)
if err != nil {
return "", -1, fmt.Errorf("CreateFile NUL: %w", err)
}
defer windows.CloseHandle(nulHandle)
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
if err != nil {
return "", -1, fmt.Errorf("UTF16PtrFromString: %w", err)
}
var envBlock *uint16
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
envBlock = nil
} else {
defer windows.DestroyEnvironmentBlock(envBlock)
}
cwd, _ := windows.UTF16PtrFromString(profile)
if cwd == nil || *cwd == 0 {
cwd = nil
}
var si windows.StartupInfo
si.Cb = uint32(unsafe.Sizeof(si))
si.Flags = windows.STARTF_USESTDHANDLES
si.StdInput = nulHandle
si.StdOutput = outHandle
si.StdErr = outHandle
var pi windows.ProcessInformation
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, true, flags, envBlock, cwd, &si, &pi)
if err != nil {
return "", -1, fmt.Errorf("CreateProcessAsUser: %w", err)
}
windows.CloseHandle(pi.Thread)
defer windows.CloseHandle(pi.Process)
_, _ = windows.WaitForSingleObject(pi.Process, windows.INFINITE)
var exitCode uint32
_ = windows.GetExitCodeProcess(pi.Process, &exitCode)
out, _ := os.ReadFile(outPath)
return string(out), int(exitCode), nil
}
func windowsQuote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// RunInUserSessionCapture runs an executable in the context of the logged-on
// interactive user and returns its combined output and exit code. It is used
// e.g. to write per-user registry settings for the VNC server.
func RunInUserSessionCapture(exe string, args []string) (string, int, error) {
token, profile, err := interactiveUser()
if err != nil {
return "", -1, err
}
defer token.Close()
return runAsUser(token, profile, exe, args)
}
// LaunchInUserSession launches an executable in the context of the logged-on
// interactive user without waiting for it to finish. It returns the process ID
// of the launched process, which can be used to terminate it later. This is
// used e.g. to start a VNC server inside the user's desktop session.
func LaunchInUserSession(exe string, args []string) (uint32, error) {
token, profile, err := interactiveUser()
if err != nil {
return 0, err
}
defer token.Close()
commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args))
if err != nil {
return 0, fmt.Errorf("UTF16PtrFromString: %w", err)
}
var envBlock *uint16
if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil {
envBlock = nil
} else {
defer windows.DestroyEnvironmentBlock(envBlock)
}
cwd, _ := windows.UTF16PtrFromString(profile)
if cwd == nil || *cwd == 0 {
cwd = nil
}
var si windows.StartupInfo
si.Cb = uint32(unsafe.Sizeof(si))
si.Flags = windows.STARTF_USESHOWWINDOW
si.ShowWindow = windows.SW_SHOWNORMAL
var pi windows.ProcessInformation
flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, false, flags, envBlock, cwd, &si, &pi)
if err != nil {
return 0, fmt.Errorf("CreateProcessAsUser: %w", err)
}
windows.CloseHandle(pi.Thread)
windows.CloseHandle(pi.Process)
return pi.ProcessId, nil
}
func windowsQuoteArgs(args []string) string {
out := make([]string, 0, len(args))
for _, a := range args {
out = append(out, windowsQuote(a))
}
return strings.Join(out, " ")
}
func fileExists(p string) string {
if p == "" {
return ""
}
if _, err := os.Stat(p); err == nil {
return p
}
return ""
}
func needsElevation(output string) bool {
low := strings.ToLower(output)
for _, s := range []string{
"elevat",
"administrator",
"requires authentication",
"access is denied",
"access denied",
"run as admin",
} {
if strings.Contains(low, s) {
return true
}
}
return false
}
@@ -0,0 +1,288 @@
package svcmgr
import (
"fmt"
"os/exec"
"runtime"
"strings"
)
type ServiceInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
}
func ListServices() ([]ServiceInfo, error) {
switch runtime.GOOS {
case "windows":
return listWindowsServices()
case "darwin":
return listDarwinServices()
default:
return listLinuxServices()
}
}
func GetServiceStatus(name string) (string, bool, error) {
switch runtime.GOOS {
case "windows":
return getWindowsServiceStatus(name)
case "darwin":
return getDarwinServiceStatus(name)
default:
return getLinuxServiceStatus(name)
}
}
func StartService(name string) (string, error) {
switch runtime.GOOS {
case "windows":
return startWindowsService(name)
case "darwin":
return startDarwinService(name)
default:
return startLinuxService(name)
}
}
func StopService(name string) (string, error) {
switch runtime.GOOS {
case "windows":
return stopWindowsService(name)
case "darwin":
return stopDarwinService(name)
default:
return stopLinuxService(name)
}
}
func RestartService(name string) (string, error) {
switch runtime.GOOS {
case "windows":
return restartWindowsService(name)
case "darwin":
return restartDarwinService(name)
default:
return restartLinuxService(name)
}
}
func Reboot() error {
switch runtime.GOOS {
case "windows":
return exec.Command("shutdown", "/r", "/t", "0").Run()
case "darwin":
return exec.Command("sudo", "shutdown", "-r", "now").Run()
default:
return exec.Command("sudo", "reboot").Run()
}
}
func Shutdown() error {
switch runtime.GOOS {
case "windows":
return exec.Command("shutdown", "/s", "/t", "0").Run()
case "darwin":
return exec.Command("sudo", "shutdown", "-h", "now").Run()
default:
return exec.Command("sudo", "poweroff").Run()
}
}
func listWindowsServices() ([]ServiceInfo, error) {
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-Service -ErrorAction SilentlyContinue | ForEach-Object { '{0}`t{1}`t{2}' -f $_.Name, $_.Status, $_.StartType }").Output()
if err != nil {
return nil, fmt.Errorf("Get-Service failed: %w", err)
}
var services []ServiceInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Split(strings.TrimSpace(line), "\t")
if len(fields) < 3 {
continue
}
services = append(services, ServiceInfo{
Name: fields[0],
Status: strings.ToLower(fields[1]),
Enabled: strings.EqualFold(fields[2], "Automatic"),
})
}
return services, nil
}
func getWindowsServiceStatus(name string) (string, bool, error) {
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
fmt.Sprintf("$s = Get-Service -Name '%s' -ErrorAction Stop; '{0}`t{1}' -f $s.Status, $s.StartType", name)).Output()
if err != nil {
return "", false, fmt.Errorf("Get-Service failed: %w", err)
}
fields := strings.Split(strings.TrimSpace(string(out)), "\t")
if len(fields) < 2 {
return "", false, fmt.Errorf("unexpected Get-Service output: %s", strings.TrimSpace(string(out)))
}
return strings.ToLower(fields[0]), strings.EqualFold(fields[1], "Automatic"), nil
}
func startWindowsService(name string) (string, error) {
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
fmt.Sprintf("Start-Service -Name '%s' -ErrorAction Stop", name))
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
}
return "started", nil
}
func stopWindowsService(name string) (string, error) {
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
fmt.Sprintf("Stop-Service -Name '%s' -ErrorAction Stop", name))
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
}
return "stopped", nil
}
func restartWindowsService(name string) (string, error) {
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command",
fmt.Sprintf("Restart-Service -Name '%s' -ErrorAction Stop", name))
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out))
}
return "restarted", nil
}
func listDarwinServices() ([]ServiceInfo, error) {
out, err := exec.Command("launchctl", "list").Output()
if err != nil {
return nil, fmt.Errorf("launchctl list failed: %w", err)
}
var services []ServiceInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
status := "unknown"
if fields[0] != "-" {
status = "running"
}
services = append(services, ServiceInfo{
Name: fields[2],
Status: status,
})
}
return services, nil
}
func getDarwinServiceStatus(name string) (string, bool, error) {
out, err := exec.Command("launchctl", "list").Output()
if err != nil {
return "", false, fmt.Errorf("launchctl list failed: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] {
fields := strings.Fields(line)
if len(fields) >= 3 && fields[2] == name {
if fields[0] != "-" {
return "running", true, nil
}
return "inactive", false, nil
}
}
return "not_found", false, nil
}
func startDarwinService(name string) (string, error) {
cmd := exec.Command("launchctl", "load", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name))
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
}
return "started", nil
}
func stopDarwinService(name string) (string, error) {
cmd := exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name))
if out, err := cmd.CombinedOutput(); err != nil {
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
}
return "stopped", nil
}
func restartDarwinService(name string) (string, error) {
stopDarwinService(name)
return startDarwinService(name)
}
func listLinuxServices() ([]ServiceInfo, error) {
cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend", "--plain", "--no-accept-unknown")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("systemctl list-units failed: %w", err)
}
var services []ServiceInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
svcName := strings.TrimSuffix(fields[0], ".service")
status := fields[2]
enabled := fields[3] == "loaded"
services = append(services, ServiceInfo{
Name: svcName,
Status: status,
Enabled: enabled,
})
}
return services, nil
}
func getLinuxServiceStatus(name string) (string, bool, error) {
cmd := exec.Command("systemctl", "is-active", name)
out, err := cmd.Output()
status := strings.TrimSpace(string(out))
enabledCmd := exec.Command("systemctl", "is-enabled", name)
enabledOut, _ := enabledCmd.Output()
enabled := strings.TrimSpace(string(enabledOut)) == "enabled"
if err != nil && status != "inactive" {
return status, enabled, fmt.Errorf("systemctl status failed: %w", err)
}
return status, enabled, nil
}
func startLinuxService(name string) (string, error) {
cmd := exec.Command("sudo", "systemctl", "start", name)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out))
}
return "started", nil
}
func stopLinuxService(name string) (string, error) {
cmd := exec.Command("sudo", "systemctl", "stop", name)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out))
}
return "stopped", nil
}
func restartLinuxService(name string) (string, error) {
cmd := exec.Command("sudo", "systemctl", "restart", name)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out))
}
return "restarted", nil
}
@@ -0,0 +1,119 @@
package tunnel
import (
"fmt"
"io"
"log"
"net"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type Tunnel struct {
wsURL string
port int
sessionID string
ws *websocket.Conn
tcp net.Conn
stop chan struct{}
once sync.Once
}
// New returns a tunnel that pipes traffic between the Fleet Forge server
// (WebSocket) and the local VNC server (127.0.0.1:port). It does not connect
// until Run is called.
func New(serverURL, credential, sessionID string, vncPort int) *Tunnel {
wsURL := strings.Replace(serverURL, "https://", "wss://", 1)
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
wsURL = strings.TrimSuffix(wsURL, "/")
q := url.Values{}
q.Set("session_id", sessionID)
q.Set("token", credential)
full := wsURL + "/api/v1/remote/agent?" + q.Encode()
return &Tunnel{
wsURL: full,
port: vncPort,
sessionID: sessionID,
stop: make(chan struct{}),
}
}
// Run dials the server WebSocket and the local VNC port, then pipes bytes in
// both directions until either side closes or Stop is called.
func (t *Tunnel) Run() error {
ws, _, err := websocket.DefaultDialer.Dial(t.wsURL, nil)
if err != nil {
return fmt.Errorf("tunnel dial (server): %w", err)
}
defer ws.Close()
t.ws = ws
tcp, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", t.port), 10*time.Second)
if err != nil {
return fmt.Errorf("tunnel dial (local VNC): %w", err)
}
defer tcp.Close()
t.tcp = tcp
log.Printf("[tunnel] connected for session %s (vnc 127.0.0.1:%d)", t.sessionID, t.port)
done := make(chan error, 2)
go func() {
for {
_, data, err := ws.ReadMessage()
if err != nil {
done <- err
return
}
if _, err := tcp.Write(data); err != nil {
done <- err
return
}
}
}()
go func() {
buf := make([]byte, 8192)
for {
n, err := tcp.Read(buf)
if n > 0 {
if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
done <- err
return
}
}
if err != nil {
done <- err
return
}
}
}()
select {
case err := <-done:
return err
case <-t.stop:
return io.EOF
}
}
// Stop closes the underlying connections, causing Run to return.
func (t *Tunnel) Stop() {
t.once.Do(func() {
close(t.stop)
if t.ws != nil {
_ = t.ws.Close()
}
if t.tcp != nil {
_ = t.tcp.Close()
}
})
}
+283
View File
@@ -0,0 +1,283 @@
package vnc
import (
"crypto/des"
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/fleetforge/forge-agent/internal/pkgmanager"
)
const waitForPortTimeout = 25 * time.Second
type Server struct {
port int
pid int
passFile string
}
// Ensure starts a VNC server on the given port with the given password and
// returns a handle that can be used to stop it again.
func Ensure(port int, password string) (*Server, error) {
switch runtime.GOOS {
case "windows":
return startTightVNC(port, password)
case "linux":
return startX11VNC(port, password)
default:
return nil, fmt.Errorf("remote desktop is not supported on %s yet", runtime.GOOS)
}
}
func (s *Server) Stop() {
if s == nil {
return
}
switch runtime.GOOS {
case "windows":
stopTightVNC(s)
default:
stopX11VNC(s)
}
}
func (s *Server) Port() int {
return s.port
}
// ---------------------------------------------------------------- Windows
func tightVNCPath() string {
for _, base := range []string{
os.ExpandEnv(`%ProgramFiles%\TightVNC\tvnserver.exe`),
os.ExpandEnv(`%ProgramFiles(x86)%\TightVNC\tvnserver.exe`),
`C:\Program Files\TightVNC\tvnserver.exe`,
`C:\Program Files (x86)\TightVNC\tvnserver.exe`,
} {
if _, err := os.Stat(base); err == nil {
return base
}
}
return ""
}
func quickRegAdd(subkey, name, value, regType string) error {
cmd := exec.Command("reg", "add", subkey, "/v", name, "/t", regType, "/d", value, "/f")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("reg add %s: %w (%s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// configTightVNC writes the TightVNC server settings to both the global (HKLM)
// and the current user (HKCU) registry locations. Application mode reads the
// user settings, while the (optional) service mode reads the global ones, so we
// configure both. HKCU is written as the interactive user because reg.exe under
// the SYSTEM account would target the system profile instead.
func configTightVNC(port int, enc []byte) error {
type entry struct {
name string
val string
typ string
}
entries := []entry{
{"RfbPort", strconv.Itoa(port), "REG_DWORD"},
{"UsePasswordAuthentication", "1", "REG_DWORD"},
{"AcceptLoopbackConnections", "1", "REG_DWORD"},
{"Password", hex.EncodeToString(enc), "REG_BINARY"},
}
const globalKey = `HKLM\Software\TightVNC\Server`
const userKey = `HKCU\Software\TightVNC\Server`
write := func(subkey string, inUserSession bool) error {
for _, e := range entries {
args := []string{"add", subkey, "/v", e.name, "/t", e.typ, "/d", e.val, "/f"}
var err error
if inUserSession {
_, code, uerr := pkgmanager.RunInUserSessionCapture("reg", args)
if uerr != nil {
err = uerr
} else if code != 0 {
err = fmt.Errorf("reg add %s (as user) exited %d", e.name, code)
}
} else {
err = quickRegAdd(subkey, e.name, e.val, e.typ)
}
if err != nil {
return fmt.Errorf("set %s\\%s: %w", subkey, e.name, err)
}
}
return nil
}
if err := write(globalKey, false); err != nil {
return err
}
if err := write(userKey, true); err != nil {
return err
}
return nil
}
func startTightVNC(port int, password string) (*Server, error) {
exe := tightVNCPath()
if exe == "" {
if _, err := pkgmanager.InstallPackageWithManager("TightVNC.TightVNC", "winget", ""); err != nil {
if _, ferr := pkgmanager.InstallPackageWithManager("tightvnc", "choco", ""); ferr != nil {
return nil, fmt.Errorf("tightvnc not installed and automatic install failed: %v (fallback %v)", err, ferr)
}
}
exe = tightVNCPath()
if exe == "" {
return nil, fmt.Errorf("failed to locate tvnserver.exe after installation")
}
}
// Best effort: stop any TightVNC service so it does not fight on the port.
stopTightVNCServices()
enc, err := encryptPassword(password)
if err != nil {
return nil, fmt.Errorf("vnc password encryption failed: %w", err)
}
if err := configTightVNC(port, enc); err != nil {
return nil, err
}
// Start in the interactive user's session so the real desktop is served.
pid, err := pkgmanager.LaunchInUserSession(exe, []string{"-run"})
if err != nil {
return nil, fmt.Errorf("failed to start tvnserver in user session: %w", err)
}
srv := &Server{port: port, pid: int(pid)}
if err := waitForPort(port); err != nil {
srv.Stop()
return nil, err
}
return srv, nil
}
func stopTightVNCServices() {
for _, name := range []string{"tvnserver", "TightVNC"} {
_ = exec.Command("sc", "stop", name).Run()
}
}
func stopTightVNC(s *Server) {
stopTightVNCServices()
if s.pid > 0 {
if proc, err := os.FindProcess(s.pid); err == nil {
_ = proc.Kill()
}
}
}
// encryptPassword produces the DES-encrypted 8-byte form used by the VNC
// servers to store the password (fixed key with bit-reversed bytes).
func encryptPassword(password string) ([]byte, error) {
buf := make([]byte, 8)
copy(buf, []byte(password))
key := []byte{0xe8, 0x4a, 0xd6, 0x60, 0xc4, 0x72, 0x1a, 0xe3}
for i := range key {
key[i] = bitReverse(key[i])
}
block, err := des.NewCipher(key)
if err != nil {
return nil, err
}
out := make([]byte, 8)
block.Encrypt(out, buf)
return out, nil
}
func bitReverse(b byte) byte {
var r byte
for i := 0; i < 8; i++ {
r = r<<1 | (b & 1)
b >>= 1
}
return r
}
// ---------------------------------------------------------------- Linux
func startX11VNC(port int, password string) (*Server, error) {
exe, err := exec.LookPath("x11vnc")
if err != nil {
if _, ierr := pkgmanager.InstallPackage("x11vnc"); ierr == nil {
exe, err = exec.LookPath("x11vnc")
}
if err != nil {
return nil, fmt.Errorf("x11vnc is not installed and could not be installed")
}
}
display := os.Getenv("DISPLAY")
if display == "" {
display = ":0"
}
passFile := filepath.Join(os.TempDir(), fmt.Sprintf("ff-vnc-pass-%d.txt", time.Now().UnixNano()))
if out, err := exec.Command(exe, "-storepasswd", password, passFile).CombinedOutput(); err != nil {
return nil, fmt.Errorf("x11vnc -storepasswd failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
cmd := exec.Command(
exe,
"-display", display,
"-rfbport", strconv.Itoa(port),
"-rfbauth", passFile,
"-forever",
"-shared",
)
if err := cmd.Start(); err != nil {
os.Remove(passFile)
return nil, fmt.Errorf("failed to start x11vnc: %w", err)
}
srv := &Server{port: port, pid: cmd.Process.Pid}
srv.passFile = passFile
if err := waitForPort(port); err != nil {
srv.Stop()
return nil, err
}
return srv, nil
}
func stopX11VNC(s *Server) {
if s.pid > 0 {
if proc, err := os.FindProcess(s.pid); err == nil {
_ = proc.Kill()
}
}
if s.passFile != "" {
os.Remove(s.passFile)
}
}
func waitForPort(port int) error {
deadline := time.Now().Add(waitForPortTimeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 2*time.Second)
if err == nil {
conn.Close()
return nil
}
time.Sleep(1 * time.Second)
}
return fmt.Errorf("VNC server did not start listening on port %d", port)
}
@@ -0,0 +1,142 @@
//go:build windows
package winsvc
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/fleetforge/forge-agent/internal/config"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
"golang.org/x/sys/windows/svc/mgr"
)
func InstallService(serverURL, enrollmentToken, label string) error {
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return fmt.Errorf("failed to resolve executable path: %w", err)
}
cfg := &config.Config{
ServerURL: serverURL,
EnrollmentToken: enrollmentToken,
Label: label,
HeartbeatSec: 30,
}
if err := config.Save(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Config saved to: %s\n", config.ConfigPath())
scm, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to Service Control Manager: %w", err)
}
defer scm.Disconnect()
existing, err := scm.OpenService(ServiceName)
if err == nil {
existing.Close()
return fmt.Errorf("service already exists: %s", ServiceName)
}
s, err := scm.CreateService(ServiceName, exePath, mgr.Config{
DisplayName: ServiceDisplayName,
Description: ServiceDescription,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
}, "run", "--service")
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
defer s.Close()
if err := eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil {
s.Delete()
return fmt.Errorf("failed to install event log source: %w", err)
}
fmt.Printf("Service '%s' installed successfully.\n", ServiceDisplayName)
fmt.Println("The service will start automatically on system boot.")
if err := s.Start(); err != nil {
fmt.Printf("Warning: Could not start service: %v\n", err)
} else {
waitUntilRunning(s)
fmt.Println("Service started.")
}
return nil
}
func UninstallService() error {
scm, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to Service Control Manager: %w", err)
}
defer scm.Disconnect()
s, err := scm.OpenService(ServiceName)
if err != nil {
return fmt.Errorf("service does not exist: %s", ServiceName)
}
defer s.Close()
st, err := s.Control(svc.Stop)
if err == nil {
waitUntilStopped(s, st)
}
if err := eventlog.Remove(ServiceName); err != nil {
fmt.Printf("Warning: Could not remove event log source: %v\n", err)
}
if err := s.Delete(); err != nil {
return fmt.Errorf("failed to delete service: %w", err)
}
fmt.Printf("Service '%s' removed successfully.\n", ServiceDisplayName)
if err := config.Delete(); err != nil && !os.IsNotExist(err) {
fmt.Printf("Warning: Could not remove config file: %v\n", err)
} else {
fmt.Println("Config file removed.")
}
return nil
}
func waitUntilStopped(s *mgr.Service, initialStatus svc.Status) {
state := initialStatus.State
for i := 0; i < 30; i++ {
if state == svc.Stopped {
return
}
time.Sleep(time.Second)
st, err := s.Query()
if err != nil {
return
}
state = st.State
}
}
func waitUntilRunning(s *mgr.Service) {
for i := 0; i < 30; i++ {
st, err := s.Query()
if err != nil {
return
}
if st.State == svc.Running {
return
}
time.Sleep(time.Second)
}
}
@@ -0,0 +1,76 @@
//go:build windows
package winsvc
import (
"fmt"
"log"
"time"
"github.com/fleetforge/forge-agent/internal/agent"
"github.com/fleetforge/forge-agent/internal/config"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
)
const ServiceName = "FleetForgeAgent"
const ServiceDisplayName = "Fleet Forge Agent"
const ServiceDescription = "Fleet Forge endpoint management agent."
type agentService struct {
cfg *config.Config
}
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
status <- svc.Status{State: svc.StartPending, Accepts: svc.AcceptStop | svc.AcceptShutdown}
elog, err := eventlog.Open(ServiceName)
if err == nil {
defer elog.Close()
elog.Info(1, "Fleet Forge Agent service starting")
}
a := agent.New(s.cfg)
done := make(chan error, 1)
go func() {
done <- a.Run()
}()
status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
for {
select {
case err := <-done:
if err != nil {
log.Printf("[service] Agent stopped with error: %v", err)
if elog != nil {
elog.Error(1, fmt.Sprintf("Agent stopped: %v", err))
}
}
return false, 0
case c := <-r:
switch c.Cmd {
case svc.Stop, svc.Shutdown:
log.Println("[service] Stop/Shutdown received")
status <- svc.Status{State: svc.StopPending}
return false, 0
case svc.Interrogate:
status <- c.CurrentStatus
}
case <-time.After(5 * time.Second):
}
}
}
func RunAsService() error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
svcInst := &agentService{cfg: cfg}
return svc.Run(ServiceName, svcInst)
}
@@ -0,0 +1,17 @@
//go:build !windows
package winsvc
import "fmt"
func RunAsService() error {
return fmt.Errorf("windows service is not supported on this platform")
}
func InstallService(serverURL, enrollmentToken, label string) error {
return fmt.Errorf("windows service installation is not supported on this platform")
}
func UninstallService() error {
return fmt.Errorf("windows service uninstallation is not supported on this platform")
}