feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user