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).
119 lines
3.5 KiB
Go
119 lines
3.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// TestRenderSecrets verifies the agent parses `{{ bao "path#key" }}` placeholders,
|
|
// fetches the secrets from the SSO, renders the template to its target
|
|
// atomically, and runs the reload.
|
|
func TestRenderSecrets(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tpl := filepath.Join(dir, "db.env.tpl")
|
|
target := filepath.Join(dir, "db.env")
|
|
os.WriteFile(tpl, []byte("DB_USER=\"{{ bao \"secret/data/nodes/n1/db#username\" }}\"\nDB_PASS=\"{{ bao \"secret/data/nodes/n1/db#password\" }}\"\n"), 0600)
|
|
|
|
// Fake SSO secrets endpoint.
|
|
var gotPaths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agent/secrets" {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
var req struct{ Paths []string `json:"paths"` }
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
gotPaths = req.Paths
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"secrets": map[string]interface{}{
|
|
"secret/data/nodes/n1/db": map[string]interface{}{
|
|
"username": "alice",
|
|
"password": "s3cret",
|
|
},
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := &Config{
|
|
ServerURL: srv.URL,
|
|
AuthToken: "tok",
|
|
Secrets: []SecretTarget{
|
|
{Template: tpl, Target: target, Reload: ""},
|
|
},
|
|
}
|
|
|
|
exec := &MockExecutor{}
|
|
if err := renderSecrets(cfg, exec); err != nil {
|
|
t.Fatalf("renderSecrets: %v", err)
|
|
}
|
|
|
|
// The requested path should be the one in the template.
|
|
if len(gotPaths) != 1 || gotPaths[0] != "secret/data/nodes/n1/db" {
|
|
t.Fatalf("expected to request secret/data/nodes/n1/db, got %v", gotPaths)
|
|
}
|
|
|
|
// The target should be rendered with the secret values.
|
|
content, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatalf("read target: %v", err)
|
|
}
|
|
expected := "DB_USER=\"alice\"\nDB_PASS=\"s3cret\"\n"
|
|
if string(content) != expected {
|
|
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
|
}
|
|
|
|
// 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)
|
|
if info.Mode().Perm() != 0600 {
|
|
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderSecretsReload verifies the reload command runs after rendering.
|
|
func TestRenderSecretsReload(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tpl := filepath.Join(dir, "app.tpl")
|
|
target := filepath.Join(dir, "app.conf")
|
|
os.WriteFile(tpl, []byte("KEY={{ bao \"secret/data/nodes/n1/app#key\" }}"), 0600)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"secrets": map[string]interface{}{
|
|
"secret/data/nodes/n1/app": map[string]interface{}{"key": "v"},
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := &Config{
|
|
ServerURL: srv.URL,
|
|
AuthToken: "tok",
|
|
Secrets: []SecretTarget{
|
|
{Template: tpl, Target: target, Reload: "systemctl reload app"},
|
|
},
|
|
}
|
|
exec := &MockExecutor{}
|
|
if err := renderSecrets(cfg, exec); err != nil {
|
|
t.Fatalf("renderSecrets: %v", err)
|
|
}
|
|
if len(exec.ExecutedCommands) != 1 {
|
|
t.Fatalf("expected 1 reload command, got %v", exec.ExecutedCommands)
|
|
}
|
|
cmd := exec.ExecutedCommands[0]
|
|
if len(cmd) != 3 || cmd[0] != "sh" || cmd[2] != "systemctl reload app" {
|
|
t.Fatalf("expected reload 'sh -c systemctl reload app', got %v", cmd)
|
|
}
|
|
}
|