Files
theta-agent/websocket_test.go
T
wmantly 4a619f7adc 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).
2026-08-09 17:10:07 -07:00

378 lines
11 KiB
Go

package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"testing"
)
// A fixed key pair for the tests, standing in for the SSO's persisted signing
// key. High-risk commands must now be genuinely signed: the agent fails closed
// when no public_key is configured, so these tests sign the way the server
// does instead of relying on verification being skipped.
var testPubKey, testPrivKey, _ = ed25519.GenerateKey(nil)
func testPubKeyB64() string {
return base64.StdEncoding.EncodeToString(testPubKey)
}
// sign mirrors the server's canonicalization (sorted keys, no whitespace, no
// HTML escaping, `signature` omitted) and adds the signature to the payload.
func sign(t *testing.T, payload map[string]interface{}) map[string]interface{} {
t.Helper()
if payload == nil {
payload = map[string]interface{}{}
}
canonical, err := canonicalize(payload)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
signed := make(map[string]interface{}, len(payload)+1)
for k, v := range payload {
signed[k] = v
}
signed["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(testPrivKey, canonical))
return signed
}
type MockConn struct {
Messages [][]byte
}
func (m *MockConn) WriteMessage(messageType int, data []byte) error {
m.Messages = append(m.Messages, data)
return nil
}
type MockExecutor struct {
ExecutedCommands [][]string
WrittenFiles map[string][]byte
}
func (m *MockExecutor) Execute(command string, args ...string) ([]byte, error) {
m.ExecutedCommands = append(m.ExecutedCommands, append([]string{command}, args...))
return []byte("mock output"), nil
}
func (m *MockExecutor) WriteFile(path string, data []byte, perm os.FileMode) error {
if m.WrittenFiles == nil {
m.WrittenFiles = make(map[string][]byte)
}
m.WrittenFiles[path] = data
return nil
}
func (m *MockExecutor) ReadFile(path string) ([]byte, error) {
if m.WrittenFiles != nil {
if data, ok := m.WrittenFiles[path]; ok {
return data, nil
}
}
return []byte("mock file content"), nil
}
func TestHandleCommand(t *testing.T) {
tests := []struct {
name string
cfg *Config
msg WSMessage
expectedStatus string
expectedCmd []string
expectedFile string
expectedFileCont string
// sign the payload with the test key before dispatch, the way the SSO
// signs high-risk commands
signed bool
// heartbeat_ack (and any fire-and-forget ack) must be silently ignored —
// no response message, no command, no log noise.
expectedNoResponse bool
}{
{
name: "config command success",
cfg: &Config{
Capabilities: Capabilities{},
},
msg: WSMessage{
Type: "config",
Payload: map[string]interface{}{"key": "value"},
},
expectedStatus: "ok",
},
{
name: "reboot command allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{Reboot: true},
},
msg: WSMessage{
Type: "reboot",
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"reboot"},
},
{
name: "reboot command denied",
cfg: &Config{
Capabilities: Capabilities{Reboot: false},
},
msg: WSMessage{
Type: "reboot",
},
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "service_restart allowed",
cfg: &Config{
Capabilities: Capabilities{
ServiceControl: []string{"nginx"},
},
},
msg: WSMessage{
Type: "service_restart",
Payload: map[string]interface{}{
"service": "nginx",
},
},
expectedStatus: "ok",
expectedCmd: []string{"systemctl", "restart", "nginx"},
},
{
name: "service_restart denied",
cfg: &Config{
Capabilities: Capabilities{
ServiceControl: []string{"nginx"},
},
},
msg: WSMessage{
Type: "service_restart",
Payload: map[string]interface{}{
"service": "ssh",
},
},
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "configure_ldap allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ConfigureLDAP: true},
},
msg: WSMessage{
Type: "configure_ldap",
Payload: map[string]interface{}{
"config": "domain = theta42.local\nserver = sso.local",
},
},
signed: true,
expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
expectedFileCont: "domain = theta42.local\nserver = sso.local",
expectedCmd: []string{"systemctl", "restart", "sssd"},
},
{
name: "configure_ldap denied",
cfg: &Config{
Capabilities: Capabilities{ConfigureLDAP: false},
},
msg: WSMessage{
Type: "configure_ldap",
Payload: map[string]interface{}{
"config": "domain = theta42.local",
},
},
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "arbitrary_bash allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ArbitraryBash: true},
},
msg: WSMessage{
Type: "arbitrary_bash",
Payload: map[string]interface{}{
"script": "uptime",
},
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"bash", "-c", "uptime"},
},
{
name: "arbitrary_bash denied",
cfg: &Config{
Capabilities: Capabilities{ArbitraryBash: false},
},
msg: WSMessage{
Type: "arbitrary_bash",
Payload: map[string]interface{}{
"script": "rm -rf /",
},
},
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "heartbeat_ack is silently ignored",
cfg: &Config{
Capabilities: Capabilities{},
},
msg: WSMessage{
Type: "heartbeat_ack",
},
expectedNoResponse: true,
},
{
name: "unknown command",
cfg: &Config{
Capabilities: Capabilities{},
},
msg: WSMessage{
Type: "mystery_command",
},
expectedStatus: "error",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mockConn := &MockConn{}
mockExec := &MockExecutor{}
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
if tc.signed {
msg.Payload = sign(t, msg.Payload)
}
handleCommand(cm, msg, mockConn, mockExec, nil)
if tc.expectedNoResponse {
if len(mockConn.Messages) != 0 {
t.Fatalf("expected no response message, got %d: %v", len(mockConn.Messages), mockConn.Messages)
}
if len(mockExec.ExecutedCommands) > 0 {
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
}
return
}
if len(mockConn.Messages) != 1 {
t.Fatalf("expected 1 response message, got %d", len(mockConn.Messages))
}
var resp map[string]string
if err := json.Unmarshal(mockConn.Messages[0], &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != tc.expectedStatus {
t.Errorf("expected status %q, got %q", tc.expectedStatus, resp["status"])
}
if tc.expectedCmd != nil {
if len(mockExec.ExecutedCommands) == 0 {
t.Errorf("expected command to be executed, but none were")
} else {
cmd := mockExec.ExecutedCommands[0]
if len(cmd) != len(tc.expectedCmd) {
t.Errorf("expected command length %d, got %d", len(tc.expectedCmd), len(cmd))
}
for i := range cmd {
if cmd[i] != tc.expectedCmd[i] {
t.Errorf("expected arg %d = %q, got %q", i, tc.expectedCmd[i], cmd[i])
}
}
}
} else if len(mockExec.ExecutedCommands) > 0 {
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
}
if tc.expectedFile != "" {
content, ok := mockExec.WrittenFiles[tc.expectedFile]
if !ok {
t.Errorf("expected file %q to be written, but it wasn't", tc.expectedFile)
} else if string(content) != tc.expectedFileCont {
t.Errorf("expected file content %q, got %q", tc.expectedFileCont, string(content))
}
}
})
}
}
// The agent must not execute a high-risk command it cannot verify. This used to
// return true when no public_key was configured, so an agent installed without
// one executed reboot / configure_ldap / arbitrary_bash unverified.
func TestVerifySignatureFailsClosedWithoutPublicKey(t *testing.T) {
cfg := &Config{} // no PublicKey
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a command with no public_key configured")
}
}
func TestVerifySignatureRejectsWrongKey(t *testing.T) {
otherPub, _, _ := ed25519.GenerateKey(nil)
cfg := &Config{PublicKey: base64.StdEncoding.EncodeToString(otherPub)}
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a signature from a different key")
}
}
func TestVerifySignatureRejectsTamperedPayload(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
payload := sign(t, map[string]interface{}{"script": "uptime"})
payload["script"] = "rm -rf /" // swap the script, keep the signature
if verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("verifySignature accepted a payload modified after signing")
}
}
// Regression: encoding/json escapes <, > and & by default, but the server's
// JSON.stringify does not. Any script using redirection or && therefore
// canonicalized differently on each side and failed verification -- which is
// most real scripts.
func TestVerifySignatureAcceptsShellMetacharacters(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
for _, script := range []string{
"echo hi > /tmp//out.log",
"systemctl is-active nginx && systemctl reload nginx",
"grep -c . < /etc/passwd",
"a=1 && b=2 && echo \"$a<$b\" > /dev/null",
} {
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": script})}
if !verifySignature(cfg, msg) {
t.Errorf("verifySignature rejected a correctly signed script: %q", script)
}
}
}
// The canonical form must be byte-identical to the server's: sorted keys, no
// whitespace, no HTML escaping, no trailing newline, signature omitted.
func TestCanonicalizeMatchesServerForm(t *testing.T) {
got, err := canonicalize(map[string]interface{}{
"script": "echo a > b && c",
"comment": "x&y",
})
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
want := `{"comment":"x&y","script":"echo a > b && c"}`
if string(got) != want {
t.Errorf("canonical form mismatch:\n got: %s\nwant: %s", got, want)
}
}