Files
theta-agent/config.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

211 lines
6.3 KiB
Go

package main
import (
"fmt"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
type Capabilities struct {
Telemetry bool `yaml:"telemetry"`
ConfigureLDAP bool `yaml:"configure_ldap"`
Reboot bool `yaml:"reboot"`
ServiceControl []string `yaml:"service_control"`
ArbitraryBash bool `yaml:"arbitrary_bash"`
LdapTunnel bool `yaml:"ldap_tunnel"`
Secrets bool `yaml:"secrets"`
IAM bool `yaml:"iam"`
}
// SecretTarget maps a local template to a rendered target file and an optional
// post-render reload command (DESIGN.md §5).
type SecretTarget struct {
Template string `yaml:"template"`
Target string `yaml:"target"`
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 {
ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"`
// A join key is the one credential an operator hands out. On first connect
// the server exchanges it for a per-agent AuthToken (written back to this
// file), so it is a bootstrap value, not a long-term credential. Used only
// when AuthToken is empty.
JoinKey string `yaml:"join_key"`
Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
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
// enrolled, otherwise the join key.
func (c *Config) Credential() string {
if c.AuthToken != "" {
return c.AuthToken
}
return c.JoinKey
}
// ConfigManager handles thread-safe access and reloading of the agent configuration.
type ConfigManager struct {
mu sync.RWMutex
current *Config
configPath string
}
func NewConfigManager(path string) (*ConfigManager, error) {
cfg, err := LoadConfig(path)
if err != nil {
return nil, err
}
return &ConfigManager{
current: cfg,
configPath: path,
}, nil
}
// Get returns a copy of the current configuration.
func (cm *ConfigManager) Get() *Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.current
}
// Reload re-reads the configuration from disk and updates the active config.
func (cm *ConfigManager) Reload() error {
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload failed: %w", err)
}
cm.mu.Lock()
cm.current = cfg
cm.mu.Unlock()
return nil
}
// PersistEnrollment writes the credentials the server issued during join-key
// enrollment back into agent.yml, then reloads. Only the auth_token and
// public_key lines are rewritten (added if absent); every other line, including
// operator comments and the capability matrix, is preserved -- this file is
// hand-edited, so a naive marshal-and-write would destroy it.
//
// The join key is blanked once we hold our own token: leaving a fleet-wide
// credential on every host after it has stopped being needed is exactly the
// blast radius the per-agent token exists to avoid.
func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
if token == "" {
return fmt.Errorf("server reported enrollment but sent no token")
}
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalar(string(raw), "auth_token", token)
if publicKey != "" {
out = setYamlScalar(out, "public_key", publicKey)
}
out = setYamlScalar(out, "join_key", "")
// Same permissions the installer sets: this file now holds a credential.
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after enrollment: %w", err)
}
cm.current = cfg
return nil
}
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
// the key when it is absent. Deliberately line-based rather than a YAML
// round-trip so comments and formatting survive.
func setYamlScalar(doc, key, value string) string {
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
line := fmt.Sprintf("%s: %q", key, value)
if re.MatchString(doc) {
return re.ReplaceAllString(doc, line)
}
if !strings.HasSuffix(doc, "\n") {
doc += "\n"
}
return doc + line + "\n"
}
func LoadConfig(path string) (*Config, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
var cfg Config
decoder := yaml.NewDecoder(file)
if err := decoder.Decode(&cfg); err != nil {
return nil, fmt.Errorf("failed to decode YAML config: %w", err)
}
if cfg.Capabilities.ConfigureLDAP {
cfg.Capabilities.LdapTunnel = true
}
return &cfg, nil
}
// CanManageService checks if a specific service is permitted to be restarted/stopped
func (c *Capabilities) CanManageService(serviceName string) bool {
for _, allowed := range c.ServiceControl {
if allowed == serviceName {
return true
}
}
return false
}