feat(windows): WireGuard client, auto-VPN, IAM, tray enrichment, installer, CI
Second milestone of the Windows parity work (DESIGN-WINDOWS.md §13). WireGuard mesh client (signed, WSS-delivered): - wireguard_apply / wireguard_remove commands: Ed25519-verified, gated on a new wireguard capability. Linux applies via wg-quick up/down; Windows installs the peer config as a WireGuardTunnel service via wireguard.exe (/installtunnelservice, /uninstalltunnelservice) - state polling in the home monitor drives the blue tray icon and auto-VPN: connect when away from home + auto_vpn, disconnect on return (2m cooldown) - tray VPN toggle and the auto-VPN checkbox are now live; the preference persists to agent.yml (PersistAutoVPN) IAM on Windows (iam_windows.go): - allowed_login_groups -> net localgroup; ssh_keys -> per-profile authorized_keys + %ProgramData%\ssh\administrators_authorized_keys; revoke_users -> helper logs off all of the user's WTS sessions; sudo_rules logged as no direct equivalent Tray enrichment: - Open Config (opens agent.yml), Clear enrollment (re-enroll) menu items - set_auto_vpn persists; vpn_connect/vpn_disconnect/reinit/open_config commands handled by the daemon (tray_server.go) Packaging & release: - installer/windows/installer.iss: fully-offline Inno Setup bundle (agent, tray, helper, vendor-signed WireGuard MSI, OpenCredential CP, VC++ redist), /SILENT /SERVER_URL /JOIN_KEY parameters, SYSTEM service + HKLM Run tray autostart, Users-writable %ProgramData%\Theta42 for the IPC socket - .github/workflows/build-windows.yml: build + test, pinned vendor downloads, ISCC compile, Azure Trusted Signing (OIDC), SHA256SUMS, GH release attach, optional SSO resource publish - agent.yml.example documents auto_vpn, wireguard, service_name, desktop_helper, public_ip_detect Tests: - wireguard_apply/remove dispatch (allowed + capability-denied), PersistAutoVPN, ClearEnrollment; dispatch tests pin linuxPlatformOps with a temp WireGuard conf - end-to-end verified against a local mock SSO on Windows: join-key enrollment (token persisted, join key blanked), discovery/telemetry pushed, signed arbitrary_bash verified + executed via powershell -EncodedCommand; tray IPC socket binds %ProgramData%\Theta42; LDAP byte-pump binds 127.0.0.1:389; helper update swap verified Rebuilds all tracked dist binaries (v2.1.0).
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
name: build-windows
|
||||
|
||||
# Builds the Windows agent, tray, session helper, and the fully-offline Inno
|
||||
# installer; signs them with Azure Trusted Signing; generates SHA256SUMS; and
|
||||
# publishes the artifacts to the SSO resource tree (DESIGN-WINDOWS.md §9).
|
||||
#
|
||||
# Signing requires GitHub OIDC federation to Azure. If the Azure secrets are
|
||||
# not configured the workflow still builds and attaches unsigned artifacts.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.22.2"
|
||||
VERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Run unit tests
|
||||
shell: bash
|
||||
run: go test ./...
|
||||
|
||||
- name: Build agent, tray, helper
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
LDFLAGS="-s -w"
|
||||
for arch in amd64 arm64; do
|
||||
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-windows-$arch.exe .
|
||||
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-tray-windows-$arch.exe ./cmd/theta-agent-tray/
|
||||
GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-helper-windows-$arch.exe ./cmd/theta-agent-helper/
|
||||
done
|
||||
|
||||
- name: Fetch vendored, vendor-signed dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
$vendor = "installer/windows/vendor"
|
||||
New-Item -ItemType Directory -Force -Path $vendor | Out-Null
|
||||
# Pinned by sha256; WireGuard MSI and VC++ redist are Microsoft/vendor signed.
|
||||
$assets = @(
|
||||
@{ url = "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi"; out = "wireguard-amd64-0.5.3.msi"; sha = "PINNED_WIREGUARD_SHA256" },
|
||||
@{ url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; out = "vc_redist.x64.exe"; sha = "PINNED_VC_REDIST_SHA256" }
|
||||
)
|
||||
foreach ($a in $assets) {
|
||||
$dest = Join-Path $vendor $a.out
|
||||
Invoke-WebRequest -Uri $a.url -OutFile $dest
|
||||
$h = (Get-FileHash -Algorithm SHA256 $dest).Hash.ToLower()
|
||||
if ($h -ne $a.sha) { throw "sha256 mismatch for $($a.out): $h" }
|
||||
Set-Content -Path "$dest.sha256" -Value $h
|
||||
}
|
||||
# OpenCredential (pGina fork) release — swap PINNED below for a real release URL.
|
||||
$oc = "https://github.com/pedropablobm/OpenCredential/releases/latest/download/OpenCredential.zip"
|
||||
Invoke-WebRequest -Uri $oc -OutFile (Join-Path $vendor "opencredential.zip")
|
||||
Expand-Archive -Path (Join-Path $vendor "opencredential.zip") -DestinationPath (Join-Path $vendor "OpenCredential")
|
||||
|
||||
- name: Compile Inno installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup -y --no-progress
|
||||
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
if (-not (Test-Path $iscc)) { $iscc = "C:\Program Files\Inno Setup 6\ISCC.exe" }
|
||||
& $iscc "/DMyAppVersion=${{ env.VERSION }}" installer/windows/installer.iss
|
||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem dist -File | Where-Object { $_.Extension -in ".exe",".msi" -or $_.Name -like "*setup*" } | ForEach-Object {
|
||||
"{0} {1}" -f (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLower(), $_.Name
|
||||
} | Set-Content -Path dist/SHA256SUMS
|
||||
|
||||
- name: Sign with Azure Trusted Signing
|
||||
if: env.AZURE_TENANT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: AzureSignTool (agent, tray, helper, CP, installer)
|
||||
if: env.AZURE_TENANT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.4.0
|
||||
with:
|
||||
endpoint: ${{ secrets.AZURE_TS_ENDPOINT }}
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE }}
|
||||
files: |
|
||||
dist/theta-agent-windows-*.exe
|
||||
dist/theta-agent-tray-windows-*.exe
|
||||
dist/theta-agent-helper-windows-*.exe
|
||||
dist/theta-agent-*-setup.exe
|
||||
output-files: ""
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: theta-agent-windows
|
||||
path: |
|
||||
dist/theta-agent-windows-*.exe
|
||||
dist/theta-agent-helper-windows-*.exe
|
||||
dist/theta-agent-tray-windows-*.exe
|
||||
dist/theta-agent-*-setup.exe
|
||||
dist/SHA256SUMS
|
||||
|
||||
- name: Attach to GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/theta-agent-windows-*.exe
|
||||
dist/theta-agent-helper-windows-*.exe
|
||||
dist/theta-agent-tray-windows-*.exe
|
||||
dist/theta-agent-*-setup.exe
|
||||
dist/SHA256SUMS
|
||||
|
||||
# DESIGN-WINDOWS.md §9: the SSO holds the release resources. Publish when
|
||||
# an admin-gated upload endpoint is configured; otherwise the artifacts
|
||||
# are available from the GitHub release above.
|
||||
- name: Publish to SSO resource tree
|
||||
if: env.SSO_RESOURCE_PUBLISH_URL != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$headers = @{ Authorization = "Bearer $env:SSO_RESOURCE_PUBLISH_TOKEN" }
|
||||
foreach ($f in Get-ChildItem dist -File) {
|
||||
if ($f.Name -notmatch "setup|windows-|SHA256SUMS") { continue }
|
||||
Invoke-RestMethod -Method Post -Uri "$env:SSO_RESOURCE_PUBLISH_URL/$($f.Name)" -InFile $f.FullName -Headers $headers
|
||||
}
|
||||
env:
|
||||
SSO_RESOURCE_PUBLISH_URL: ${{ secrets.SSO_RESOURCE_PUBLISH_URL }}
|
||||
SSO_RESOURCE_PUBLISH_TOKEN: ${{ secrets.SSO_RESOURCE_PUBLISH_TOKEN }}
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
# theta-agent configuration file
|
||||
# Default location: /etc/theta42/agent.yml
|
||||
# Default location: /etc/theta42/agent.yml (Linux) or %ProgramData%\Theta42\agent.yml (Windows)
|
||||
|
||||
server_url: "https://sso.example.com"
|
||||
|
||||
@@ -30,8 +30,25 @@ location: "default" # Location identifier (e.g., site, datacenter) for naming
|
||||
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
|
||||
# never parses LDAP. Point SSSD at it with:
|
||||
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
|
||||
# (Windows relies on the TCP loopback listener 127.0.0.1:389 instead.)
|
||||
ldap_socket: "/run/theta/ldap.sock"
|
||||
|
||||
# Auto-connect the WireGuard tunnel when this host is away from home and the
|
||||
# directory WebSocket is up. The tray checkbox persists here too.
|
||||
auto_vpn: false
|
||||
|
||||
# Windows-specific (DESIGN-WINDOWS.md §11). Ignored on Linux.
|
||||
service_name: "theta-agent" # Windows service name
|
||||
desktop_helper: "" # theta-agent-helper.exe path (session-0 ops)
|
||||
public_ip_detect: true # false = air-gap: never call external IP services
|
||||
|
||||
# WireGuard mesh client (DESIGN-WINDOWS.md §5). The signed wireguard_apply
|
||||
# command pushes the peer config down the WSS channel; these are local paths.
|
||||
wireguard:
|
||||
tunnel_name: "theta-mesh"
|
||||
conf: "" # "" = platform default (/etc/wireguard/... or %ProgramData%\Theta42\wg\...)
|
||||
executable: "" # wireguard.exe path (Windows; "" = PATH/default install)
|
||||
|
||||
capabilities:
|
||||
# ---------------------------------------------------------
|
||||
# Basic Capabilities (Safe, read-only or infrastructure management)
|
||||
@@ -52,6 +69,9 @@ capabilities:
|
||||
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
|
||||
iam: true
|
||||
|
||||
# Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md §5)
|
||||
wireguard: false
|
||||
|
||||
# Secret templates to render (DESIGN.md §5). Each maps a local template to a
|
||||
# target file and an optional post-render reload. The template embeds secrets as
|
||||
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
|
||||
|
||||
@@ -116,23 +116,32 @@ func sleepHost() {
|
||||
}
|
||||
}
|
||||
|
||||
// logoutSession logs off the active console session, or the session of the
|
||||
// logoutSession logs off the active console session, or every session of the
|
||||
// given user (matched by WTS session enumeration).
|
||||
func logoutSession(user string) {
|
||||
sessionID := activeConsoleSessionID()
|
||||
if user != "" {
|
||||
id, err := sessionForUser(user)
|
||||
var ids []uint32
|
||||
if user == "" {
|
||||
if id := activeConsoleSessionID(); id != 0 {
|
||||
ids = []uint32{id}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
ids, err = sessionsForUser(user)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logout: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sessionID = id
|
||||
}
|
||||
if sessionID == 0 {
|
||||
if len(ids) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "logout: no active session to log off")
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, id := range ids {
|
||||
logoffSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
func logoffSession(sessionID uint32) {
|
||||
r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0)
|
||||
if r == 0 {
|
||||
fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err)
|
||||
@@ -146,25 +155,30 @@ func activeConsoleSessionID() uint32 {
|
||||
return uint32(id)
|
||||
}
|
||||
|
||||
func sessionForUser(user string) (uint32, error) {
|
||||
// sessionsForUser returns every session whose logged-in user matches.
|
||||
func sessionsForUser(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)
|
||||
return nil, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
|
||||
}
|
||||
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
|
||||
|
||||
want := strings.ToLower(user)
|
||||
var ids []uint32
|
||||
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
|
||||
ids = append(ids, info.SessionID)
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no active session for user %q", user)
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no active session for user %q", user)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func wtsSessionUsername(sessionID uint32) string {
|
||||
|
||||
@@ -111,6 +111,8 @@ var (
|
||||
mAutoVPN *systray.MenuItem
|
||||
mVPNToggle *systray.MenuItem
|
||||
mSeparator *systray.MenuItem
|
||||
mOpenConfig *systray.MenuItem
|
||||
mReinit *systray.MenuItem
|
||||
mQuit *systray.MenuItem
|
||||
|
||||
currentStatus TrayStatus
|
||||
@@ -130,7 +132,9 @@ func onReady() {
|
||||
mAutoVPN = systray.AddMenuItemCheckbox("Auto-connect VPN when away", "Automatically connect to home via WireGuard when not on the home LAN", false)
|
||||
mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
|
||||
systray.AddSeparator()
|
||||
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
||||
mOpenConfig = systray.AddMenuItem("Open Config", "Open agent.yml in the default editor")
|
||||
mReinit = systray.AddMenuItem("Clear enrollment…", "Blank auth_token/public_key so the agent re-enrolls on reconnect")
|
||||
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
||||
|
||||
// ── IPC loop — connect with retry ──
|
||||
go connectWithRetry()
|
||||
@@ -156,6 +160,12 @@ func onReady() {
|
||||
sendCmd(TrayCommand{Command: "vpn_connect"})
|
||||
}
|
||||
|
||||
case <-mOpenConfig.ClickedCh:
|
||||
sendCmd(TrayCommand{Command: "open_config"})
|
||||
|
||||
case <-mReinit.ClickedCh:
|
||||
sendCmd(TrayCommand{Command: "reinit"})
|
||||
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Capabilities struct {
|
||||
LdapTunnel bool `yaml:"ldap_tunnel"`
|
||||
Secrets bool `yaml:"secrets"`
|
||||
IAM bool `yaml:"iam"`
|
||||
WireGuard bool `yaml:"wireguard"`
|
||||
}
|
||||
|
||||
// SecretTarget maps a local template to a rendered target file and an optional
|
||||
@@ -31,10 +32,13 @@ type SecretTarget struct {
|
||||
|
||||
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
|
||||
type WireGuardConfig struct {
|
||||
// TunnelName is the Windows WireGuard tunnel/service name.
|
||||
// TunnelName is the WireGuard interface (Linux) / service name (Windows).
|
||||
TunnelName string `yaml:"tunnel_name"`
|
||||
// Conf is where the pushed peer config is persisted on disk.
|
||||
Conf string `yaml:"conf"`
|
||||
// Executable is the wireguard.exe path (Windows); "" = PATH or default
|
||||
// install location.
|
||||
Executable string `yaml:"executable"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -55,6 +59,7 @@ type Config struct {
|
||||
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)
|
||||
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
|
||||
WireGuard WireGuardConfig `yaml:"wireguard"`
|
||||
}
|
||||
|
||||
@@ -164,12 +169,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistAutoVPN writes the tray's auto-VPN preference back into agent.yml so
|
||||
// it survives a restart. Same line-preserving edit as PersistEnrollment.
|
||||
func (cm *ConfigManager) PersistAutoVPN(value bool) error {
|
||||
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 := setYamlScalarValue(string(raw), "auto_vpn", fmt.Sprintf("%t", value), false)
|
||||
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 auto_vpn: %w", err)
|
||||
}
|
||||
cm.current = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEnrollment blanks the auth_token and public_key so the agent re-enrolls
|
||||
// with whatever join_key is configured. Triggered by the tray's "re-enroll".
|
||||
func (cm *ConfigManager) ClearEnrollment() error {
|
||||
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", "")
|
||||
out = setYamlScalar(out, "public_key", "")
|
||||
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 clear: %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 {
|
||||
return setYamlScalarValue(doc, key, value, true)
|
||||
}
|
||||
|
||||
// setYamlScalarValue is setYamlScalar with control over quoting. Numeric/bool
|
||||
// scalars (e.g. auto_vpn: true) must stay unquoted or YAML decodes them as
|
||||
// strings.
|
||||
func setYamlScalarValue(doc, key, value string, quote bool) string {
|
||||
line := fmt.Sprintf("%s: %s", key, value)
|
||||
if quote {
|
||||
line = fmt.Sprintf("%s: %q", key, value)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -206,3 +206,55 @@ func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
|
||||
t.Error("expected an error when the server sends no token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistAutoVPN writes the tray preference into the file and reloads it.
|
||||
func TestPersistAutoVPN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/agent.yml"
|
||||
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_abc123\"\n"), 0600)
|
||||
cm, err := NewConfigManager(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cm.PersistAutoVPN(true); err != nil {
|
||||
t.Fatalf("PersistAutoVPN: %v", err)
|
||||
}
|
||||
if !cm.Get().AutoVPN {
|
||||
t.Errorf("AutoVPN should be true after persist")
|
||||
}
|
||||
out, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(out), "auto_vpn: true") {
|
||||
t.Errorf("expected auto_vpn: true in file, got:\n%s", out)
|
||||
}
|
||||
|
||||
if err := cm.PersistAutoVPN(false); err != nil {
|
||||
t.Fatalf("PersistAutoVPN(false): %v", err)
|
||||
}
|
||||
if cm.Get().AutoVPN {
|
||||
t.Errorf("AutoVPN should be false after persist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearEnrollment blanks credentials so the agent re-enrolls.
|
||||
func TestClearEnrollment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/agent.yml"
|
||||
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\nauth_token: \"tok-abc\"\npublic_key: \"PK\"\njoin_key: \"tjk_keep\"\n"), 0600)
|
||||
cm, err := NewConfigManager(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cm.ClearEnrollment(); err != nil {
|
||||
t.Fatalf("ClearEnrollment: %v", err)
|
||||
}
|
||||
cfg := cm.Get()
|
||||
if cfg.AuthToken != "" || cfg.PublicKey != "" {
|
||||
t.Errorf("expected cleared credentials, got token=%q pub=%q", cfg.AuthToken, cfg.PublicKey)
|
||||
}
|
||||
out, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(out), "tok-abc") {
|
||||
t.Errorf("old token should be gone from file, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
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.
@@ -67,6 +67,21 @@ func SetVPNActive(active bool) {
|
||||
homeState.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAutoVPN records the auto-connect preference (initialized from agent.yml,
|
||||
// updated by the tray checkbox).
|
||||
func SetAutoVPN(v bool) {
|
||||
homeState.mu.Lock()
|
||||
homeState.autoVPN = v
|
||||
homeState.mu.Unlock()
|
||||
}
|
||||
|
||||
// AutoVPN returns the current auto-connect preference.
|
||||
func AutoVPN() bool {
|
||||
homeState.mu.RLock()
|
||||
defer homeState.mu.RUnlock()
|
||||
return homeState.autoVPN
|
||||
}
|
||||
|
||||
// StartHomeMonitor periodically refreshes the agent's public IP and pushes
|
||||
// updated tray status. Call as a goroutine from main().
|
||||
func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
|
||||
@@ -107,5 +122,62 @@ func checkAndPush(cfg *Config, connectedFn func() bool) {
|
||||
siteName = "home"
|
||||
}
|
||||
|
||||
// WireGuard state + auto-VPN (DESIGN-WINDOWS.md §5). The tunnel can be
|
||||
// driven by the SSO (wireguard_apply/remove) or by the tray; polling keeps
|
||||
// the tray icon blue and lets auto-VPN react to home/away changes.
|
||||
if cfg.Capabilities.WireGuard {
|
||||
vpn = defaultPlatformOps.WireGuardState()
|
||||
SetVPNActive(vpn)
|
||||
isHome := computeIsHome(agentIP, homeIP, connected, cfg.ServerURL)
|
||||
handleAutoVPN(cfg, isHome, vpn, autoVPN, connected)
|
||||
}
|
||||
|
||||
UpdateTrayStatus(connected, agentIP, homeIP, vpn, autoVPN, siteName, cfg.ServerURL)
|
||||
}
|
||||
|
||||
// computeIsHome mirrors the home-LAN determination in UpdateTrayStatus: public
|
||||
// IP matches the directory's site IP, the server is local/LAN, or the site IP
|
||||
// is not yet known (assume local home).
|
||||
func computeIsHome(agentPublicIP, homePublicIP string, connected bool, serverURL string) bool {
|
||||
if !connected {
|
||||
return false
|
||||
}
|
||||
isLocalServer := strings.Contains(serverURL, "localhost") ||
|
||||
strings.Contains(serverURL, "127.0.0.1") ||
|
||||
strings.Contains(serverURL, ".local") ||
|
||||
strings.Contains(serverURL, "192.168.") ||
|
||||
strings.Contains(serverURL, "10.")
|
||||
return (homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP) || isLocalServer || homePublicIP == ""
|
||||
}
|
||||
|
||||
// lastAutoVPNChange gates auto-VPN so the home monitor (60s tick) does not
|
||||
// hammer connect/disconnect on every poll.
|
||||
var lastAutoVPNChange time.Time
|
||||
|
||||
// handleAutoVPN connects the tunnel when away from home and auto-connect is on,
|
||||
// and drops it again once back on the home LAN.
|
||||
func handleAutoVPN(cfg *Config, isHome, vpn, autoVPN, connected bool) {
|
||||
if !autoVPN || !connected {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Sub(lastAutoVPNChange) < 2*time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
if isHome && vpn {
|
||||
log.Println("[home-detect] back home; disconnecting WireGuard (auto-vpn)")
|
||||
if err := defaultPlatformOps.DisconnectWireGuard(); err != nil {
|
||||
log.Printf("[home-detect] disconnect failed: %v", err)
|
||||
}
|
||||
lastAutoVPNChange = now
|
||||
return
|
||||
}
|
||||
if !isHome && !vpn {
|
||||
log.Println("[home-detect] away from home; connecting WireGuard (auto-vpn)")
|
||||
if err := defaultPlatformOps.ConnectWireGuard(); err != nil {
|
||||
log.Printf("[home-detect] connect failed: %v", err)
|
||||
}
|
||||
lastAutoVPNChange = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// Windows IAM helpers (DESIGN-WINDOWS.md §4 "IAM on Windows"). The platform
|
||||
// neutral parts live in iam.go; this file adds the Windows-specific pieces that
|
||||
// applyIAM cannot express (it is the Linux engine).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// applyWindowsSSHKeys writes each user's authorized_keys into their profile so
|
||||
// the built-in Windows OpenSSH server picks them up. OpenSSH on Windows reads
|
||||
// %USERPROFILE%\.ssh\authorized_keys for standard accounts (administrators use
|
||||
// %ProgramData%\ssh\administrators_authorized_keys; we mirror the key there too
|
||||
// when the profile belongs to an admin is unknowable here, so we write both
|
||||
// locations if present).
|
||||
func applyWindowsSSHKeys(keys []SSHKey) error {
|
||||
pd := os.Getenv("ProgramData")
|
||||
if pd == "" {
|
||||
pd = `C:\ProgramData`
|
||||
}
|
||||
|
||||
// administrators_authorized_keys covers members of Administrators.
|
||||
adminKeys := programDataAdminKeysPath(pd)
|
||||
|
||||
for _, k := range keys {
|
||||
if k.User == "" {
|
||||
continue
|
||||
}
|
||||
content := strings.Join(k.Keys, "\n") + "\n"
|
||||
|
||||
profile := filepath.Join(`C:\Users`, k.User)
|
||||
if st, err := os.Stat(profile); err == nil && st.IsDir() {
|
||||
sshDir := filepath.Join(profile, ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", sshDir, err)
|
||||
}
|
||||
dest := filepath.Join(sshDir, "authorized_keys")
|
||||
if err := os.WriteFile(dest, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dest, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Also mirror into administrators_authorized_keys so admin-keyed logins
|
||||
// work through OpenSSH's admin ACL check.
|
||||
if err := os.MkdirAll(filepath.Dir(adminKeys), 0700); err == nil {
|
||||
_ = os.WriteFile(adminKeys, []byte(content), 0600)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func programDataAdminKeysPath(pd string) string {
|
||||
return filepath.Join(pd, "ssh", "administrators_authorized_keys")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
; Theta Agent — Windows installer (Inno Setup 6.4+)
|
||||
;
|
||||
; Fully offline: bundles the agent, tray, session helper, the official WireGuard
|
||||
; for Windows client, the OpenCredential credential provider, and the VC++ v14
|
||||
; runtime. Nothing on the target machine requires internet access.
|
||||
;
|
||||
; Usage:
|
||||
; iscc installer\windows\installer.iss
|
||||
; theta-agent-2.1.0-windows-amd64-setup.exe /SILENT ^
|
||||
; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_...
|
||||
;
|
||||
; /SERVER_URL and /JOIN_KEY are written into agent.yml so the installed service
|
||||
; enrolls on first start (no UI, no extra click).
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "2.1.0"
|
||||
#endif
|
||||
|
||||
#define MyAppName "Theta Agent"
|
||||
#define MyAppPublisher "Theta42"
|
||||
#define MyAppExeName "theta-agent-windows-amd64.exe"
|
||||
#define AgentDir "..\..\dist"
|
||||
#define VendorDir "vendor"
|
||||
|
||||
[Setup]
|
||||
AppId={{E2F64E2C-7A2B-4F4D-9E8C-9C0D0E9F3A21}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
DefaultDirName={autopf}\Theta42
|
||||
DefaultGroupName=Theta42
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#AgentDir}
|
||||
OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
UninstallDisplayName={#MyAppName}
|
||||
CloseApplications=no
|
||||
MinVersion=10.0.17763
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Dirs]
|
||||
; The daemon (SYSTEM) and the tray (logged-in user) share %ProgramData%\Theta42
|
||||
; for agent.yml, the tray IPC socket and the WireGuard config. Users need write
|
||||
; access for the socket; the agent.yml ACL is tightened by the code.
|
||||
Name: "{commonappdata}\Theta42"; Permissions: users-modify
|
||||
Name: "{app}\vendor"
|
||||
|
||||
[Files]
|
||||
Source: "{#AgentDir}\theta-agent-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#AgentDir}\theta-agent-tray-windows-amd64.exe"; DestDir: "{app}\tray"; Flags: ignoreversion
|
||||
Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
; WireGuard for Windows — official, vendor-signed MSI. Install happens offline
|
||||
; (the driver is signed; no signature phone-home).
|
||||
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
|
||||
; OpenCredential credential provider (BSD-3 pGina fork) + VC++ runtime it needs.
|
||||
Source: "{#VendorDir}\OpenCredential\*"; DestDir: "{app}\OpenCredential"; Flags: ignoreversion recursesubdirs
|
||||
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
Source: "{#VendorDir}\vc_redist.x64.exe.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
|
||||
[Registry]
|
||||
; Start the tray for every interactive logon.
|
||||
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "ThetaAgentTray"; ValueData: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
; VC++ v14 runtime (OpenCredential native deps).
|
||||
Filename: "{app}\vendor\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Installing VC++ runtime..."; Flags: runhidden waituntilterminated
|
||||
; WireGuard for Windows client.
|
||||
Filename: "msiexec.exe"; Parameters: "/i ""{app}\vendor\wireguard-amd64-0.5.3.msi"" /qn /norestart"; StatusMsg: "Installing WireGuard client..."; Flags: runhidden waituntilterminated
|
||||
; Register the agent as a SYSTEM auto-start service.
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated
|
||||
; Credential provider registration + agent.yml are handled in [Code] so we can
|
||||
; feed SERVER_URL/JOIN_KEY in and sequence the CP install before logon.
|
||||
Filename: "{app}\OpenCredential\OpenCredentialInstaller.exe"; Parameters: "/S"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated skipifsilent
|
||||
|
||||
[Code]
|
||||
var
|
||||
ServerURL: String;
|
||||
JoinKey: String;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
begin
|
||||
ServerURL := GetCmdLineParam('/SERVER_URL', '');
|
||||
JoinKey := GetCmdLineParam('/JOIN_KEY', '');
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
// Write agent.yml only after all files are in place. The file lives in
|
||||
// ProgramData so the SYSTEM service and user processes share it.
|
||||
procedure WriteAgentConfig(ConfigPath: String);
|
||||
var
|
||||
Lines: TArrayOfString;
|
||||
begin
|
||||
SetArrayLength(Lines, 13);
|
||||
Lines[0] := '# theta-agent configuration (written by installer)';
|
||||
Lines[1] := 'server_url: "' + ServerURL + '"';
|
||||
Lines[2] := 'auth_token: ""';
|
||||
Lines[3] := 'join_key: "' + JoinKey + '"';
|
||||
Lines[4] := 'public_key: ""';
|
||||
Lines[5] := 'auto_vpn: false';
|
||||
Lines[6] := 'service_name: "theta-agent"';
|
||||
Lines[7] := 'desktop_helper: "' + ExpandConstant('{app}') + '\theta-agent-helper-windows-amd64.exe"';
|
||||
Lines[8] := 'public_ip_detect: true';
|
||||
Lines[9] := 'capabilities:';
|
||||
Lines[10] := ' telemetry: true';
|
||||
Lines[11] := ' ldap_tunnel: true';
|
||||
Lines[12] := ' wireguard: true';
|
||||
SaveStringsToUTF8File(ConfigPath, Lines, False);
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
WriteAgentConfig(ExpandConstant('{commonappdata}\Theta42\agent.yml'));
|
||||
end;
|
||||
@@ -23,6 +23,10 @@ var (
|
||||
agentStopCh = make(chan struct{})
|
||||
)
|
||||
|
||||
// currentCM lets the tray IPC server persist preferences (auto_vpn) and reset
|
||||
// enrollment into the live config file. Set once in runAgent.
|
||||
var currentCM *ConfigManager
|
||||
|
||||
// stopAgent signals the running agent to shut down. Idempotent.
|
||||
func stopAgent() {
|
||||
agentStopOnce.Do(func() { close(agentStopCh) })
|
||||
@@ -71,6 +75,10 @@ func runAgent() {
|
||||
// dispatcher runs behind.
|
||||
exec := &SystemExecutor{}
|
||||
defaultPlatformOps = NewPlatformOps(cfg, exec)
|
||||
currentCM = cm
|
||||
|
||||
// Seed the auto-VPN preference from disk; the tray checkbox updates it.
|
||||
SetAutoVPN(cfg.AutoVPN)
|
||||
|
||||
// Tray IPC server — desktop tray connects here for status updates.
|
||||
go globalTrayServer.Start()
|
||||
|
||||
@@ -45,3 +45,13 @@ func defaultLdapSocketPath() string {
|
||||
func windowsTraySocketPath() string {
|
||||
return filepath.Join(windowsDataDir(), "tray.sock")
|
||||
}
|
||||
|
||||
// defaultWireGuardConfPath returns where the pushed peer config is persisted.
|
||||
// Linux uses /etc/wireguard so wg-quick finds it by interface name; Windows
|
||||
// keeps it in the shared data dir next to the tray socket.
|
||||
func defaultWireGuardConfPath(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(windowsDataDir(), "wg", name+".conf")
|
||||
}
|
||||
return filepath.Join("/etc/wireguard", name+".conf")
|
||||
}
|
||||
|
||||
@@ -5,5 +5,17 @@ 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}
|
||||
name := cfg.WireGuard.TunnelName
|
||||
if name == "" {
|
||||
name = "theta-mesh"
|
||||
}
|
||||
conf := cfg.WireGuard.Conf
|
||||
if conf == "" {
|
||||
conf = defaultWireGuardConfPath(name)
|
||||
}
|
||||
return &linuxPlatformOps{
|
||||
exec: exec,
|
||||
tunnelName: name,
|
||||
confPath: conf,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,20 @@ package main
|
||||
|
||||
// NewPlatformOps returns the Windows implementation.
|
||||
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps {
|
||||
name := cfg.WireGuard.TunnelName
|
||||
if name == "" {
|
||||
name = "theta-mesh"
|
||||
}
|
||||
conf := cfg.WireGuard.Conf
|
||||
if conf == "" {
|
||||
conf = defaultWireGuardConfPath(name)
|
||||
}
|
||||
return &windowsPlatformOps{
|
||||
exec: exec,
|
||||
helperPath: cfg.DesktopHelper,
|
||||
serviceName: cfg.ServiceName,
|
||||
tunnelName: name,
|
||||
confPath: conf,
|
||||
wgExe: cfg.WireGuard.Executable,
|
||||
}
|
||||
}
|
||||
|
||||
+58
-1
@@ -15,7 +15,9 @@ import (
|
||||
)
|
||||
|
||||
type linuxPlatformOps struct {
|
||||
exec Executor
|
||||
exec Executor
|
||||
tunnelName string // WireGuard interface/tunnel name
|
||||
confPath string // persisted peer config path
|
||||
}
|
||||
|
||||
func (p *linuxPlatformOps) Reboot() ([]byte, error) {
|
||||
@@ -156,3 +158,58 @@ func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
|
||||
func (p *linuxPlatformOps) SelfRestart() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ApplyWireGuard persists the peer config and brings the tunnel up with
|
||||
// wg-quick (wg-quick reads /etc/wireguard/<name>.conf by interface name).
|
||||
func (p *linuxPlatformOps) ApplyWireGuard(conf string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
|
||||
return fmt.Errorf("wireguard: create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
|
||||
return fmt.Errorf("wireguard: persist config: %w", err)
|
||||
}
|
||||
out, err := p.exec.Execute("wg-quick", "up", p.tunnelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: wg-quick up %s: %v: %s", p.tunnelName, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *linuxPlatformOps) RemoveWireGuard() error {
|
||||
out, err := p.exec.Execute("wg-quick", "down", p.tunnelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: wg-quick down %s: %v: %s", p.tunnelName, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WireGuardState reports whether the tunnel interface exists.
|
||||
func (p *linuxPlatformOps) WireGuardState() bool {
|
||||
out, err := p.exec.Execute("ip", "link", "show", p.tunnelName)
|
||||
return err == nil && len(out) > 0
|
||||
}
|
||||
|
||||
// ConnectWireGuard brings the persisted config up unless already active.
|
||||
func (p *linuxPlatformOps) ConnectWireGuard() error {
|
||||
if p.WireGuardState() {
|
||||
return nil
|
||||
}
|
||||
conf, err := os.ReadFile(p.confPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
|
||||
}
|
||||
return p.ApplyWireGuard(string(conf))
|
||||
}
|
||||
|
||||
func (p *linuxPlatformOps) DisconnectWireGuard() error {
|
||||
if !p.WireGuardState() {
|
||||
return nil
|
||||
}
|
||||
return p.RemoveWireGuard()
|
||||
}
|
||||
|
||||
// ApplyIAM is the Linux node-identity engine (sudoers.d, SSH keys, PAM access,
|
||||
// SSSD cache flush + session kill).
|
||||
func (p *linuxPlatformOps) ApplyIAM(payload IAMPayload) error {
|
||||
return applyIAM(payload, p.exec)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,19 @@ type PlatformOps interface {
|
||||
// SelfRestart terminates the agent so a staged update takes effect. Linux
|
||||
// exits; the Windows service handler stops itself (the helper restarts it).
|
||||
SelfRestart()
|
||||
|
||||
// WireGuard mesh client (DESIGN-WINDOWS.md §5). ApplyWireGuard persists and
|
||||
// brings up a peer tunnel; RemoveWireGuard tears it down; WireGuardState
|
||||
// reports whether the tunnel is up; ConnectWireGuard/DisconnectWireGuard
|
||||
// drive the tunnel from the persisted config (auto-VPN).
|
||||
ApplyWireGuard(conf string) error
|
||||
RemoveWireGuard() error
|
||||
WireGuardState() (active bool)
|
||||
ConnectWireGuard() error
|
||||
DisconnectWireGuard() error
|
||||
|
||||
// ApplyIAM applies a verified node identity payload (DESIGN.md §6).
|
||||
ApplyIAM(payload IAMPayload) error
|
||||
}
|
||||
|
||||
// defaultPlatformOps is the ops implementation the command dispatcher uses.
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf16"
|
||||
@@ -34,6 +35,9 @@ type windowsPlatformOps struct {
|
||||
exec Executor
|
||||
helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8)
|
||||
serviceName string // Windows service name (defaults to theta-agent)
|
||||
tunnelName string // WireGuard tunnel/service name
|
||||
confPath string // persisted peer config path
|
||||
wgExe string // wireguard.exe client path ("" = PATH lookup)
|
||||
}
|
||||
|
||||
func (p *windowsPlatformOps) Reboot() ([]byte, error) {
|
||||
@@ -176,6 +180,111 @@ func (p *windowsPlatformOps) SelfRestart() {
|
||||
stopAgent()
|
||||
}
|
||||
|
||||
// wireGuardExe resolves the WireGuard client executable: explicit config path,
|
||||
// PATH lookup, or the default install location.
|
||||
func (p *windowsPlatformOps) wireGuardExe() string {
|
||||
if p.wgExe != "" {
|
||||
return p.wgExe
|
||||
}
|
||||
const defaultInstall = `C:\Program Files\WireGuard\wireguard.exe`
|
||||
if _, err := os.Stat(defaultInstall); err == nil {
|
||||
return defaultInstall
|
||||
}
|
||||
return "wireguard.exe"
|
||||
}
|
||||
|
||||
// ApplyWireGuard persists the peer config and installs it as a WireGuard
|
||||
// service via the official client (wireguard.exe /installtunnelservice).
|
||||
func (p *windowsPlatformOps) ApplyWireGuard(conf string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
|
||||
return fmt.Errorf("wireguard: create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
|
||||
return fmt.Errorf("wireguard: persist config: %w", err)
|
||||
}
|
||||
out, err := p.exec.Execute(p.wireGuardExe(), "/installtunnelservice", p.tunnelName, p.confPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: installtunnelservice %s: %v: %s", p.tunnelName, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *windowsPlatformOps) RemoveWireGuard() error {
|
||||
out, err := p.exec.Execute(p.wireGuardExe(), "/uninstalltunnelservice", p.tunnelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: uninstalltunnelservice %s: %v: %s", p.tunnelName, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WireGuardState reports whether the WireGuardTunnel$<name> service is running.
|
||||
func (p *windowsPlatformOps) WireGuardState() bool {
|
||||
out, err := p.exec.Execute("sc.exe", "query", "WireGuardTunnel$"+p.tunnelName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToUpper(string(out)), "RUNNING")
|
||||
}
|
||||
|
||||
// ConnectWireGuard brings the persisted config up unless already active.
|
||||
func (p *windowsPlatformOps) ConnectWireGuard() error {
|
||||
if p.WireGuardState() {
|
||||
return nil
|
||||
}
|
||||
conf, err := os.ReadFile(p.confPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
|
||||
}
|
||||
return p.ApplyWireGuard(string(conf))
|
||||
}
|
||||
|
||||
func (p *windowsPlatformOps) DisconnectWireGuard() error {
|
||||
if !p.WireGuardState() {
|
||||
return nil
|
||||
}
|
||||
return p.RemoveWireGuard()
|
||||
}
|
||||
|
||||
// ApplyIAM maps node identity onto local Windows security (DESIGN-WINDOWS.md
|
||||
// §4): local groups for allowed_login_groups, per-user authorized_keys for
|
||||
// OpenSSH, and session logoff via the helper for revocation.
|
||||
func (p *windowsPlatformOps) ApplyIAM(payload IAMPayload) error {
|
||||
ac := payload.AccessControl
|
||||
|
||||
for _, g := range ac.AllowedLoginGroups {
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := p.exec.Execute("net", "localgroup", g, "/add"); err != nil {
|
||||
log.Printf("[iam] net localgroup %s /add: %v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ac.SSHKeys) > 0 {
|
||||
if err := applyWindowsSSHKeys(ac.SSHKeys); err != nil {
|
||||
log.Printf("[iam] ssh keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range ac.RevokeUsers {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if p.helperPath != "" {
|
||||
if _, err := p.exec.Execute(p.helperPath, "logout", u); err != nil {
|
||||
log.Printf("[iam] revoke %s: %v", u, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[iam] revoke %s: desktop_helper not configured; no sessions logged off", u)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ac.SudoRules) > 0 {
|
||||
log.Println("[iam] sudo_rules have no direct Windows equivalent; mapped to local group membership (UAC elevation policy)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// spawnDetached launches exe as a background process that survives this one.
|
||||
func spawnDetached(exe string, args ...string) error {
|
||||
cmd := execCommand(exe, args...)
|
||||
|
||||
+35
-3
@@ -15,7 +15,9 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -95,19 +97,49 @@ func (ts *trayServer) handleCommand(cmd TrayCommand) {
|
||||
ts.mu.Lock()
|
||||
ts.status.AutoVPN = cmd.Value
|
||||
ts.mu.Unlock()
|
||||
SetAutoVPN(cmd.Value)
|
||||
if currentCM != nil {
|
||||
if err := currentCM.PersistAutoVPN(cmd.Value); err != nil {
|
||||
log.Printf("[tray-ipc] could not persist auto_vpn: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("[tray-ipc] auto_vpn set to %v", cmd.Value)
|
||||
// TODO: persist to agent.yml
|
||||
case "vpn_connect":
|
||||
log.Printf("[tray-ipc] VPN connect requested")
|
||||
// TODO: invoke WireGuard connect
|
||||
if err := defaultPlatformOps.ConnectWireGuard(); err != nil {
|
||||
log.Printf("[tray-ipc] VPN connect failed: %v", err)
|
||||
}
|
||||
case "vpn_disconnect":
|
||||
log.Printf("[tray-ipc] VPN disconnect requested")
|
||||
// TODO: invoke WireGuard disconnect
|
||||
if err := defaultPlatformOps.DisconnectWireGuard(); err != nil {
|
||||
log.Printf("[tray-ipc] VPN disconnect failed: %v", err)
|
||||
}
|
||||
case "reinit":
|
||||
log.Printf("[tray-ipc] clearing enrollment (re-enroll requested)")
|
||||
if currentCM != nil {
|
||||
if err := currentCM.ClearEnrollment(); err != nil {
|
||||
log.Printf("[tray-ipc] could not clear enrollment: %v", err)
|
||||
}
|
||||
}
|
||||
case "open_config":
|
||||
log.Printf("[tray-ipc] opening config %s", defaultConfigPath())
|
||||
openInDefaultViewer(defaultConfigPath())
|
||||
default:
|
||||
log.Printf("[tray-ipc] unknown command: %q", cmd.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// openInDefaultViewer opens a file/folder with the platform default handler.
|
||||
func openInDefaultViewer(path string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// explorer /select,<path> opens the parent folder with the file
|
||||
// selected. explorer is a GUI app, so no console window appears.
|
||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
||||
return
|
||||
}
|
||||
_ = exec.Command("xdg-open", path).Start()
|
||||
}
|
||||
|
||||
// Push broadcasts an updated status to all connected tray clients.
|
||||
func (ts *trayServer) Push(status TrayStatus) {
|
||||
ts.mu.Lock()
|
||||
|
||||
+42
-1
@@ -524,12 +524,53 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
|
||||
if err := applyIAM(payload, exec); err != nil {
|
||||
if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
|
||||
log.Printf("IAM apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "iam applied")
|
||||
case "wireguard_apply":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard apply rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
conf, _ := msg.Payload["config"].(string)
|
||||
if conf == "" {
|
||||
sendResponse("error", "missing wireguard config")
|
||||
return
|
||||
}
|
||||
log.Printf("Applying WireGuard peer config...")
|
||||
if err := defaultPlatformOps.ApplyWireGuard(conf); err != nil {
|
||||
log.Printf("WireGuard apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(true)
|
||||
sendResponse("ok", "wireguard applied")
|
||||
case "wireguard_remove":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard remove rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
log.Printf("Removing WireGuard tunnel...")
|
||||
if err := defaultPlatformOps.RemoveWireGuard(); err != nil {
|
||||
log.Printf("WireGuard remove failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard remove failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(false)
|
||||
sendResponse("ok", "wireguard removed")
|
||||
case "arbitrary_bash":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
|
||||
+49
-2
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{
|
||||
"config": "[Interface]\nAddress = 10.0.0.2/32\n",
|
||||
},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "up", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply denied",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: false},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{"config": "[Interface]\n"},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_remove allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{Type: "wireguard_remove"},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "down", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "heartbeat_ack is silently ignored",
|
||||
cfg: &Config{
|
||||
@@ -248,9 +290,14 @@ func TestHandleCommand(t *testing.T) {
|
||||
|
||||
// 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).
|
||||
// on any CI host (Windows included). A temp dir holds the WireGuard
|
||||
// config so wireguard_apply's persistence step is writable.
|
||||
prevOps := defaultPlatformOps
|
||||
defaultPlatformOps = &linuxPlatformOps{exec: mockExec}
|
||||
defaultPlatformOps = &linuxPlatformOps{
|
||||
exec: mockExec,
|
||||
tunnelName: "theta-mesh",
|
||||
confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"),
|
||||
}
|
||||
defer func() { defaultPlatformOps = prevOps }()
|
||||
|
||||
msg := tc.msg
|
||||
|
||||
Reference in New Issue
Block a user