diff --git a/cmd/theta-agent-tray/main.go b/cmd/theta-agent-tray/main.go index ff8ab34..db7fd45 100644 --- a/cmd/theta-agent-tray/main.go +++ b/cmd/theta-agent-tray/main.go @@ -21,8 +21,11 @@ package main import ( "bufio" + "bytes" "encoding/json" "fmt" + "image" + "image/png" "log" "net" "os" @@ -121,7 +124,7 @@ var ( func onReady() { // Initial icon — red until we hear from the daemon. - systray.SetIcon(iconRed) + systray.SetIcon(toWindowsIcon(iconRed)) systray.SetTitle("Theta Agent") systray.SetTooltip("Theta Agent — connecting…") @@ -216,19 +219,20 @@ func streamStatus(conn net.Conn) { func updateUI(s TrayStatus) { currentStatus = s - // Icon color. + // Icon color. fyne.io/systray needs .ico on Windows; the PNG icons are + // wrapped in an ICO container (Windows Vista+ supports PNG-in-ICO). + icon := iconRed switch s.Color { case ColorRed: - systray.SetIcon(iconRed) + icon = iconRed case ColorYellow: - systray.SetIcon(iconYellow) + icon = iconYellow case ColorGreen: - systray.SetIcon(iconGreen) + icon = iconGreen case ColorBlue: - systray.SetIcon(iconBlue) - default: - systray.SetIcon(iconRed) + icon = iconBlue } + systray.SetIcon(toWindowsIcon(icon)) // Tooltip. tooltip := s.StatusText @@ -273,3 +277,100 @@ func sendCmd(cmd TrayCommand) { log.Printf("theta-agent-tray: send command error: %v", err) } } + +// toWindowsIcon converts PNG bytes into a Windows .ico for fyne.io/systray, +// which requires .ico content on Windows (LoadImage cannot read PNG-in-ICO). +// On non-Windows the PNG is returned untouched. +func toWindowsIcon(pngBytes []byte) []byte { + if runtime.GOOS != "windows" { + return pngBytes + } + return pngToIco(pngBytes) +} + +// pngToIco decodes a PNG and re-encodes it as classic BMP (XOR + AND mask) +// entries at 16/32/48px — the format LoadImage has always supported. +func pngToIco(pngBytes []byte) []byte { + src, err := png.Decode(bytes.NewReader(pngBytes)) + if err != nil { + return pngBytes // give systray the raw bytes; it will log and continue + } + + sizes := []int{16, 32, 48} + var dir []byte + var payload []byte + offset := 6 + len(sizes)*16 // ICONDIR + all ICONDIRENTRYs + + // ICONDIR: reserved(2)=0 type(2)=1 count(2) + dir = append(dir, 0, 0, 1, 0, byte(len(sizes)), 0) + + for _, s := range sizes { + bmp := rgbaToDIB(scaleNearest(src, s, s)) + dw, dh := byte(s), byte(s) + if s >= 256 { + dw, dh = 0, 0 + } + entry := []byte{dw, dh, 0, 0, 1, 0, 32, 0} + entry = append(entry, putU32le(len(bmp))...) + entry = append(entry, putU32le(offset+len(payload))...) + dir = append(dir, entry...) + payload = append(payload, bmp...) + } + return append(dir, payload...) +} + +// scaleNearest resizes src to w x h with nearest-neighbour sampling. +func scaleNearest(src image.Image, w, h int) image.Image { + b := src.Bounds() + if b.Dx() == w && b.Dy() == h { + return src + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + sy := b.Min.Y + y*b.Dy()/h + for x := 0; x < w; x++ { + sx := b.Min.X + x*b.Dx()/w + dst.Set(x, y, src.At(sx, sy)) + } + } + return dst +} + +// rgbaToDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent +// AND mask — the classic icon bitmap LoadImage understands. +func rgbaToDIB(img image.Image) []byte { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + + hdr := make([]byte, 40) + copy(hdr, putU32le(40)) // biSize + copy(hdr[4:], putU32le(w)) // biWidth + copy(hdr[8:], putU32le(h*2)) // biHeight (XOR + AND) + copy(hdr[12:], putU16le(1)) // biPlanes + copy(hdr[14:], putU16le(32)) // biBitCount + andRow := ((w + 31) / 32) * 4 // AND mask row, padded to 32 bits + copy(hdr[20:], putU32le(w*h*4+andRow*h)) // biSizeImage + + xor := make([]byte, w*h*4) + for y := 0; y < h; y++ { + srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up + for x := 0; x < w; x++ { + r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA() + o := y*w*4 + x*4 + xor[o+0] = byte(bl >> 8) // B + xor[o+1] = byte(g >> 8) // G + xor[o+2] = byte(r >> 8) // R + xor[o+3] = byte(a >> 8) // A + } + } + and := make([]byte, andRow*h) // all zeros: no transparency holes + return append(append(hdr, xor...), and...) +} + +func putU32le(v int) []byte { + return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)} +} + +func putU16le(v int) []byte { + return []byte{byte(v), byte(v >> 8)} +} diff --git a/dist/theta-agent-tray-linux-amd64 b/dist/theta-agent-tray-linux-amd64 index f1fb139..1026144 100755 Binary files a/dist/theta-agent-tray-linux-amd64 and b/dist/theta-agent-tray-linux-amd64 differ diff --git a/dist/theta-agent-tray-linux-arm64 b/dist/theta-agent-tray-linux-arm64 index f1fb139..1026144 100755 Binary files a/dist/theta-agent-tray-linux-arm64 and b/dist/theta-agent-tray-linux-arm64 differ diff --git a/dist/theta-agent-tray-windows-amd64.exe b/dist/theta-agent-tray-windows-amd64.exe index f1fb139..1026144 100755 Binary files a/dist/theta-agent-tray-windows-amd64.exe and b/dist/theta-agent-tray-windows-amd64.exe differ diff --git a/dist/theta-agent-windows-arm64.exe b/dist/theta-agent-windows-arm64.exe index 8ff5471..7fcf5dc 100755 Binary files a/dist/theta-agent-windows-arm64.exe and b/dist/theta-agent-windows-arm64.exe differ diff --git a/installer/windows/installer.iss b/installer/windows/installer.iss index 73a88a4..4105876 100644 --- a/installer/windows/installer.iss +++ b/installer/windows/installer.iss @@ -1,4 +1,4 @@ -; Theta Agent — Windows installer (Inno Setup 6.4+) +; 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 @@ -9,8 +9,11 @@ ; 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). +; Interactively, a wizard page asks for the SSO Manager URL and a join key (with +; a button that opens the SSO's Directory -> Install Agent page to mint one). +; In silent mode, /SERVER_URL, /JOIN_KEY, /AUTH_TOKEN, /PUBLIC_KEY and +; /B64_CONFIG (base64 of a full agent.yml) drive the same result. The values are +; written into agent.yml so the installed service enrolls on first start. #ifndef MyAppVersion #define MyAppVersion "2.1.0" @@ -58,7 +61,7 @@ Source: "{#AgentDir}\theta-agent-windows-amd64.exe"; DestDir: "{app}"; Flags: ig 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. Installs offline (the +; WireGuard for Windows ??? official, vendor-signed MSI. Installs offline (the ; driver is signed; no signature phone-home). Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion @@ -67,6 +70,11 @@ Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion +[Icons] +Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Comment: "Theta Agent status tray" +Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file" +Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}" + [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 @@ -74,17 +82,30 @@ Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: [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 -; OpenCredential credential provider — must be registered before logon. +; OpenCredential credential provider ??? must be registered before logon. Filename: "{app}\vendor\OpenCredentialInstaller-1.0.0.0.exe"; Parameters: "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; StatusMsg: "Installing OpenCredential credential provider..."; 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 +; The WireGuard client launches its UI at the end of the MSI; close it ??? the +; tunnel is managed by the agent (wireguard.exe /installtunnelservice). +Filename: "taskkill.exe"; Parameters: "/f /im wireguard.exe"; Flags: runhidden ; Register the agent as a SYSTEM auto-start service. Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated +; Show the tray right away instead of waiting for the next logon. +Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Description: "Start Theta Agent tray"; StatusMsg: "Starting Theta Agent tray..."; Flags: nowait postinstall skipifsilent [Code] var ServerURL: String; JoinKey: String; + AuthToken: String; + PublicKey: String; + B64Config: String; + + AgentConfigPage: TWizardPage; + ServerURLEdit: TNewEdit; + JoinKeyEdit: TNewEdit; + OpenSSOButton: TNewButton; // Reads a custom setup command-line parameter (e.g. /SERVER_URL=https://...). // {param:...} raises when the parameter is absent, so the exception becomes "". @@ -101,21 +122,145 @@ function InitializeSetup(): Boolean; begin ServerURL := GetCmdParam('SERVER_URL'); JoinKey := GetCmdParam('JOIN_KEY'); + AuthToken := GetCmdParam('AUTH_TOKEN'); + PublicKey := GetCmdParam('PUBLIC_KEY'); + B64Config := GetCmdParam('B64_CONFIG'); Result := True; end; +// Opens the SSO's Directory page so the operator can mint a join key right from +// the wizard. Uses the Server URL they just typed. +procedure OnOpenSSOClick(Sender: TObject); +var + Url: String; + ErrorCode: Integer; +begin + Url := Trim(ServerURLEdit.Text); + if Url = '' then begin + MsgBox('Enter the SSO Manager URL first (e.g. https://sso.example.com).', + mbInformation, MB_OK); + Exit; + end; + if not ShellExec('open', Url, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then + MsgBox('Could not open the browser: ' + SysErrorMessage(ErrorCode), mbError, MB_OK); +end; + +procedure CreateAgentConfigPage(); +var + InfoLabel: TNewStaticText; + UrlLabel: TNewStaticText; + KeyLabel: TNewStaticText; +begin + AgentConfigPage := CreateCustomPage(wpWelcome, + 'SSO Manager connection', + 'Tell the agent which SSO to enroll with.'); + + InfoLabel := TNewStaticText.Create(AgentConfigPage); + InfoLabel.Parent := AgentConfigPage.Surface; + InfoLabel.WordWrap := True; + InfoLabel.Caption := 'Paste the SSO Manager URL for this deployment. Then either paste a join key ' + + '(mint one with the button below, under Directory -> Install Agent) or leave it blank to ' + + 'enroll from the tray / CLI later.'; + InfoLabel.AutoSize := True; + InfoLabel.Width := AgentConfigPage.Surface.Width; + + UrlLabel := TNewStaticText.Create(AgentConfigPage); + UrlLabel.Parent := AgentConfigPage.Surface; + UrlLabel.Caption := 'SSO Manager URL:'; + UrlLabel.Top := InfoLabel.Top + InfoLabel.Height + 16; + + ServerURLEdit := TNewEdit.Create(AgentConfigPage); + ServerURLEdit.Parent := AgentConfigPage.Surface; + ServerURLEdit.Top := UrlLabel.Top + UrlLabel.Height + 4; + ServerURLEdit.Width := AgentConfigPage.Surface.Width; + ServerURLEdit.Text := ServerURL; + + OpenSSOButton := TNewButton.Create(AgentConfigPage); + OpenSSOButton.Parent := AgentConfigPage.Surface; + OpenSSOButton.Top := ServerURLEdit.Top + ServerURLEdit.Height + 8; + OpenSSOButton.Left := ServerURLEdit.Left; + OpenSSOButton.Caption := 'Open SSO install-agent page...'; + OpenSSOButton.Width := 190; + OpenSSOButton.OnClick := @OnOpenSSOClick; + + KeyLabel := TNewStaticText.Create(AgentConfigPage); + KeyLabel.Parent := AgentConfigPage.Surface; + KeyLabel.Caption := 'Join key (optional):'; + KeyLabel.Top := OpenSSOButton.Top + OpenSSOButton.Height + 12; + + JoinKeyEdit := TNewEdit.Create(AgentConfigPage); + JoinKeyEdit.Parent := AgentConfigPage.Surface; + JoinKeyEdit.Top := KeyLabel.Top + KeyLabel.Height + 4; + JoinKeyEdit.Width := AgentConfigPage.Surface.Width; + JoinKeyEdit.Text := JoinKey; +end; + +procedure InitializeWizard(); +begin + CreateAgentConfigPage(); +end; + +// Pull the values the operator typed into the wizard so WriteAgentConfig can use +// them; silent installs keep the command-line params. +procedure CurPageChanged(CurPageID: Integer); +begin + if CurPageID = AgentConfigPage.ID then begin + ServerURL := Trim(ServerURLEdit.Text); + JoinKey := Trim(JoinKeyEdit.Text); + end; +end; + +// Minimal base64 decoder returning a plain String (agent.yml is ASCII). +function B64Decode(const S: String): String; +var + i, v, p: Integer; + buf: array[0..3] of Integer; + outStr: String; +begin + outStr := ''; + v := 0; + for i := 1 to Length(S) do begin + if S[i] = '=' then begin + buf[v] := 0; + Inc(v); + end else begin + p := Pos(S[i], 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'); + buf[v] := p - 1; + Inc(v); + end; + if v = 4 then begin + outStr := outStr + Chr((buf[0] shl 2) or (buf[1] shr 4)); + outStr := outStr + Chr(((buf[1] and $F) shl 4) or (buf[2] shr 2)); + outStr := outStr + Chr(((buf[2] and 3) shl 6) or buf[3]); + v := 0; + end; + end; + Result := outStr; +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; + Decoded: String; begin - SetArrayLength(Lines, 13); + // /B64_CONFIG= overrides everything (the SSO's Custom Config + // wizard emits it). + if B64Config <> '' then begin + Decoded := B64Decode(B64Config); + SetArrayLength(Lines, 1); + Lines[0] := Decoded; + SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False); + Exit; + end; + + SetArrayLength(Lines, 17); Lines[0] := '# theta-agent configuration (written by installer)'; Lines[1] := 'server_url: "' + ServerURL + '"'; - Lines[2] := 'auth_token: ""'; + Lines[2] := 'auth_token: "' + AuthToken + '"'; Lines[3] := 'join_key: "' + JoinKey + '"'; - Lines[4] := 'public_key: ""'; + Lines[4] := 'public_key: "' + PublicKey + '"'; Lines[5] := 'auto_vpn: false'; Lines[6] := 'service_name: "theta-agent"'; Lines[7] := 'desktop_helper: "' + ExpandConstant('{app}') + '\theta-agent-helper-windows-amd64.exe"'; @@ -124,7 +269,11 @@ begin Lines[10] := ' telemetry: true'; Lines[11] := ' ldap_tunnel: true'; Lines[12] := ' wireguard: true'; - SaveStringsToUTF8File(ConfigPath, Lines, False); + Lines[13] := ' secrets: false'; + Lines[14] := ' iam: false'; + Lines[15] := ' reboot: false'; + Lines[16] := ' arbitrary_bash: false'; + SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False); end; procedure CurStepChanged(CurStep: TSetupStep);