feat(windows): platform ops, service wrapper, helper, and air-gap paths
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).
- Add a PlatformOps abstraction so command dispatch is OS-neutral:
- linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
untagged so shared dispatch tests run on Windows CI)
- windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
user32/wtsapi32, and staged self-update (wait for service stop, swap the
locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
service runs as SYSTEM while the tray runs as the user, so the per-user temp
dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
POSIX so the suite is green on Windows
Rebuilds all tracked dist binaries (v2.1.0).
This commit is contained in:
@@ -4,3 +4,5 @@ theta-agent-*
|
|||||||
dist/
|
dist/
|
||||||
*.exe
|
*.exe
|
||||||
agent.yml
|
agent.yml
|
||||||
|
!cmd/theta-agent-helper/
|
||||||
|
!cmd/theta-agent-helper/main.go
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_D
|
|||||||
echo " -> windows/arm64..."
|
echo " -> windows/arm64..."
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe"
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe"
|
||||||
|
|
||||||
|
echo " -> windows/amd64 helper..."
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-amd64.exe" ./cmd/theta-agent-helper/
|
||||||
|
|
||||||
|
echo " -> windows/arm64 helper..."
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-arm64.exe" ./cmd/theta-agent-helper/
|
||||||
|
|
||||||
echo " -> darwin/amd64 (macOS Intel)..."
|
echo " -> darwin/amd64 (macOS Intel)..."
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64"
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64"
|
||||||
|
|
||||||
@@ -38,6 +44,12 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR
|
|||||||
echo " -> linux/arm64 tray..."
|
echo " -> linux/arm64 tray..."
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/
|
||||||
|
|
||||||
|
echo " -> windows/amd64 tray..."
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-amd64.exe" ./cmd/theta-agent-tray/
|
||||||
|
|
||||||
|
echo " -> windows/arm64 tray..."
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-arm64.exe" ./cmd/theta-agent-tray/
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Build complete! Artifacts in $DIST_DIR:"
|
echo "Build complete! Artifacts in $DIST_DIR:"
|
||||||
ls -lh "$DIST_DIR"
|
ls -lh "$DIST_DIR"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,12 @@ func handleCLI(args []string) bool {
|
|||||||
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
||||||
runReinitialize(args[1:])
|
runReinitialize(args[1:])
|
||||||
return true
|
return true
|
||||||
|
case "install-service":
|
||||||
|
handleServiceCommand(args[1:])
|
||||||
|
return true
|
||||||
|
case "remove-service", "uninstall-service":
|
||||||
|
handleServiceCommand(append([]string{"remove"}, args[1:]...))
|
||||||
|
return true
|
||||||
case "--version", "version", "-v":
|
case "--version", "version", "-v":
|
||||||
fmt.Println("Theta Agent " + AgentVersion)
|
fmt.Println("Theta Agent " + AgentVersion)
|
||||||
return true
|
return true
|
||||||
@@ -50,6 +57,8 @@ func printUsage() {
|
|||||||
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
|
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
|
||||||
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
|
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
|
||||||
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
|
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
|
||||||
|
fmt.Println(" theta-agent install-service (Windows) register the agent as a service")
|
||||||
|
fmt.Println(" theta-agent remove-service (Windows) unregister the agent service")
|
||||||
fmt.Println(" theta-agent version Show version info")
|
fmt.Println(" theta-agent version Show version info")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Reinitialize Flags:")
|
fmt.Println("Reinitialize Flags:")
|
||||||
@@ -58,7 +67,7 @@ func printUsage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSelfUpdate(args []string) {
|
func runSelfUpdate(args []string) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
cm, err := NewConfigManager(configPath)
|
cm, err := NewConfigManager(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
||||||
@@ -69,7 +78,16 @@ func runSelfUpdate(args []string) {
|
|||||||
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
|
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
|
arch := "amd64"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
arch = "arm64"
|
||||||
|
}
|
||||||
|
ext := ""
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
artifact := fmt.Sprintf("theta-agent-%s-%s%s", runtime.GOOS, arch, ext)
|
||||||
|
downloadURL := fmt.Sprintf("%s/resources/theta-agent/%s", serverURL, artifact)
|
||||||
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
@@ -106,7 +124,7 @@ func runSelfUpdate(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runReinitialize(args []string) {
|
func runReinitialize(args []string) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
joinKey := ""
|
joinKey := ""
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
||||||
@@ -146,6 +164,12 @@ func runReinitialize(args []string) {
|
|||||||
|
|
||||||
func restartAffectedServices(exec Executor) {
|
func restartAffectedServices(exec Executor) {
|
||||||
log.Printf("[+] Restarting theta-agent service...")
|
log.Printf("[+] Restarting theta-agent service...")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// sc.exe has no one-shot restart.
|
||||||
|
_, _ = exec.Execute("sc", "stop", "theta-agent")
|
||||||
|
_, _ = exec.Execute("sc", "start", "theta-agent")
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
|
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
|
||||||
|
|
||||||
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
||||||
@@ -235,7 +259,7 @@ func runGetSecrets(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchAgentSecrets() (map[string]string, error) {
|
func fetchAgentSecrets() (map[string]string, error) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
cm, err := NewConfigManager(configPath)
|
cm, err := NewConfigManager(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// theta-agent-helper — session-aware companion for the theta-agent Windows
|
||||||
|
// service (DESIGN-WINDOWS.md §4 and §8).
|
||||||
|
//
|
||||||
|
// The agent service runs in session 0 with no interactive desktop. Operations
|
||||||
|
// that need an interactive session (lock, display off, logout) or that must
|
||||||
|
// outlive the service process (staged self-update: swap the locked exe and
|
||||||
|
// restart the service) run here instead.
|
||||||
|
//
|
||||||
|
// theta-agent-helper lock
|
||||||
|
// theta-agent-helper display_off
|
||||||
|
// theta-agent-helper logout [user]
|
||||||
|
// theta-agent-helper update <newExe> <currentExe> <serviceName>
|
||||||
|
//
|
||||||
|
// Sleep is handled in-process by the service (SetSuspendState works from
|
||||||
|
// session 0); it is also exposed here for manual use.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wmSyscommand = 0x0112
|
||||||
|
scMonitorpower = 0xF170
|
||||||
|
sleepHwnd = ^uintptr(0) // HWND_BROADCAST
|
||||||
|
monitorPowerOff = 2
|
||||||
|
|
||||||
|
wtsCurrentServerHandle = 0
|
||||||
|
wtsUserName = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
|
||||||
|
powrprof = syscall.NewLazyDLL("powrprof.dll")
|
||||||
|
|
||||||
|
procLockWorkStation = user32.NewProc("LockWorkStation")
|
||||||
|
procSendMessageW = user32.NewProc("SendMessageW")
|
||||||
|
procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession")
|
||||||
|
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
|
||||||
|
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
|
||||||
|
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
|
||||||
|
procSetSuspendState = powrprof.NewProc("SetSuspendState")
|
||||||
|
)
|
||||||
|
|
||||||
|
type wtsSessionInfo struct {
|
||||||
|
SessionID uint32
|
||||||
|
WinStation *uint16
|
||||||
|
ConnectState uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper <lock|display_off|logout|update|sleep> [args...]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "lock":
|
||||||
|
lockSession()
|
||||||
|
case "display_off":
|
||||||
|
displayOff()
|
||||||
|
case "logout":
|
||||||
|
logoutSession(argOrEmpty(2))
|
||||||
|
case "sleep":
|
||||||
|
sleepHost()
|
||||||
|
case "update":
|
||||||
|
if len(os.Args) < 5 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper update <newExe> <currentExe> <serviceName>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
doUpdate(os.Args[2], os.Args[3], os.Args[4])
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown action %q\n", os.Args[1])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func argOrEmpty(i int) string {
|
||||||
|
if len(os.Args) > i {
|
||||||
|
return os.Args[i]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockSession() {
|
||||||
|
r, _, err := procLockWorkStation.Call()
|
||||||
|
if r == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "LockWorkStation failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOff() {
|
||||||
|
r, _, err := procSendMessageW.Call(sleepHwnd, wmSyscommand, scMonitorpower, monitorPowerOff)
|
||||||
|
if r == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "SendMessage(SC_MONITORPOWER) failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepHost() {
|
||||||
|
r, _, err := procSetSuspendState.Call(0, 0, 0)
|
||||||
|
if r == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "SetSuspendState failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logoutSession logs off the active console session, or the session of the
|
||||||
|
// given user (matched by WTS session enumeration).
|
||||||
|
func logoutSession(user string) {
|
||||||
|
sessionID := activeConsoleSessionID()
|
||||||
|
if user != "" {
|
||||||
|
id, err := sessionForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "logout: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
sessionID = id
|
||||||
|
}
|
||||||
|
if sessionID == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "logout: no active session to log off")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0)
|
||||||
|
if r == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("logged off session %d\n", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeConsoleSessionID() uint32 {
|
||||||
|
id, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("WTSGetActiveConsoleSessionId").Call()
|
||||||
|
return uint32(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionForUser(user string) (uint32, error) {
|
||||||
|
var pInfo *wtsSessionInfo
|
||||||
|
var count uint32
|
||||||
|
|
||||||
|
r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count)))
|
||||||
|
if r == 0 {
|
||||||
|
return 0, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
|
||||||
|
}
|
||||||
|
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
|
||||||
|
|
||||||
|
want := strings.ToLower(user)
|
||||||
|
for i := uint32(0); i < count; i++ {
|
||||||
|
info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo)))
|
||||||
|
name := wtsSessionUsername(info.SessionID)
|
||||||
|
if name != "" && strings.ToLower(name) == want {
|
||||||
|
return info.SessionID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no active session for user %q", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wtsSessionUsername(sessionID uint32) string {
|
||||||
|
var pBuf *uint16
|
||||||
|
var bytes uint32
|
||||||
|
r, _, _ := procWTSQuerySessionInformationW.Call(wtsCurrentServerHandle, uintptr(sessionID), wtsUserName, uintptr(unsafe.Pointer(&pBuf)), uintptr(unsafe.Pointer(&bytes)))
|
||||||
|
if r == 0 || pBuf == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pBuf)))
|
||||||
|
return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(pBuf))[:bytes/2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// doUpdate swaps the staged new binary over the running one and restarts the
|
||||||
|
// service. The service that spawned us stops itself (stopAgent) once we are
|
||||||
|
// launched; we wait for it to actually stop (the exe is locked while running),
|
||||||
|
// swap, and start it again.
|
||||||
|
func doUpdate(newExe, currentExe, serviceName string) {
|
||||||
|
if serviceName == "" {
|
||||||
|
serviceName = "theta-agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !serviceRunning(serviceName) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file can stay locked a moment after the SCM reports STOPPED, so retry.
|
||||||
|
swapped := false
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
if err := os.Rename(newExe, currentExe); err == nil {
|
||||||
|
swapped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !swapped {
|
||||||
|
fmt.Fprintf(os.Stderr, "update: could not replace %s (is the service still running?)\n", currentExe)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sc", "start", serviceName)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "update: sc start %s: %v: %s\n", serviceName, err, out)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("update: swapped %s and restarted %s\n", currentExe, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceRunning(name string) bool {
|
||||||
|
out, err := exec.Command("sc", "query", name).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToUpper(string(out))
|
||||||
|
return strings.Contains(text, "RUNNING") || strings.Contains(text, "START_PENDING")
|
||||||
|
}
|
||||||
@@ -36,12 +36,17 @@ import (
|
|||||||
// ── IPC types (duplicated from the main agent package; tray is its own binary) ──
|
// ── IPC types (duplicated from the main agent package; tray is its own binary) ──
|
||||||
|
|
||||||
// traySocketPaths returns the daemon IPC socket paths for this platform, in
|
// traySocketPaths returns the daemon IPC socket paths for this platform, in
|
||||||
// the same order the daemon tries to bind them. Windows has no /run or /tmp,
|
// the same order the daemon tries to bind them. Windows has no /run or /tmp and
|
||||||
// so it uses a Unix socket under the per-user temp dir; Linux keeps the
|
// the daemon runs as a SYSTEM service, so both sides use the shared
|
||||||
// original pair.
|
// %ProgramData%\Theta42\tray.sock (installer creates the dir with a
|
||||||
|
// Users-writable ACL); Linux keeps the original pair.
|
||||||
func traySocketPaths() []string {
|
func traySocketPaths() []string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return []string{filepath.Join(os.TempDir(), "theta-tray.sock")}
|
pd := os.Getenv("ProgramData")
|
||||||
|
if pd == "" {
|
||||||
|
pd = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
return []string{filepath.Join(pd, "Theta42", "tray.sock")}
|
||||||
}
|
}
|
||||||
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ type SecretTarget struct {
|
|||||||
Reload string `yaml:"reload"`
|
Reload string `yaml:"reload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
|
||||||
|
type WireGuardConfig struct {
|
||||||
|
// TunnelName is the Windows WireGuard tunnel/service name.
|
||||||
|
TunnelName string `yaml:"tunnel_name"`
|
||||||
|
// Conf is where the pushed peer config is persisted on disk.
|
||||||
|
Conf string `yaml:"conf"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerURL string `yaml:"server_url"`
|
ServerURL string `yaml:"server_url"`
|
||||||
AuthToken string `yaml:"auth_token"`
|
AuthToken string `yaml:"auth_token"`
|
||||||
@@ -42,6 +50,31 @@ type Config struct {
|
|||||||
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
||||||
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
||||||
Capabilities Capabilities `yaml:"capabilities"`
|
Capabilities Capabilities `yaml:"capabilities"`
|
||||||
|
|
||||||
|
// Windows-specific (DESIGN-WINDOWS.md §11).
|
||||||
|
ServiceName string `yaml:"service_name"` // Windows service name
|
||||||
|
DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path
|
||||||
|
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
|
||||||
|
WireGuard WireGuardConfig `yaml:"wireguard"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectPublicIP reports whether the agent may perform external public-IP
|
||||||
|
// lookups. Defaults to true; an air-gapped host sets public_ip_detect: false so
|
||||||
|
// the agent never tries to reach ipify/icanhazip/etc.
|
||||||
|
func (c *Config) DetectPublicIP() bool {
|
||||||
|
if c.PublicIPDetect == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *c.PublicIPDetect
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceNameOrDefault returns the Windows service name, defaulting to
|
||||||
|
// theta-agent when unset.
|
||||||
|
func (c *Config) ServiceNameOrDefault() string {
|
||||||
|
if c.ServiceName != "" {
|
||||||
|
return c.ServiceName
|
||||||
|
}
|
||||||
|
return "theta-agent"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credential returns the value to present when connecting: our own token once
|
// Credential returns the value to present when connecting: our own token once
|
||||||
|
|||||||
+5
-1
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -155,11 +156,14 @@ capabilities:
|
|||||||
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
|
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
|
||||||
}
|
}
|
||||||
|
|
||||||
// file must stay 0600 -- it now holds a credential
|
// file must stay 0600 -- it now holds a credential. POSIX-only: Windows has
|
||||||
|
// no mode bits (0666 is reported regardless) and relies on ACLs instead.
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
fi, _ := os.Stat(path)
|
fi, _ := os.Stat(path)
|
||||||
if fi.Mode().Perm() != 0600 {
|
if fi.Mode().Perm() != 0600 {
|
||||||
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
|
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -3,13 +3,14 @@ module github.com/theta42/theta-agent
|
|||||||
go 1.22.2
|
go 1.22.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
golang.org/x/sys v0.20.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.2 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
@@ -18,5 +19,4 @@ require (
|
|||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+7
-1
@@ -82,7 +82,13 @@ func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkAndPush(cfg *Config, connectedFn func() bool) {
|
func checkAndPush(cfg *Config, connectedFn func() bool) {
|
||||||
ip := fetchPublicIP()
|
// An air-gapped host cannot reach the public-IP providers; when
|
||||||
|
// public_ip_detect is false we skip the network calls entirely and report
|
||||||
|
// no public IP rather than flap the home/away state (DESIGN-WINDOWS.md §10).
|
||||||
|
var ip string
|
||||||
|
if cfg.DetectPublicIP() {
|
||||||
|
ip = fetchPublicIP()
|
||||||
|
}
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
log.Println("[home-detect] could not determine public IP")
|
log.Println("[home-detect] could not determine public IP")
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -39,12 +39,14 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
|||||||
|
|
||||||
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
||||||
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
||||||
|
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
|
||||||
|
// on the TCP loopback listener below.
|
||||||
|
if socketPath != "" {
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||||
os.MkdirAll(dir, 0755)
|
os.MkdirAll(dir, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. UNIX Domain Socket Listener
|
|
||||||
lnUnix, err := net.Listen("unix", socketPath)
|
lnUnix, err := net.Listen("unix", socketPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
os.Chmod(socketPath, 0666)
|
os.Chmod(socketPath, 0666)
|
||||||
@@ -53,6 +55,7 @@ func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
||||||
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
|
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -14,15 +15,40 @@ import (
|
|||||||
// comes up and drops, so StartHomeMonitor can read it without a mutex.
|
// comes up and drops, so StartHomeMonitor can read it without a mutex.
|
||||||
var wsConnected atomic.Bool
|
var wsConnected atomic.Bool
|
||||||
|
|
||||||
|
// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the
|
||||||
|
// foreground, or the SCM stop request when running as a Windows service. Both
|
||||||
|
// runAgent (foreground) and the service handler wait on it.
|
||||||
|
var (
|
||||||
|
agentStopOnce sync.Once
|
||||||
|
agentStopCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
// stopAgent signals the running agent to shut down. Idempotent.
|
||||||
|
func stopAgent() {
|
||||||
|
agentStopOnce.Do(func() { close(agentStopCh) })
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows: when installed as a service the SCM starts us and svc.Run takes
|
||||||
|
// over the process lifecycle. Foreground (or any other OS) falls through to
|
||||||
|
// runAgent, which blocks until a signal arrives.
|
||||||
|
if maybeRunAsService() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAgent runs the agent daemon until stopAgent is called.
|
||||||
|
func runAgent() {
|
||||||
log.Println("Starting Theta Agent...")
|
log.Println("Starting Theta Agent...")
|
||||||
|
|
||||||
// Attempt to load configuration
|
// Attempt to load configuration
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||||
configPath = os.Args[1]
|
configPath = os.Args[1]
|
||||||
}
|
}
|
||||||
@@ -41,8 +67,10 @@ func main() {
|
|||||||
cfg.Capabilities.ArbitraryBash,
|
cfg.Capabilities.ArbitraryBash,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize system executor
|
// Initialize system executor and pin the platform ops the command
|
||||||
|
// dispatcher runs behind.
|
||||||
exec := &SystemExecutor{}
|
exec := &SystemExecutor{}
|
||||||
|
defaultPlatformOps = NewPlatformOps(cfg, exec)
|
||||||
|
|
||||||
// Tray IPC server — desktop tray connects here for status updates.
|
// Tray IPC server — desktop tray connects here for status updates.
|
||||||
go globalTrayServer.Start()
|
go globalTrayServer.Start()
|
||||||
@@ -53,10 +81,16 @@ func main() {
|
|||||||
// Home detection + tray status push (polls public IP every 60s).
|
// Home detection + tray status push (polls public IP every 60s).
|
||||||
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
||||||
|
|
||||||
// Block until signal is received
|
// Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and
|
||||||
|
// is driven by its own handler.
|
||||||
|
go func() {
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sigs
|
<-sigs
|
||||||
|
stopAgent()
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-agentStopCh
|
||||||
|
|
||||||
fmt.Println("Shutting down Theta Agent...")
|
fmt.Println("Shutting down Theta Agent...")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Platform paths. Windows has no /etc, /run or /tmp, so the agent's
|
||||||
|
// filesystem touchpoints move under %ProgramData%\Theta42, which SYSTEM (the
|
||||||
|
// service) and authenticated users (the tray) can both reach.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// windowsDataDir returns %ProgramData%\Theta42.
|
||||||
|
func windowsDataDir() string {
|
||||||
|
pd := os.Getenv("ProgramData")
|
||||||
|
if pd == "" {
|
||||||
|
pd = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
return filepath.Join(pd, "Theta42")
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfigPath returns the platform's agent.yml location.
|
||||||
|
func defaultConfigPath() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return filepath.Join(windowsDataDir(), "agent.yml")
|
||||||
|
}
|
||||||
|
return "/etc/theta42/agent.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultLdapSocketPath returns the local LDAP byte-pump socket. Windows has no
|
||||||
|
// AF_UNIX in a stable location that both service and clients share, so it
|
||||||
|
// relies on the TCP loopback listener (127.0.0.1:389) that ldapTunnel.start
|
||||||
|
// falls back to.
|
||||||
|
func defaultLdapSocketPath() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/run/theta/ldap.sock"
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowsTraySocketPath returns the tray IPC socket path. It lives in the
|
||||||
|
// shared data dir (not the per-user temp dir) because the daemon runs as the
|
||||||
|
// SYSTEM service while the tray runs as the logged-in user; the installer
|
||||||
|
// grants Users write access to this directory and its children.
|
||||||
|
func windowsTraySocketPath() string {
|
||||||
|
return filepath.Join(windowsDataDir(), "tray.sock")
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// NewPlatformOps returns the Linux/POSIX implementation (systemctl, journalctl,
|
||||||
|
// bash). It is the fallback for any non-Windows host.
|
||||||
|
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps {
|
||||||
|
return &linuxPlatformOps{exec: exec}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// NewPlatformOps returns the Windows implementation.
|
||||||
|
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps {
|
||||||
|
return &windowsPlatformOps{
|
||||||
|
exec: exec,
|
||||||
|
helperPath: cfg.DesktopHelper,
|
||||||
|
serviceName: cfg.ServiceName,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// linuxPlatformOps implements PlatformOps for Linux (and any POSIX host).
|
||||||
|
// The commands mirror what websocket.go executed before the PlatformOps split;
|
||||||
|
// behaviour is unchanged. Deliberately NOT build-tagged: it compiles on every
|
||||||
|
// OS (it only shells out to systemctl/journalctl/bash), so the shared command
|
||||||
|
// dispatch tests can pin it and run identically on Windows CI.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type linuxPlatformOps struct {
|
||||||
|
exec Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) Reboot() ([]byte, error) {
|
||||||
|
return p.exec.Execute("reboot")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) Shutdown() ([]byte, error) {
|
||||||
|
out, err := p.exec.Execute("shutdown", "-h", "now")
|
||||||
|
if err != nil {
|
||||||
|
return p.exec.Execute("poweroff")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) FetchLogs(service string, lines int) ([]byte, error) {
|
||||||
|
return p.exec.Execute("journalctl", "-u", service, "-n", fmt.Sprintf("%d", lines), "--no-pager")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) ServiceControl(service, action string) ([]byte, error) {
|
||||||
|
return p.exec.Execute("systemctl", action, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) RunScript(script string) ([]byte, error) {
|
||||||
|
return p.exec.Execute("bash", "-c", script)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) {
|
||||||
|
switch subAction {
|
||||||
|
case "lock_session", "lock":
|
||||||
|
out, err := p.exec.Execute("loginctl", "lock-sessions")
|
||||||
|
if err != nil {
|
||||||
|
return p.exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
case "logout_user", "logout":
|
||||||
|
if targetUser != "" {
|
||||||
|
out, err := p.exec.Execute("loginctl", "terminate-user", targetUser)
|
||||||
|
if err != nil {
|
||||||
|
return p.exec.Execute("pkill", "-KILL", "-u", targetUser)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out, err := p.exec.Execute("loginctl", "terminate-session")
|
||||||
|
if err != nil {
|
||||||
|
return p.exec.Execute("pkill", "-9", "-f", "session-child")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
case "display_off":
|
||||||
|
return p.exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions")
|
||||||
|
case "sleep_host", "sleep":
|
||||||
|
return p.exec.Execute("systemctl", "suspend")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown desktop action '%s'", subAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLDAP reproduces the original SSSD/nsswitch/PAM configuration flow.
|
||||||
|
func (p *linuxPlatformOps) ConfigureLDAP(configData string) error {
|
||||||
|
log.Println("Pushing updated SSSD configuration...")
|
||||||
|
_ = os.MkdirAll("/etc/sssd", 0755)
|
||||||
|
if err := p.exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write SSSD config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
|
||||||
|
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
|
||||||
|
nssContent := string(nssBytes)
|
||||||
|
updatedNss := false
|
||||||
|
lines := strings.Split(nssContent, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
|
||||||
|
lines[i] = line + " sss"
|
||||||
|
updatedNss = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updatedNss {
|
||||||
|
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Restarting SSSD service...")
|
||||||
|
if _, err := p.exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
||||||
|
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
|
||||||
|
if _, err2 := p.exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
|
||||||
|
_, _ = p.exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||||
|
if _, err3 := p.exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
|
||||||
|
writeSSSDSshConf(p.exec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to restart sssd")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSSSDSshConf(p.exec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSSDSshConf installs the AuthorizedKeysCommand config sshd needs to look
|
||||||
|
// up LDAP-backed SSH keys.
|
||||||
|
func writeSSSDSshConf(exec Executor) {
|
||||||
|
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
||||||
|
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
||||||
|
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||||
|
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
|
||||||
|
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||||
|
}
|
||||||
|
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
|
||||||
|
sshdStr := string(sshdBytes)
|
||||||
|
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
|
||||||
|
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||||
|
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
|
||||||
|
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
|
||||||
|
tmpPath, err := downloadBinary(downloadURL, checksum)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve current binary path: %w", err)
|
||||||
|
}
|
||||||
|
if resolved, err := filepath.EvalSymlinks(selfPath); err == nil {
|
||||||
|
selfPath = resolved
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, selfPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace binary: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatformOps) SelfRestart() {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// PlatformOps abstracts the system operations the command dispatcher needs.
|
||||||
|
// Command dispatch, capability gating and Ed25519 verification are
|
||||||
|
// platform-neutral (websocket.go); the actual mechanics differ per OS:
|
||||||
|
//
|
||||||
|
// - linuxPlatformOps (platform_linuxops.go) — systemctl, journalctl, bash, ...
|
||||||
|
// - windowsPlatformOps (platform_windows.go) — sc.exe, powershell, the
|
||||||
|
// theta-agent-helper for session-0 ops and staged self-update.
|
||||||
|
//
|
||||||
|
// defaultPlatformOps is pinned at startup (runAgent) and swappable in tests so
|
||||||
|
// command dispatch can be exercised identically on any host.
|
||||||
|
|
||||||
|
type PlatformOps interface {
|
||||||
|
// Reboot restarts the host.
|
||||||
|
Reboot() ([]byte, error)
|
||||||
|
|
||||||
|
// Shutdown powers the host off.
|
||||||
|
Shutdown() ([]byte, error)
|
||||||
|
|
||||||
|
// FetchLogs returns the last `lines` log lines for a service.
|
||||||
|
FetchLogs(service string, lines int) ([]byte, error)
|
||||||
|
|
||||||
|
// ServiceControl runs an action (start/stop/restart/status/...) against a
|
||||||
|
// Windows service. Actions that don't map are executed best-effort.
|
||||||
|
ServiceControl(service, action string) ([]byte, error)
|
||||||
|
|
||||||
|
// RunScript executes an operator-supplied script (arbitrary_bash).
|
||||||
|
RunScript(script string) ([]byte, error)
|
||||||
|
|
||||||
|
// DesktopControl runs a desktop-control subaction (lock/logout/display/sleep)
|
||||||
|
// for a target user.
|
||||||
|
DesktopControl(subAction, targetUser string) ([]byte, error)
|
||||||
|
|
||||||
|
// ConfigureLDAP writes the pushed LDAP configuration. Linux configures
|
||||||
|
// SSSD; Windows manages logon via OpenCredential instead and declines.
|
||||||
|
ConfigureLDAP(configData string) error
|
||||||
|
|
||||||
|
// ApplyUpdate downloads, verifies and stages a new binary. The swap is
|
||||||
|
// platform-specific: Linux renames over the running binary, Windows writes
|
||||||
|
// a `.new` next to it and hands the swap to theta-agent-helper (the running
|
||||||
|
// exe is locked and the service must stop first).
|
||||||
|
ApplyUpdate(downloadURL, checksum string) error
|
||||||
|
|
||||||
|
// SelfRestart terminates the agent so a staged update takes effect. Linux
|
||||||
|
// exits; the Windows service handler stops itself (the helper restarts it).
|
||||||
|
SelfRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultPlatformOps is the ops implementation the command dispatcher uses.
|
||||||
|
// Pinned by runAgent after config load; tests override it directly.
|
||||||
|
var defaultPlatformOps = NewPlatformOps(&Config{}, &SystemExecutor{})
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// windowsPlatformOps implements PlatformOps for Windows. Remote ops map to
|
||||||
|
// Windows equivalents:
|
||||||
|
//
|
||||||
|
// - reboot/shutdown → shutdown.exe
|
||||||
|
// - service control → sc.exe
|
||||||
|
// - fetch logs → Get-WinEvent (PowerShell)
|
||||||
|
// - arbitrary_bash → powershell -EncodedCommand (survives arbitrary quoting)
|
||||||
|
// - desktop control → theta-agent-helper (session-0 workaround, see
|
||||||
|
// DESIGN-WINDOWS.md §4)
|
||||||
|
// - self-update → staged `.new` + helper swap (the running exe is locked)
|
||||||
|
//
|
||||||
|
// configure_ldap is declined: Windows logon goes through the OpenCredential
|
||||||
|
// credential provider, which the installer configures directly.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type windowsPlatformOps struct {
|
||||||
|
exec Executor
|
||||||
|
helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8)
|
||||||
|
serviceName string // Windows service name (defaults to theta-agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *windowsPlatformOps) Reboot() ([]byte, error) {
|
||||||
|
return p.exec.Execute("shutdown", "/r", "/t", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *windowsPlatformOps) Shutdown() ([]byte, error) {
|
||||||
|
return p.exec.Execute("shutdown", "/s", "/t", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *windowsPlatformOps) FetchLogs(service string, lines int) ([]byte, error) {
|
||||||
|
script := fmt.Sprintf("Get-WinEvent -LogName Application -MaxEvents %d -ErrorAction SilentlyContinue | Format-List TimeCreated, ProviderName, Id, LevelDisplayName, Message", lines)
|
||||||
|
return p.runPowerShell(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceControl maps systemd-style actions onto sc.exe. `restart` has no
|
||||||
|
// one-shot sc command, so it stops then starts; `status` is `sc query`.
|
||||||
|
func (p *windowsPlatformOps) ServiceControl(service, action string) ([]byte, error) {
|
||||||
|
switch action {
|
||||||
|
case "status":
|
||||||
|
return p.exec.Execute("sc.exe", "query", service)
|
||||||
|
case "restart":
|
||||||
|
// A service that is already stopped is not an error worth failing on.
|
||||||
|
if _, err := p.exec.Execute("sc.exe", "stop", service); err != nil {
|
||||||
|
log.Printf("[windows] sc stop %s: %v (continuing to start)", service, err)
|
||||||
|
}
|
||||||
|
out, err := p.exec.Execute("sc.exe", "start", service)
|
||||||
|
if err != nil && strings.Contains(string(out), "1056") {
|
||||||
|
// ERROR_SERVICE_ALREADY_RUNNING — the stop never landed; treat as up.
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
default:
|
||||||
|
return p.exec.Execute("sc.exe", action, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *windowsPlatformOps) RunScript(script string) ([]byte, error) {
|
||||||
|
return p.runPowerShell(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPowerShell invokes powershell with a UTF-16LE base64 -EncodedCommand. An
|
||||||
|
// operator script can contain arbitrary quotes, `&`, `%`, etc.; passing it as a
|
||||||
|
// plain argument would be mangled by cmd.exe/arg quoting rules, while the
|
||||||
|
// encoded form is byte-exact on both sides.
|
||||||
|
func (p *windowsPlatformOps) runPowerShell(script string) ([]byte, error) {
|
||||||
|
units := utf16.Encode([]rune(script))
|
||||||
|
b := make([]byte, len(units)*2)
|
||||||
|
for i, r := range units {
|
||||||
|
b[i*2] = byte(r)
|
||||||
|
b[i*2+1] = byte(r >> 8)
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString(b)
|
||||||
|
return p.exec.Execute("powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesktopControl routes ops that need an interactive desktop to the helper,
|
||||||
|
// which the service launches in the target session (DESIGN-WINDOWS.md §4).
|
||||||
|
// Sleep can run from session 0 and is handled in-process.
|
||||||
|
func (p *windowsPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) {
|
||||||
|
var action string
|
||||||
|
switch subAction {
|
||||||
|
case "sleep_host", "sleep":
|
||||||
|
return nil, setSuspendState()
|
||||||
|
case "lock_session", "lock":
|
||||||
|
action = "lock"
|
||||||
|
case "display_off":
|
||||||
|
action = "display_off"
|
||||||
|
case "logout_user", "logout":
|
||||||
|
action = "logout"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown desktop action '%s'", subAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.helperPath == "" {
|
||||||
|
return nil, fmt.Errorf("desktop control requires theta-agent-helper (desktop_helper not configured)")
|
||||||
|
}
|
||||||
|
args := []string{action}
|
||||||
|
if targetUser != "" {
|
||||||
|
args = append(args, targetUser)
|
||||||
|
}
|
||||||
|
return p.exec.Execute(p.helperPath, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSuspendState puts the machine to sleep. Requires SeShutdownPrivilege,
|
||||||
|
// which the SYSTEM service holds.
|
||||||
|
func setSuspendState() error {
|
||||||
|
powrprof := syscall.NewLazyDLL("powrprof.dll")
|
||||||
|
proc := powrprof.NewProc("SetSuspendState")
|
||||||
|
r, _, err := proc.Call(0, 0, 0) // Hibernate=false, ForceCritical=false, WakeIfDisarmed=false
|
||||||
|
if r == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLDAP is not applicable on Windows: directory logon uses the
|
||||||
|
// OpenCredential credential provider configured by the installer.
|
||||||
|
func (p *windowsPlatformOps) ConfigureLDAP(configData string) error {
|
||||||
|
return fmt.Errorf("configure_ldap is not applicable on Windows; logon is managed by the OpenCredential credential provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUpdate downloads and verifies the new binary to `<self>.new`, then hands
|
||||||
|
// the swap to the helper: the running service holds the exe open, so the agent
|
||||||
|
// must stop before the file can be replaced. The helper outlives the service
|
||||||
|
// (detached process), swaps the files, and restarts the service.
|
||||||
|
func (p *windowsPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve current binary path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath, err := downloadBinary(downloadURL, checksum)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := selfPath + ".new"
|
||||||
|
if err := moveFile(tmpPath, newPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to stage new binary: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := p.helperPath
|
||||||
|
if helper == "" {
|
||||||
|
return fmt.Errorf("desktop_helper not configured; cannot complete self-update")
|
||||||
|
}
|
||||||
|
service := p.serviceName
|
||||||
|
if service == "" {
|
||||||
|
service = "theta-agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[windows] staging self-update via helper (%s -> %s)", newPath, selfPath)
|
||||||
|
if err := spawnDetached(helper, "update", newPath, selfPath, service); err != nil {
|
||||||
|
return fmt.Errorf("failed to launch updater helper: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *windowsPlatformOps) SelfRestart() {
|
||||||
|
stopAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnDetached launches exe as a background process that survives this one.
|
||||||
|
func spawnDetached(exe string, args ...string) error {
|
||||||
|
cmd := execCommand(exe, args...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// execCommand is a thin wrapper so tests that build the windows ops can stub it.
|
||||||
|
func execCommand(name string, args ...string) *exec.Cmd {
|
||||||
|
return exec.Command(name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveFile renames src onto dst, falling back to a copy when the source is on a
|
||||||
|
// different volume than the destination (os.Rename fails across volumes).
|
||||||
|
func moveFile(src, dst string) error {
|
||||||
|
if err := os.Rename(src, dst); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
os.Remove(dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(src)
|
||||||
|
}
|
||||||
+6
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,11 +69,15 @@ func TestRenderSecrets(t *testing.T) {
|
|||||||
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The target should be 0600 (holds secrets).
|
// The target should be 0600 (holds secrets). Windows has no POSIX modes and
|
||||||
|
// reports 0666 regardless; the intent there is covered by the ACLs the
|
||||||
|
// installer sets on the target directory.
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
info, _ := os.Stat(target)
|
info, _ := os.Stat(target)
|
||||||
if info.Mode().Perm() != 0600 {
|
if info.Mode().Perm() != 0600 {
|
||||||
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderSecretsReload verifies the reload command runs after rendering.
|
// TestRenderSecretsReload verifies the reload command runs after rendering.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// handleServiceCommand is a no-op outside Windows: the agent is managed by
|
||||||
|
// systemd/init there.
|
||||||
|
func handleServiceCommand(args []string) {}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Service management CLI (theta-agent install-service / remove-service). The
|
||||||
|
// Inno installer also drives this rather than hand-rolling `sc create`.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleServiceCommand(args []string) {
|
||||||
|
action := "install"
|
||||||
|
if len(args) > 0 {
|
||||||
|
action = args[0]
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "install":
|
||||||
|
installService()
|
||||||
|
case "remove":
|
||||||
|
removeService()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: theta-agent %s [install|remove]\n", action)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installService() {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
logFatal("cannot resolve agent path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
logFatal("cannot connect to service manager: %v", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService("theta-agent")
|
||||||
|
if err == nil {
|
||||||
|
s.Close()
|
||||||
|
fmt.Println("[+] theta-agent service already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := mgr.Config{
|
||||||
|
DisplayName: "Theta Agent",
|
||||||
|
Description: "Theta42 unified endpoint management agent (telemetry, remote ops, LDAP logon, WireGuard mesh)",
|
||||||
|
ServiceStartName: "LocalSystem",
|
||||||
|
StartType: mgr.StartAutomatic,
|
||||||
|
}
|
||||||
|
s, err = m.CreateService("theta-agent", exe, cfg, "is", "auto")
|
||||||
|
if err != nil {
|
||||||
|
logFatal("cannot create service: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
fmt.Printf("[+] Registered theta-agent service (%s)\n", filepath.Base(exe))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeService() {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
logFatal("cannot connect to service manager: %v", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
s, err := m.OpenService("theta-agent")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("[!] theta-agent service not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
if err := s.Delete(); err != nil {
|
||||||
|
logFatal("cannot delete service: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("[+] Removed theta-agent service")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFatal(format string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[!] "+format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// maybeRunAsService is a no-op on non-Windows hosts: the agent runs in the
|
||||||
|
// foreground under systemd / init and is driven by SIGTERM.
|
||||||
|
func maybeRunAsService() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Windows service wrapper. The installer registers theta-agent as a SYSTEM
|
||||||
|
// auto-start service; when the SCM launches the process this file takes over.
|
||||||
|
// The service is up before any user session, which is what allows the
|
||||||
|
// credential provider to validate LDAP logins at Ctrl+Alt+Del and what makes
|
||||||
|
// the tray IPC socket path (shared data dir) correct.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maybeRunAsService returns true (and runs the service loop) when launched by
|
||||||
|
// the Service Control Manager; false when run in a console.
|
||||||
|
func maybeRunAsService() bool {
|
||||||
|
isSvc, err := svc.IsWindowsService()
|
||||||
|
if err != nil || !isSvc {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := svc.Run("theta-agent", &agentService{}); err != nil {
|
||||||
|
log.Printf("service: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentService struct{}
|
||||||
|
|
||||||
|
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||||
|
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.StartPending}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
runAgent()
|
||||||
|
}()
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-r:
|
||||||
|
switch c.Cmd {
|
||||||
|
case svc.Interrogate:
|
||||||
|
changes <- c.CurrentStatus
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
changes <- svc.Status{State: svc.StopPending}
|
||||||
|
stopAgent()
|
||||||
|
<-done
|
||||||
|
changes <- svc.Status{State: svc.Stopped}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
changes <- svc.Status{State: svc.Stopped}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -425,7 +425,7 @@ func collectHostDetails() HostDetails {
|
|||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentVersion = "v2.0.0"
|
const AgentVersion = "v2.1.0"
|
||||||
|
|
||||||
// CollectDiscoveryData gathers static host information.
|
// CollectDiscoveryData gathers static host information.
|
||||||
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||||
@@ -446,7 +446,10 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
|||||||
cpuDet := collectCPUDetails()
|
cpuDet := collectCPUDetails()
|
||||||
loggedUsers := collectLoggedUsers()
|
loggedUsers := collectLoggedUsers()
|
||||||
|
|
||||||
pubIP := getPublicIP()
|
pubIP := ""
|
||||||
|
if cfg.DetectPublicIP() {
|
||||||
|
pubIP = getPublicIP()
|
||||||
|
}
|
||||||
|
|
||||||
diskTotalGB := 0.0
|
diskTotalGB := 0.0
|
||||||
for _, d := range disks {
|
for _, d := range disks {
|
||||||
|
|||||||
+7
-7
@@ -9,19 +9,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TraySocketPaths are the paths the daemon tries to bind, in order.
|
// TraySocketPaths are the paths the daemon tries to bind, in order.
|
||||||
//
|
//
|
||||||
// Windows has no /run or /tmp; a Unix socket in the per-user temp dir works
|
// Windows has no /run or /tmp. The daemon runs as the SYSTEM service while the
|
||||||
// there (AF_UNIX is supported since Windows 10 1803) and needs no admin
|
// tray runs as the logged-in user, so the socket lives in the shared data dir
|
||||||
// rights. Linux keeps the original /run/theta path with a /tmp fallback.
|
// (%ProgramData%\Theta42, created by the installer with a Users-writable ACL)
|
||||||
|
// rather than a per-user temp dir — the two processes must agree on one path.
|
||||||
|
// Linux keeps the original /run/theta path with a /tmp fallback.
|
||||||
var TraySocketPaths = func() []string {
|
var TraySocketPaths = func() []string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return []string{filepath.Join(os.TempDir(), "theta-tray.sock")}
|
return []string{windowsTraySocketPath()}
|
||||||
}
|
}
|
||||||
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
||||||
}()
|
}()
|
||||||
@@ -30,7 +30,7 @@ var TraySocketPaths = func() []string {
|
|||||||
// entry in TraySocketPaths on each platform.
|
// entry in TraySocketPaths on each platform.
|
||||||
var TraySocket = func() string {
|
var TraySocket = func() string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return filepath.Join(os.TempDir(), "theta-tray.sock")
|
return windowsTraySocketPath()
|
||||||
}
|
}
|
||||||
return "/tmp/theta-tray.sock"
|
return "/tmp/theta-tray.sock"
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,7 @@ func (ts *trayServer) Start() {
|
|||||||
|
|
||||||
for _, p := range TraySocketPaths {
|
for _, p := range TraySocketPaths {
|
||||||
os.Remove(p)
|
os.Remove(p)
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0755) //nolint:errcheck
|
||||||
l, err = net.Listen("unix", p)
|
l, err = net.Listen("unix", p)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
boundPath = p
|
boundPath = p
|
||||||
|
|||||||
+28
-117
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -174,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||||
socketPath := cfg.LdapSocket
|
socketPath := cfg.LdapSocket
|
||||||
if socketPath == "" {
|
if socketPath == "" {
|
||||||
socketPath = "/run/theta/ldap.sock"
|
socketPath = defaultLdapSocketPath()
|
||||||
}
|
}
|
||||||
go tunnel.start(socketPath, stopCh)
|
go tunnel.start(socketPath, stopCh)
|
||||||
}
|
}
|
||||||
@@ -297,7 +296,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
||||||
out, err := exec.Execute("journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", linesCount), "--no-pager")
|
out, err := defaultPlatformOps.FetchLogs(serviceName, linesCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Log fetch failed: %v", err)
|
log.Printf("Log fetch failed: %v", err)
|
||||||
sendResponse("error", "failed to fetch logs")
|
sendResponse("error", "failed to fetch logs")
|
||||||
@@ -329,13 +328,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Updating binary from %s...", urlStr)
|
log.Printf("Updating binary from %s...", urlStr)
|
||||||
if err := downloadAndUpdateBinary(urlStr, checksum); err != nil {
|
if err := defaultPlatformOps.ApplyUpdate(urlStr, checksum); err != nil {
|
||||||
log.Printf("Update failed: %v", err)
|
log.Printf("Update failed: %v", err)
|
||||||
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendResponse("ok", "update applied successfully; restarting agent...")
|
sendResponse("ok", "update applied successfully; restarting agent...")
|
||||||
os.Exit(0)
|
defaultPlatformOps.SelfRestart()
|
||||||
case "config":
|
case "config":
|
||||||
// A config frame carrying credentials means the server accepted our
|
// A config frame carrying credentials means the server accepted our
|
||||||
// join key and enrolled this host.
|
// join key and enrolled this host.
|
||||||
@@ -370,7 +369,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Executing reboot...")
|
log.Printf("Executing reboot...")
|
||||||
if _, err := exec.Execute("reboot"); err != nil {
|
if _, err := defaultPlatformOps.Reboot(); err != nil {
|
||||||
log.Printf("Reboot failed: %v", err)
|
log.Printf("Reboot failed: %v", err)
|
||||||
sendResponse("error", "reboot failed")
|
sendResponse("error", "reboot failed")
|
||||||
return
|
return
|
||||||
@@ -388,8 +387,8 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
log.Printf("Executing shutdown...")
|
log.Printf("Executing shutdown...")
|
||||||
sendResponse("ok", "system shutting down")
|
sendResponse("ok", "system shutting down")
|
||||||
if _, err := exec.Execute("shutdown", "-h", "now"); err != nil {
|
if _, err := defaultPlatformOps.Shutdown(); err != nil {
|
||||||
exec.Execute("poweroff")
|
log.Printf("Shutdown failed: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
|
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
|
||||||
@@ -399,36 +398,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
targetUser, _ := msg.Payload["user"].(string)
|
targetUser, _ := msg.Payload["user"].(string)
|
||||||
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
|
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
|
||||||
var out []byte
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch subAction {
|
switch subAction {
|
||||||
case "lock_session", "lock":
|
case "lock_session", "lock", "logout_user", "logout", "display_off", "sleep_host", "sleep":
|
||||||
out, err = exec.Execute("loginctl", "lock-sessions")
|
|
||||||
if err != nil {
|
|
||||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off")
|
|
||||||
}
|
|
||||||
case "logout_user", "logout":
|
|
||||||
if targetUser != "" {
|
|
||||||
out, err = exec.Execute("loginctl", "terminate-user", targetUser)
|
|
||||||
if err != nil {
|
|
||||||
out, err = exec.Execute("pkill", "-KILL", "-u", targetUser)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out, err = exec.Execute("loginctl", "terminate-session")
|
|
||||||
if err != nil {
|
|
||||||
out, err = exec.Execute("pkill", "-9", "-f", "session-child")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "display_off":
|
|
||||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions")
|
|
||||||
case "sleep_host", "sleep":
|
|
||||||
out, err = exec.Execute("systemctl", "suspend")
|
|
||||||
default:
|
default:
|
||||||
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
|
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out, err := defaultPlatformOps.DesktopControl(subAction, targetUser)
|
||||||
|
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
@@ -457,7 +436,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Executing systemctl %s %s...", action, serviceName)
|
log.Printf("Executing systemctl %s %s...", action, serviceName)
|
||||||
out, err := exec.Execute("systemctl", action, serviceName)
|
out, err := defaultPlatformOps.ServiceControl(serviceName, action)
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
@@ -480,7 +459,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Restarting service %s...", serviceName)
|
log.Printf("Restarting service %s...", serviceName)
|
||||||
if _, err := exec.Execute("systemctl", "restart", serviceName); err != nil {
|
if _, err := defaultPlatformOps.ServiceControl(serviceName, "restart"); err != nil {
|
||||||
log.Printf("Service restart failed: %v", err)
|
log.Printf("Service restart failed: %v", err)
|
||||||
sendResponse("error", "restart failed")
|
sendResponse("error", "restart failed")
|
||||||
return
|
return
|
||||||
@@ -504,70 +483,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Pushing updated SSSD configuration...")
|
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
|
||||||
_ = os.MkdirAll("/etc/sssd", 0755)
|
log.Printf("LDAP configuration failed: %v", err)
|
||||||
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
sendResponse("error", err.Error())
|
||||||
log.Printf("Failed to write SSSD config: %v", err)
|
|
||||||
sendResponse("error", "failed to write config")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
|
|
||||||
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
|
|
||||||
nssContent := string(nssBytes)
|
|
||||||
updatedNss := false
|
|
||||||
lines := strings.Split(nssContent, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
|
|
||||||
lines[i] = line + " sss"
|
|
||||||
updatedNss = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if updatedNss {
|
|
||||||
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Restarting SSSD service...")
|
|
||||||
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
|
||||||
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
|
|
||||||
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
|
|
||||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
|
||||||
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
|
|
||||||
// Configure SSH AuthorizedKeysCommand
|
|
||||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
|
||||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
|
||||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
sendResponse("ok", "LDAP configuration updated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sendResponse("error", "failed to restart sssd")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
|
|
||||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
|
||||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
|
||||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
}
|
|
||||||
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
|
|
||||||
sshdStr := string(sshdBytes)
|
|
||||||
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
|
|
||||||
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure PAM mkhomedir is enabled
|
|
||||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
|
||||||
|
|
||||||
sendResponse("ok", "LDAP configuration updated")
|
sendResponse("ok", "LDAP configuration updated")
|
||||||
case "render_secrets":
|
case "render_secrets":
|
||||||
if !verifySignature(cfg, msg) {
|
if !verifySignature(cfg, msg) {
|
||||||
@@ -628,7 +549,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Executing remote script: %s", script)
|
log.Printf("Executing remote script: %s", script)
|
||||||
out, err := exec.Execute("bash", "-c", script)
|
out, err := defaultPlatformOps.RunScript(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Script execution failed: %v", err)
|
log.Printf("Script execution failed: %v", err)
|
||||||
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
||||||
@@ -655,20 +576,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
// downloadBinary fetches the new binary, verifies its SHA-256, and returns the
|
||||||
|
// path of a temp file holding it. The platform's ApplyUpdate decides how to
|
||||||
|
// install it (Linux renames over the running exe; Windows stages a `.new` and
|
||||||
|
// swaps via the helper once the service stops).
|
||||||
|
func downloadBinary(downloadURL string, expectedSHA256 string) (string, error) {
|
||||||
resp, err := http.Get(downloadURL)
|
resp, err := http.Get(downloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("http fetch failed: %w", err)
|
return "", fmt.Errorf("http fetch failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("unexpected http status: %s", resp.Status)
|
return "", fmt.Errorf("unexpected http status: %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
|
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
@@ -678,32 +603,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
|||||||
|
|
||||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
return fmt.Errorf("failed to save binary: %w", err)
|
return "", fmt.Errorf("failed to save binary: %w", err)
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
|
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
|
||||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
return "", fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to set executable permissions: %w", err)
|
return "", fmt.Errorf("failed to set executable permissions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
selfPath, err := os.Executable()
|
return tmpPath, nil
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve current binary path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(selfPath)
|
|
||||||
if err == nil {
|
|
||||||
selfPath = resolvedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, selfPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to replace binary: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,14 @@ func TestHandleCommand(t *testing.T) {
|
|||||||
mockConn := &MockConn{}
|
mockConn := &MockConn{}
|
||||||
mockExec := &MockExecutor{}
|
mockExec := &MockExecutor{}
|
||||||
cm := &ConfigManager{current: tc.cfg}
|
cm := &ConfigManager{current: tc.cfg}
|
||||||
|
|
||||||
|
// The dispatch tests assert the exact command lines the Linux
|
||||||
|
// executor produces; pin the platform ops so they behave the same
|
||||||
|
// on any CI host (Windows included).
|
||||||
|
prevOps := defaultPlatformOps
|
||||||
|
defaultPlatformOps = &linuxPlatformOps{exec: mockExec}
|
||||||
|
defer func() { defaultPlatformOps = prevOps }()
|
||||||
|
|
||||||
msg := tc.msg
|
msg := tc.msg
|
||||||
if tc.signed {
|
if tc.signed {
|
||||||
msg.Payload = sign(t, msg.Payload)
|
msg.Payload = sign(t, msg.Payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user