4a619f7adc
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).
209 lines
5.2 KiB
Go
209 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadConfig(t *testing.T) {
|
|
// Create a temporary directory for config files
|
|
tmpDir, err := os.MkdirTemp("", "agent-config-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
yamlContent string
|
|
filename string
|
|
expectErr bool
|
|
}{
|
|
{
|
|
name: "valid config",
|
|
yamlContent: `
|
|
server_url: "http://sso.local"
|
|
auth_token: "secret-token"
|
|
location: "datacenter-1"
|
|
capabilities:
|
|
telemetry: true
|
|
configure_ldap: true
|
|
reboot: false
|
|
service_control: ["nginx", "gitea"]
|
|
arbitrary_bash: false
|
|
`,
|
|
filename: "valid.yml",
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "invalid yaml",
|
|
yamlContent: "invalid: [yaml: content",
|
|
filename: "invalid.yml",
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "missing file",
|
|
yamlContent: "",
|
|
filename: "nonexistent.yml",
|
|
expectErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
path := filepath.Join(tmpDir, tc.filename)
|
|
if tc.yamlContent != "" {
|
|
err := os.WriteFile(path, []byte(tc.yamlContent), 0644)
|
|
if err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
}
|
|
|
|
cfg, err := LoadConfig(path)
|
|
if (err != nil) != tc.expectErr {
|
|
t.Errorf("LoadConfig() error = %v, expectErr %v", err, tc.expectErr)
|
|
return
|
|
}
|
|
|
|
if !tc.expectErr && cfg == nil {
|
|
t.Error("LoadConfig() returned nil config without error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanManageService(t *testing.T) {
|
|
caps := Capabilities{
|
|
ServiceControl: []string{"nginx", "gitea"},
|
|
}
|
|
|
|
tests := []struct {
|
|
service string
|
|
expected bool
|
|
}{
|
|
{"nginx", true},
|
|
{"gitea", true},
|
|
{"ssh", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.service, func(t *testing.T) {
|
|
if got := caps.CanManageService(tc.service); got != tc.expected {
|
|
t.Errorf("CanManageService(%q) = %v, want %v", tc.service, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// PersistEnrollment rewrites a hand-edited file, so it must replace exactly the
|
|
// credential lines and leave everything else -- comments, capabilities,
|
|
// formatting -- untouched.
|
|
func TestPersistEnrollmentPreservesFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/agent.yml"
|
|
original := `# theta-agent configuration file
|
|
server_url: "https://sso.example.com"
|
|
|
|
# Bootstrap credential, exchanged on first connect.
|
|
join_key: "tjk_abc123"
|
|
public_key: ""
|
|
location: "rack-4"
|
|
|
|
capabilities:
|
|
telemetry: true
|
|
# keep this comment
|
|
reboot: false
|
|
service_control: ["nginx"]
|
|
`
|
|
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cm, err := NewConfigManager(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := cm.PersistEnrollment("issued-token-xyz", "PUBKEYBASE64"); err != nil {
|
|
t.Fatalf("PersistEnrollment: %v", err)
|
|
}
|
|
|
|
out, _ := os.ReadFile(path)
|
|
got := string(out)
|
|
|
|
for _, want := range []string{
|
|
`auth_token: "issued-token-xyz"`,
|
|
`public_key: "PUBKEYBASE64"`,
|
|
`join_key: ""`, // blanked: a fleet-wide key must not linger once unneeded
|
|
"# theta-agent configuration file",
|
|
"# keep this comment",
|
|
`location: "rack-4"`,
|
|
`service_control: ["nginx"]`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("expected %q in rewritten config, got:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// and the in-memory config is live without a restart
|
|
if cm.Get().AuthToken != "issued-token-xyz" {
|
|
t.Errorf("config not reloaded: AuthToken = %q", cm.Get().AuthToken)
|
|
}
|
|
if cm.Get().Credential() != "issued-token-xyz" {
|
|
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().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)
|
|
if fi.Mode().Perm() != 0600 {
|
|
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/agent.yml"
|
|
// No auth_token or public_key lines at all.
|
|
if err := os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_x\"\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cm, err := NewConfigManager(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cm.PersistEnrollment("tok", "pk"); err != nil {
|
|
t.Fatalf("PersistEnrollment: %v", err)
|
|
}
|
|
if cm.Get().AuthToken != "tok" || cm.Get().PublicKey != "pk" {
|
|
out, _ := os.ReadFile(path)
|
|
t.Errorf("keys not appended; file:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestCredentialPrefersAuthToken(t *testing.T) {
|
|
c := &Config{JoinKey: "tjk_x"}
|
|
if c.Credential() != "tjk_x" {
|
|
t.Errorf("unenrolled agent should present the join key, got %q", c.Credential())
|
|
}
|
|
c.AuthToken = "own-token"
|
|
if c.Credential() != "own-token" {
|
|
t.Errorf("enrolled agent must present its own token, not the join key, got %q", c.Credential())
|
|
}
|
|
}
|
|
|
|
func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/agent.yml"
|
|
os.WriteFile(path, []byte("server_url: \"x\"\n"), 0600)
|
|
cm, _ := NewConfigManager(path)
|
|
if err := cm.PersistEnrollment("", "pk"); err == nil {
|
|
t.Error("expected an error when the server sends no token")
|
|
}
|
|
}
|