From b442e1ed767b4aafaafeef11862d1be027f4e3b4 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Tue, 14 Jul 2026 20:40:05 +0200 Subject: [PATCH] Add device agent library Provide enrollment, elevated install, posture checks, keystore, and system-tray helpers shared by the probo-agent binary. Signed-off-by: Ludovic Vielle --- pkg/deviceagent/agent.go | 70 +++-- pkg/deviceagent/checks/runcmd.go | 5 + pkg/deviceagent/checks/runcmd_paths_darwin.go | 1 + .../checks/runcmd_paths_windows.go | 46 +-- pkg/deviceagent/client.go | 75 +++-- pkg/deviceagent/client_test.go | 77 ++++++ pkg/deviceagent/config_paths_other.go | 6 + pkg/deviceagent/config_paths_windows.go | 11 + pkg/deviceagent/elevate/elevate.go | 46 +++ pkg/deviceagent/elevate/elevate_darwin.go | 72 +++++ pkg/deviceagent/elevate/elevate_windows.go | 70 +++++ pkg/deviceagent/elevate/install_stub.go | 29 ++ pkg/deviceagent/elevate/install_supported.go | 34 +++ pkg/deviceagent/elevate/powershell_windows.go | 29 ++ pkg/deviceagent/enroll.go | 117 ++++++++ pkg/deviceagent/enroll_test.go | 257 +++++++++++++++++ pkg/deviceagent/enroll_url.go | 62 +++++ pkg/deviceagent/enroll_url_test.go | 98 +++++++ pkg/deviceagent/enrollment_state.go | 112 ++++++++ pkg/deviceagent/enrollment_state_test.go | 108 ++++++++ pkg/deviceagent/keystore.go | 10 +- pkg/deviceagent/server_url.go | 79 ++++++ pkg/deviceagent/server_url_test.go | 68 +++++ pkg/deviceagent/service/launchd.plist.tmpl | 28 ++ pkg/deviceagent/tray/autostart_darwin.go | 261 ++++++++++++++++++ pkg/deviceagent/tray/autostart_darwin_test.go | 87 ++++++ pkg/deviceagent/tray/autostart_windows.go | 94 +++++++ pkg/deviceagent/tray/console_user_windows.go | 213 ++++++++++++++ .../tray/console_user_windows_test.go | 128 +++++++++ pkg/deviceagent/tray/dialog_darwin.go | 41 +++ pkg/deviceagent/tray/dialog_windows.go | 35 +++ pkg/deviceagent/tray/icon.png | Bin 0 -> 125 bytes pkg/deviceagent/tray/iconTemplate.png | Bin 0 -> 117 bytes pkg/deviceagent/tray/icons.go | 31 +++ pkg/deviceagent/tray/icons_darwin.go | 29 ++ pkg/deviceagent/tray/icons_windows.go | 29 ++ pkg/deviceagent/tray/launchagent.plist.tmpl | 20 ++ pkg/deviceagent/tray/messagebox_windows.go | 58 ++++ pkg/deviceagent/tray/osascript_darwin.go | 34 +++ pkg/deviceagent/tray/tray.go | 28 ++ pkg/deviceagent/tray/tray_nocgo.go | 29 ++ pkg/deviceagent/tray/tray_stub.go | 37 +++ pkg/deviceagent/tray/tray_supported.go | 151 ++++++++++ pkg/deviceagent/tray/wts_session_windows.go | 87 ++++++ 44 files changed, 2814 insertions(+), 88 deletions(-) create mode 100644 pkg/deviceagent/client_test.go create mode 100644 pkg/deviceagent/elevate/elevate.go create mode 100644 pkg/deviceagent/elevate/elevate_darwin.go create mode 100644 pkg/deviceagent/elevate/elevate_windows.go create mode 100644 pkg/deviceagent/elevate/install_stub.go create mode 100644 pkg/deviceagent/elevate/install_supported.go create mode 100644 pkg/deviceagent/elevate/powershell_windows.go create mode 100644 pkg/deviceagent/enroll.go create mode 100644 pkg/deviceagent/enroll_test.go create mode 100644 pkg/deviceagent/enroll_url.go create mode 100644 pkg/deviceagent/enroll_url_test.go create mode 100644 pkg/deviceagent/enrollment_state.go create mode 100644 pkg/deviceagent/enrollment_state_test.go create mode 100644 pkg/deviceagent/server_url.go create mode 100644 pkg/deviceagent/server_url_test.go create mode 100644 pkg/deviceagent/service/launchd.plist.tmpl create mode 100644 pkg/deviceagent/tray/autostart_darwin.go create mode 100644 pkg/deviceagent/tray/autostart_darwin_test.go create mode 100644 pkg/deviceagent/tray/autostart_windows.go create mode 100644 pkg/deviceagent/tray/console_user_windows.go create mode 100644 pkg/deviceagent/tray/console_user_windows_test.go create mode 100644 pkg/deviceagent/tray/dialog_darwin.go create mode 100644 pkg/deviceagent/tray/dialog_windows.go create mode 100644 pkg/deviceagent/tray/icon.png create mode 100644 pkg/deviceagent/tray/iconTemplate.png create mode 100644 pkg/deviceagent/tray/icons.go create mode 100644 pkg/deviceagent/tray/icons_darwin.go create mode 100644 pkg/deviceagent/tray/icons_windows.go create mode 100644 pkg/deviceagent/tray/launchagent.plist.tmpl create mode 100644 pkg/deviceagent/tray/messagebox_windows.go create mode 100644 pkg/deviceagent/tray/osascript_darwin.go create mode 100644 pkg/deviceagent/tray/tray.go create mode 100644 pkg/deviceagent/tray/tray_nocgo.go create mode 100644 pkg/deviceagent/tray/tray_stub.go create mode 100644 pkg/deviceagent/tray/tray_supported.go create mode 100644 pkg/deviceagent/tray/wts_session_windows.go diff --git a/pkg/deviceagent/agent.go b/pkg/deviceagent/agent.go index bd0105f41..443afb239 100644 --- a/pkg/deviceagent/agent.go +++ b/pkg/deviceagent/agent.go @@ -94,36 +94,33 @@ func New(dir, version string, logger *log.Logger) *Agent { } } -// EnrollNewDevice enrolls and persists local config and key state. -func (a *Agent) EnrollNewDevice( +// ConfigureDevice persists local credentials and performs the first +// heartbeat that activates the device on the server. +func (a *Agent) ConfigureDevice( ctx context.Context, - serverURL, enrollmentToken string, -) (*EnrollResponse, error) { + serverURL, apiKey string, +) (*HeartbeatResponse, error) { if serverURL == "" { return nil, errors.New("server URL is required") } - if enrollmentToken == "" { - return nil, errors.New("enrollment token is required") + if apiKey == "" { + return nil, errors.New("api key is required") + } + + if err := SaveAPIKey(a.Dir, apiKey); err != nil { + return nil, fmt.Errorf("cannot save api key: %w", err) } host := a.currentHostInfo(time.Now()) - client := NewClient(serverURL, "", a.UserAgent) + client := NewClient(serverURL, apiKey, a.UserAgent) - resp, err := client.Enroll( + resp, err := client.Heartbeat( ctx, - EnrollRequest{ - EnrollmentToken: enrollmentToken, - HardwareUUID: host.HardwareUUID, - SerialNumber: host.SerialNumber, - Hostname: host.Hostname, - Platform: host.Platform, - OSVersion: host.OSVersion, - AgentVersion: a.Version, - }, + a.heartbeatRequest(host), ) if err != nil { - return nil, fmt.Errorf("cannot enroll device: %w", err) + return nil, fmt.Errorf("cannot activate device: %w", err) } cfg := &Config{ @@ -136,16 +133,16 @@ func (a *Agent) EnrollNewDevice( return nil, fmt.Errorf("cannot save config: %w", err) } - if err := SaveAPIKey(a.Dir, resp.APIKey); err != nil { - return nil, fmt.Errorf("cannot save api key: %w", err) + if err := MarkEnrolled(EnrollmentRunDir(a.Dir)); err != nil { + return nil, fmt.Errorf("cannot mark device enrolled: %w", err) } if err := clearPendingPostureBatches(a.Dir); err != nil { - a.Logger.Warn("cannot clear pending posture queue after enrollment", log.Error(err)) + a.Logger.Warn("cannot clear pending posture queue after configuration", log.Error(err)) } a.cfg = cfg - a.client = NewClient(serverURL, resp.APIKey, a.UserAgent) + a.client = NewClient(serverURL, apiKey, a.UserAgent) return resp, nil } @@ -182,6 +179,10 @@ func (a *Agent) Run(ctx context.Context) error { } } + if err := MarkEnrolled(EnrollmentRunDir(a.Dir)); err != nil { + return fmt.Errorf("cannot sync enrollment marker: %w", err) + } + a.Logger = a.Logger.With(log.String("device_id", a.cfg.DeviceID)) a.Logger.InfoCtx( @@ -354,6 +355,10 @@ func (a *Agent) Unenroll(ctx context.Context) error { return err } + if err := ClearEnrollmentMarker(EnrollmentRunDir(a.Dir)); err != nil { + return err + } + return nil } @@ -369,11 +374,7 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) { resp, err := a.client.Heartbeat( ctx, - HeartbeatRequest{ - AgentVersion: a.Version, - Hostname: host.Hostname, - OSVersion: host.OSVersion, - }, + a.heartbeatRequest(host), ) if err != nil { a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err)) @@ -643,5 +644,20 @@ func (a *Agent) handleUnauthorized() { a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err)) } + if err := ClearEnrollmentMarker(EnrollmentRunDir(a.Dir)); err != nil { + a.Logger.Error("cannot clear enrollment marker after 401", log.Error(err)) + } + a.resetPendingFlushRetry() } + +func (a *Agent) heartbeatRequest(host HostInfo) HeartbeatRequest { + return HeartbeatRequest{ + HardwareUUID: host.HardwareUUID, + SerialNumber: host.SerialNumber, + Hostname: host.Hostname, + Platform: host.Platform, + OSVersion: host.OSVersion, + AgentVersion: a.Version, + } +} diff --git a/pkg/deviceagent/checks/runcmd.go b/pkg/deviceagent/checks/runcmd.go index 567a11350..424d2ea9c 100644 --- a/pkg/deviceagent/checks/runcmd.go +++ b/pkg/deviceagent/checks/runcmd.go @@ -83,6 +83,11 @@ func CommandExists(cmd string) bool { return exists } +// CommandCandidates returns absolute paths for known commands on the current platform. +func CommandCandidates(cmd string) []string { + return commandCandidates(cmd) +} + func resolveCommandPath(cmd string) (string, bool) { if filepath.IsAbs(cmd) { return cmd, isExecutableFile(cmd) diff --git a/pkg/deviceagent/checks/runcmd_paths_darwin.go b/pkg/deviceagent/checks/runcmd_paths_darwin.go index 7d93345f7..0fec4cdeb 100644 --- a/pkg/deviceagent/checks/runcmd_paths_darwin.go +++ b/pkg/deviceagent/checks/runcmd_paths_darwin.go @@ -23,6 +23,7 @@ package checks var darwinCommandPaths = map[string][]string{ "defaults": {"/usr/bin/defaults"}, "fdesetup": {"/usr/bin/fdesetup"}, + "osascript": {"/usr/bin/osascript"}, "pwpolicy": {"/usr/bin/pwpolicy"}, "softwareupdate": {"/usr/sbin/softwareupdate"}, "stat": {"/usr/bin/stat"}, diff --git a/pkg/deviceagent/checks/runcmd_paths_windows.go b/pkg/deviceagent/checks/runcmd_paths_windows.go index 966d66f95..2021a430a 100644 --- a/pkg/deviceagent/checks/runcmd_paths_windows.go +++ b/pkg/deviceagent/checks/runcmd_paths_windows.go @@ -21,35 +21,41 @@ package checks import ( + "fmt" "os" - "path/filepath" "strings" ) +var windowsCommandPaths = map[string][]string{ + "manage-bde": {`%s\System32\manage-bde.exe`}, + "net": {`%s\System32\net.exe`}, + "netsh": {`%s\System32\netsh.exe`}, + "powershell": {`%s\System32\WindowsPowerShell\v1.0\powershell.exe`}, + "sc": {`%s\System32\sc.exe`}, + "w32tm": {`%s\System32\w32tm.exe`}, +} + func commandCandidates(cmd string) []string { + templates := windowsCommandPaths[normalizeWindowsCommand(cmd)] + if len(templates) == 0 { + return nil + } + systemRoot := os.Getenv("SystemRoot") if systemRoot == "" { systemRoot = `C:\Windows` } - system32 := filepath.Join(systemRoot, "System32") - - switch strings.ToLower(cmd) { - case "powershell", "powershell.exe": - return []string{ - filepath.Join(system32, "WindowsPowerShell", "v1.0", "powershell.exe"), - } - case "manage-bde", "manage-bde.exe": - return []string{filepath.Join(system32, "manage-bde.exe")} - case "netsh", "netsh.exe": - return []string{filepath.Join(system32, "netsh.exe")} - case "w32tm", "w32tm.exe": - return []string{filepath.Join(system32, "w32tm.exe")} - case "sc", "sc.exe": - return []string{filepath.Join(system32, "sc.exe")} - case "net", "net.exe": - return []string{filepath.Join(system32, "net.exe")} - default: - return nil + paths := make([]string, len(templates)) + for i, template := range templates { + paths[i] = fmt.Sprintf(template, systemRoot) } + + return paths +} + +func normalizeWindowsCommand(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + + return strings.TrimSuffix(name, ".exe") } diff --git a/pkg/deviceagent/client.go b/pkg/deviceagent/client.go index 179a7df5d..c259a9752 100644 --- a/pkg/deviceagent/client.go +++ b/pkg/deviceagent/client.go @@ -58,32 +58,17 @@ func NewClient(serverURL, apiKey, userAgent string) *Client { } type ( - EnrollRequest struct { - EnrollmentToken string `json:"enrollment_token"` - HardwareUUID string `json:"hardware_uuid"` - SerialNumber *string `json:"serial_number,omitempty"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - OSVersion string `json:"os_version"` - AgentVersion string `json:"agent_version"` - } - - EnrollResponse struct { - DeviceID string `json:"device_id"` - APIKey string `json:"api_key"` - HeartbeatSeconds int `json:"heartbeat_interval_seconds"` - PostureSeconds int `json:"posture_interval_seconds"` - ServerTime string `json:"server_time"` - } - HeartbeatRequest struct { - AgentVersion string `json:"agent_version,omitempty"` - Hostname string `json:"hostname,omitempty"` - OSVersion string `json:"os_version,omitempty"` - UptimeSec int64 `json:"uptime_seconds,omitempty"` + HardwareUUID string `json:"hardware_uuid"` + SerialNumber *string `json:"serial_number,omitempty"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + AgentVersion string `json:"agent_version"` } HeartbeatResponse struct { + DeviceID string `json:"device_id"` HeartbeatSeconds int `json:"heartbeat_interval_seconds"` PostureSeconds int `json:"posture_interval_seconds"` ServerTime string `json:"server_time"` @@ -101,23 +86,6 @@ type ( } ) -// Enroll exchanges an enrollment token for a device key. -func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) { - var resp EnrollResponse - if err := c.do( - ctx, - http.MethodPost, - "/api/agent/v1/enroll", - false, - req, - &resp, - ); err != nil { - return nil, err - } - - return &resp, nil -} - // Heartbeat sends a periodic device heartbeat. func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) { var resp HeartbeatResponse @@ -163,6 +131,35 @@ func (c *Client) Unenroll(ctx context.Context) error { ) } +type enrollResponse struct { + APIKey string `json:"api_key"` +} + +// ExchangeEnrollmentToken redeems a one-shot enrollment token for the +// permanent device API key. +func (c *Client) ExchangeEnrollmentToken( + ctx context.Context, + token string, +) (string, error) { + var resp enrollResponse + if err := c.do( + ctx, + http.MethodPost, + "/api/agent/v1/enroll", + false, + map[string]string{"token": token}, + &resp, + ); err != nil { + return "", err + } + + if resp.APIKey == "" { + return "", errors.New("agent api: empty api key in enroll response") + } + + return resp.APIKey, nil +} + // HTTPError captures a non-2xx API response. type HTTPError struct { StatusCode int diff --git a/pkg/deviceagent/client_test.go b/pkg/deviceagent/client_test.go new file mode 100644 index 000000000..a3dd2a11b --- /dev/null +++ b/pkg/deviceagent/client_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientExchangeEnrollmentToken(t *testing.T) { + t.Parallel() + + const wantAPIKey = "device-api-key-secret" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/agent/v1/enroll", r.URL.Path) + assert.Empty(t, r.Header.Get("Authorization")) + + var body struct { + Token string `json:"token"` + } + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "enroll-token", body.Token) + + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(map[string]string{ + "api_key": wantAPIKey, + })) + })) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "", "probo-agent/test") + + apiKey, err := client.ExchangeEnrollmentToken(context.Background(), "enroll-token") + require.NoError(t, err) + assert.Equal(t, wantAPIKey, apiKey) +} + +func TestClientExchangeEnrollmentTokenUnauthorized(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + client := NewClient(srv.URL, "", "probo-agent/test") + + _, err := client.ExchangeEnrollmentToken(context.Background(), "used-token") + require.Error(t, err) + assert.True(t, IsUnauthorized(err)) +} diff --git a/pkg/deviceagent/config_paths_other.go b/pkg/deviceagent/config_paths_other.go index 38ff98be6..8874508c3 100644 --- a/pkg/deviceagent/config_paths_other.go +++ b/pkg/deviceagent/config_paths_other.go @@ -27,3 +27,9 @@ package deviceagent func DefaultConfigDir() string { return "/var/lib/probo-agent" } + +// DefaultEnrollmentRunDir returns the runtime directory for the public +// enrollment marker on non-Windows hosts. +func DefaultEnrollmentRunDir() string { + return "/var/run/probo-agent" +} diff --git a/pkg/deviceagent/config_paths_windows.go b/pkg/deviceagent/config_paths_windows.go index 6698f35f5..9677adbcd 100644 --- a/pkg/deviceagent/config_paths_windows.go +++ b/pkg/deviceagent/config_paths_windows.go @@ -35,3 +35,14 @@ func DefaultConfigDir() string { return filepath.Join(programData, "Probo", "agent") } + +// DefaultEnrollmentRunDir returns the runtime directory for the public +// enrollment marker on Windows. +func DefaultEnrollmentRunDir() string { + programData := os.Getenv("ProgramData") + if programData == "" { + programData = `C:\ProgramData` + } + + return filepath.Join(programData, "Probo", "run") +} diff --git a/pkg/deviceagent/elevate/elevate.go b/pkg/deviceagent/elevate/elevate.go new file mode 100644 index 000000000..09eb3b9a4 --- /dev/null +++ b/pkg/deviceagent/elevate/elevate.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package elevate + +import ( + "fmt" + "strings" +) + +// InstallOptions configures an elevated probo-agent install invocation. +type InstallOptions struct { + ExePath string + ServerURL string + ConfigDir string // agent config / keystore dir (--dir) +} + +func commandError(out []byte, err error) error { + if err == nil { + return nil + } + + msg := strings.TrimSpace(string(out)) + if msg == "" { + return err + } + + return fmt.Errorf("%s: %w", msg, err) +} diff --git a/pkg/deviceagent/elevate/elevate_darwin.go b/pkg/deviceagent/elevate/elevate_darwin.go new file mode 100644 index 000000000..78c821b74 --- /dev/null +++ b/pkg/deviceagent/elevate/elevate_darwin.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package elevate + +import ( + "fmt" + "os/exec" + "strings" + + "go.probo.inc/probo/pkg/deviceagent/checks" +) + +func runElevatedInstall(opts InstallOptions, enrollmentToken string) error { + parts := []string{ + shellQuote(opts.ExePath), + "install", + "--server", + shellQuote(opts.ServerURL), + "--enrollment-token", + shellQuote(enrollmentToken), + } + if opts.ConfigDir != "" { + parts = append(parts, "--dir", shellQuote(opts.ConfigDir)) + } + + shellCmd := strings.Join(parts, " ") + + script := fmt.Sprintf( + `do shell script %s with administrator privileges`, + applescriptQuote(shellCmd), + ) + + candidates := checks.CommandCandidates("osascript") + if len(candidates) == 0 { + return fmt.Errorf("command %q not available at expected absolute path", "osascript") + } + + out, err := exec.Command(candidates[0], "-e", script).CombinedOutput() + + return commandError(out, err) +} + +func shellQuote(v string) string { + return "'" + strings.ReplaceAll(v, "'", `'"'"'`) + "'" +} + +func applescriptQuote(v string) string { + v = strings.ReplaceAll(v, `\`, `\\`) + v = strings.ReplaceAll(v, `"`, `\"`) + + return `"` + v + `"` +} diff --git a/pkg/deviceagent/elevate/elevate_windows.go b/pkg/deviceagent/elevate/elevate_windows.go new file mode 100644 index 000000000..ba61d81e1 --- /dev/null +++ b/pkg/deviceagent/elevate/elevate_windows.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package elevate + +import ( + "fmt" + "os/exec" + "strings" + + "go.probo.inc/probo/pkg/deviceagent/checks" +) + +func runElevatedInstall(opts InstallOptions, enrollmentToken string) error { + args := []string{ + "install", + "--server", + opts.ServerURL, + "--enrollment-token", + enrollmentToken, + } + if opts.ConfigDir != "" { + args = append(args, "--dir", opts.ConfigDir) + } + + argList := make([]string, len(args)) + for i, arg := range args { + argList[i] = "'" + escapePowerShellSingleQuoted(arg) + "'" + } + + script := fmt.Sprintf( + `$p = Start-Process -FilePath %s -ArgumentList @(%s) -Verb RunAs -Wait -PassThru; if ($p.ExitCode -ne 0) { exit $p.ExitCode }`, + "'"+escapePowerShellSingleQuoted(opts.ExePath)+"'", + strings.Join(argList, ","), + ) + + candidates := checks.CommandCandidates("powershell.exe") + if len(candidates) == 0 { + return fmt.Errorf("command %q not available at expected absolute path", "powershell.exe") + } + + out, err := exec.Command( + candidates[0], + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ).CombinedOutput() + + return commandError(out, err) +} diff --git a/pkg/deviceagent/elevate/install_stub.go b/pkg/deviceagent/elevate/install_stub.go new file mode 100644 index 000000000..c91456a06 --- /dev/null +++ b/pkg/deviceagent/elevate/install_stub.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !darwin && !windows + +package elevate + +import "errors" + +func RunElevatedInstall(_ string, _ string, _ string, _ string) error { + return errors.New("elevated enrollment install is only supported on macOS and Windows") +} diff --git a/pkg/deviceagent/elevate/install_supported.go b/pkg/deviceagent/elevate/install_supported.go new file mode 100644 index 000000000..b9aab449f --- /dev/null +++ b/pkg/deviceagent/elevate/install_supported.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin || windows + +package elevate + +func RunElevatedInstall(exePath string, serverURL string, enrollmentToken string, configDir string) error { + return runElevatedInstall( + InstallOptions{ + ExePath: exePath, + ServerURL: serverURL, + ConfigDir: configDir, + }, + enrollmentToken, + ) +} diff --git a/pkg/deviceagent/elevate/powershell_windows.go b/pkg/deviceagent/elevate/powershell_windows.go new file mode 100644 index 000000000..4df7a8c01 --- /dev/null +++ b/pkg/deviceagent/elevate/powershell_windows.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package elevate + +import "strings" + +func escapePowerShellSingleQuoted(v string) string { + return strings.ReplaceAll(v, "'", "''") +} diff --git a/pkg/deviceagent/enroll.go b/pkg/deviceagent/enroll.go new file mode 100644 index 000000000..0a315dd40 --- /dev/null +++ b/pkg/deviceagent/enroll.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "context" + "errors" + "fmt" + "os" +) + +// ErrServerURLMismatch is returned when install is re-run with a --server +// that differs from the persisted config. +var ErrServerURLMismatch = errors.New("server URL does not match persisted config; uninstall first") + +// LoadOrExchangeAPIKey returns a persisted API key for install. +// If agent.key already exists, it is returned without contacting the server +// only when serverURL matches the server_url persisted in config.json. +// Otherwise the enrollment token is exchanged, the key and server binding +// are saved to disk immediately, and then the key is returned. +func LoadOrExchangeAPIKey( + ctx context.Context, + dir string, + client *Client, + serverURL string, + enrollmentToken string, +) (string, error) { + normalized, err := NormalizeServerURL(serverURL) + if err != nil { + return "", fmt.Errorf("invalid server URL: %w", err) + } + + apiKey, err := LoadAPIKey(dir) + if err == nil { + if err := validatePersistedServerURL(dir, normalized); err != nil { + return "", err + } + + return apiKey, nil + } + + if !errors.Is(err, ErrKeyNotFound) { + return "", fmt.Errorf("cannot load device api key: %w", err) + } + + apiKey, err = client.ExchangeEnrollmentToken(ctx, enrollmentToken) + if err != nil { + return "", fmt.Errorf("cannot exchange enrollment token: %w", err) + } + + if err := SaveAPIKey(dir, apiKey); err != nil { + return "", fmt.Errorf("cannot save device api key: %w", err) + } + + if err := SaveConfig(dir, &Config{ServerURL: normalized}); err != nil { + if delErr := DeleteAPIKey(dir); delErr != nil { + return "", fmt.Errorf( + "cannot save device config: %w (also cannot delete device api key: %v)", + err, + delErr, + ) + } + + return "", fmt.Errorf("cannot save device config: %w", err) + } + + return apiKey, nil +} + +func validatePersistedServerURL(dir, serverURL string) error { + normalized, err := NormalizeServerURL(serverURL) + if err != nil { + return fmt.Errorf("invalid server URL: %w", err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrServerURLMismatch + } + + return fmt.Errorf("cannot load device config: %w", err) + } + + if cfg.ServerURL == "" { + return ErrServerURLMismatch + } + + persisted, err := NormalizeServerURL(cfg.ServerURL) + if err != nil { + return fmt.Errorf("invalid persisted server URL: %w", err) + } + + if persisted != normalized { + return ErrServerURLMismatch + } + + return nil +} diff --git a/pkg/deviceagent/enroll_test.go b/pkg/deviceagent/enroll_test.go new file mode 100644 index 000000000..b5b14d150 --- /dev/null +++ b/pkg/deviceagent/enroll_test.go @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadOrExchangeAPIKey(t *testing.T) { + t.Parallel() + + const ( + persistedKey = "persisted-device-key" + serverURL = "https://us.probo.com" + ) + + t.Run( + "reuses key when persisted server URL matches", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveAPIKey(dir, persistedKey)) + require.NoError(t, SaveConfig(dir, &Config{ServerURL: serverURL})) + + client := NewClient("https://wrong.example.com", "", "probo-agent/test") + + apiKey, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + serverURL, + "unused-token", + ) + require.NoError(t, err) + assert.Equal(t, persistedKey, apiKey) + }, + ) + + t.Run( + "rejects key reuse when persisted server URL differs", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveAPIKey(dir, persistedKey)) + require.NoError(t, SaveConfig(dir, &Config{ServerURL: serverURL})) + + client := NewClient(serverURL, "", "probo-agent/test") + + _, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + "https://eu.probo.com", + "unused-token", + ) + require.ErrorIs(t, err, ErrServerURLMismatch) + }, + ) + + t.Run( + "reuses key when server URL differs only by hostname case", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveAPIKey(dir, persistedKey)) + require.NoError(t, SaveConfig(dir, &Config{ServerURL: serverURL})) + + client := NewClient("https://wrong.example.com", "", "probo-agent/test") + + apiKey, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + "https://US.probo.com", + "unused-token", + ) + require.NoError(t, err) + assert.Equal(t, persistedKey, apiKey) + }, + ) + + t.Run( + "rejects key reuse when config is missing", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveAPIKey(dir, persistedKey)) + + client := NewClient(serverURL, "", "probo-agent/test") + + _, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + serverURL, + "unused-token", + ) + require.ErrorIs(t, err, ErrServerURLMismatch) + }, + ) + + t.Run( + "rejects key reuse when config is missing and server differs", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveAPIKey(dir, persistedKey)) + + client := NewClient(serverURL, "", "probo-agent/test") + + _, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + "https://eu.probo.com", + "unused-token", + ) + require.ErrorIs(t, err, ErrServerURLMismatch) + }, + ) + + t.Run( + "exchanges token when key is missing", + func(t *testing.T) { + t.Parallel() + + const exchangedKey = "exchanged-device-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/agent/v1/enroll", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"api_key":"` + exchangedKey + `"}`)) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + client := NewClient(srv.URL, "", "probo-agent/test") + + apiKey, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + srv.URL, + "enroll-token", + ) + require.NoError(t, err) + assert.Equal(t, exchangedKey, apiKey) + + loaded, err := LoadAPIKey(dir) + require.NoError(t, err) + assert.Equal(t, exchangedKey, loaded) + + cfg, err := LoadConfig(dir) + require.NoError(t, err) + assert.Equal(t, srv.URL, cfg.ServerURL) + }, + ) + + t.Run( + "rejects invalid server URL before exchange", + func(t *testing.T) { + t.Parallel() + + var hits atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + client := NewClient(srv.URL, "", "probo-agent/test") + + _, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + "ftp://example.com", + "enroll-token", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid server URL") + assert.Equal(t, int32(0), hits.Load()) + + _, err = LoadAPIKey(dir) + require.ErrorIs(t, err, ErrKeyNotFound) + }, + ) + + t.Run( + "rolls back key when config save fails", + func(t *testing.T) { + t.Parallel() + + const exchangedKey = "exchanged-device-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"api_key":"` + exchangedKey + `"}`)) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, ConfigFileName), 0o700)) + + client := NewClient(srv.URL, "", "probo-agent/test") + + _, err := LoadOrExchangeAPIKey( + context.Background(), + dir, + client, + srv.URL, + "enroll-token", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot save device config") + + _, err = LoadAPIKey(dir) + require.ErrorIs(t, err, ErrKeyNotFound) + }, + ) +} diff --git a/pkg/deviceagent/enroll_url.go b/pkg/deviceagent/enroll_url.go new file mode 100644 index 000000000..6ed3ba9f7 --- /dev/null +++ b/pkg/deviceagent/enroll_url.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +func ParseEnrollURL(raw string) (serverURL string, enrollmentToken string, err error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", "", errors.New("enrollment URL is required") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", "", fmt.Errorf("cannot parse enrollment URL: %w", err) + } + + if parsed.Scheme != "probo" { + return "", "", errors.New("enrollment URL must use probo scheme") + } + + if parsed.Host != "enroll" { + return "", "", errors.New("enrollment URL must be probo://enroll") + } + + query := parsed.Query() + + serverURL, err = NormalizeServerURL(query.Get("server")) + if err != nil { + return "", "", fmt.Errorf("invalid server in enrollment URL: %w", err) + } + + enrollmentToken = strings.TrimSpace(query.Get("token")) + if enrollmentToken == "" { + return "", "", errors.New("enrollment token is missing") + } + + return serverURL, enrollmentToken, nil +} diff --git a/pkg/deviceagent/enroll_url_test.go b/pkg/deviceagent/enroll_url_test.go new file mode 100644 index 000000000..490f0f85f --- /dev/null +++ b/pkg/deviceagent/enroll_url_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseEnrollURL(t *testing.T) { + t.Parallel() + + t.Run( + "parses host enroll URL", + func(t *testing.T) { + t.Parallel() + + serverURL, enrollmentToken, err := ParseEnrollURL( + "probo://enroll?server=https%3A%2F%2Fus.probo.com&token=secret-token", + ) + require.NoError(t, err) + assert.Equal(t, "https://us.probo.com", serverURL) + assert.Equal(t, "secret-token", enrollmentToken) + }, + ) + + t.Run( + "rejects non-enroll host", + func(t *testing.T) { + t.Parallel() + + _, _, err := ParseEnrollURL( + "probo:///enroll?server=https%3A%2F%2Feu.probo.com&token=abc123", + ) + require.Error(t, err) + assert.ErrorContains(t, err, "enrollment URL must be probo://enroll") + }, + ) + + t.Run( + "rejects non probo scheme", + func(t *testing.T) { + t.Parallel() + + _, _, err := ParseEnrollURL( + "https://example.com/enroll?server=https%3A%2F%2Fus.probo.com&token=secret-token", + ) + require.Error(t, err) + assert.ErrorContains(t, err, "probo scheme") + }, + ) + + t.Run( + "rejects missing token", + func(t *testing.T) { + t.Parallel() + + _, _, err := ParseEnrollURL( + "probo://enroll?server=https%3A%2F%2Fus.probo.com", + ) + require.Error(t, err) + assert.ErrorContains(t, err, "enrollment token is missing") + }, + ) + + t.Run( + "rejects invalid server URL", + func(t *testing.T) { + t.Parallel() + + _, _, err := ParseEnrollURL( + "probo://enroll?server=https%3A%2F%2Fus.probo.com%2Fextra&token=abc123", + ) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid server") + }, + ) +} diff --git a/pkg/deviceagent/enrollment_state.go b/pkg/deviceagent/enrollment_state.go new file mode 100644 index 000000000..00118e98b --- /dev/null +++ b/pkg/deviceagent/enrollment_state.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + // EnrollmentRunDirMode is searchable by the user-session tray helper so + // IsEnrolled can stat the marker without access to the secrets directory. + EnrollmentRunDirMode = 0o755 + + // EnrollmentMarkerMode is owner-only; tray detection uses os.Stat via + // the searchable run directory, not read access to the marker contents. + EnrollmentMarkerMode = 0o600 + + // EnrollmentMarkerName is the runtime enrollment flag filename. + EnrollmentMarkerName = "enrolled" +) + +// EnrollmentRunDir returns the runtime directory for the public enrollment +// marker. Production installs use DefaultEnrollmentRunDir(); custom --dir +// values get an isolated sibling run tree for dev and tests. +func EnrollmentRunDir(configDir string) string { + if configDir == "" { + configDir = DefaultConfigDir() + } + + if configDir == DefaultConfigDir() { + return DefaultEnrollmentRunDir() + } + + return filepath.Join(filepath.Dir(configDir), "run", filepath.Base(configDir)) +} + +func enrollmentMarkerPath(runDir string) string { + if runDir == "" { + runDir = DefaultEnrollmentRunDir() + } + + return filepath.Join(runDir, EnrollmentMarkerName) +} + +// IsEnrolled reports whether the enrollment marker exists in runDir. +func IsEnrolled(runDir string) (bool, error) { + _, err := os.Stat(enrollmentMarkerPath(runDir)) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, fmt.Errorf("cannot stat enrollment marker: %w", err) +} + +// MarkEnrolled writes the enrollment marker under runDir. +func MarkEnrolled(runDir string) error { + if runDir == "" { + runDir = DefaultEnrollmentRunDir() + } + + if err := os.MkdirAll(runDir, EnrollmentRunDirMode); err != nil { + return fmt.Errorf("cannot create enrollment run dir: %w", err) + } + + if err := os.Chmod(runDir, EnrollmentRunDirMode); err != nil { + return fmt.Errorf("cannot set enrollment run dir permissions: %w", err) + } + + path := enrollmentMarkerPath(runDir) + if err := os.WriteFile(path, []byte("ok\n"), EnrollmentMarkerMode); err != nil { + return fmt.Errorf("cannot write enrollment marker: %w", err) + } + + if err := os.Chmod(path, EnrollmentMarkerMode); err != nil { + return fmt.Errorf("cannot set enrollment marker permissions: %w", err) + } + + return nil +} + +// ClearEnrollmentMarker removes the public enrollment marker from runDir. +func ClearEnrollmentMarker(runDir string) error { + if err := os.Remove(enrollmentMarkerPath(runDir)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove enrollment marker: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/enrollment_state_test.go b/pkg/deviceagent/enrollment_state_test.go new file mode 100644 index 000000000..53e7571e8 --- /dev/null +++ b/pkg/deviceagent/enrollment_state_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnrollmentMarker(t *testing.T) { + t.Parallel() + + t.Run( + "marker tracks enrollment state", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + runDir := EnrollmentRunDir(dir) + + enrolled, err := IsEnrolled(runDir) + require.NoError(t, err) + assert.False(t, enrolled) + + require.NoError(t, MarkEnrolled(runDir)) + enrolled, err = IsEnrolled(runDir) + require.NoError(t, err) + assert.True(t, enrolled) + + markerPath := enrollmentMarkerPath(runDir) + assert.NotEqual(t, filepath.Join(dir, EnrollmentMarkerName), markerPath) + + info, err := os.Stat(markerPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(EnrollmentMarkerMode), info.Mode().Perm()) + + info, err = os.Stat(runDir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(EnrollmentRunDirMode), info.Mode().Perm()) + + require.NoError(t, ClearEnrollmentMarker(runDir)) + enrolled, err = IsEnrolled(runDir) + require.NoError(t, err) + assert.False(t, enrolled) + }, + ) + + t.Run( + "clear is idempotent", + func(t *testing.T) { + t.Parallel() + + runDir := EnrollmentRunDir(t.TempDir()) + + require.NoError(t, ClearEnrollmentMarker(runDir)) + enrolled, err := IsEnrolled(runDir) + require.NoError(t, err) + assert.False(t, enrolled) + }, + ) + + t.Run( + "default config dir resolves run dir", + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, DefaultEnrollmentRunDir(), EnrollmentRunDir("")) + }, + ) + + t.Run( + "non-ENOENT stat errors are not treated as unenrolled", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + runDir := EnrollmentRunDir(dir) + require.NoError(t, os.MkdirAll(filepath.Dir(runDir), 0o755)) + require.NoError(t, os.WriteFile(runDir, []byte("x"), 0o644)) + + enrolled, err := IsEnrolled(runDir) + assert.False(t, enrolled) + require.Error(t, err) + }, + ) +} diff --git a/pkg/deviceagent/keystore.go b/pkg/deviceagent/keystore.go index de4144198..c09efbf8e 100644 --- a/pkg/deviceagent/keystore.go +++ b/pkg/deviceagent/keystore.go @@ -31,7 +31,8 @@ import ( // KeyFileName stores the device API key on disk. const KeyFileName = "agent.key" -// ErrKeyNotFound is returned when no key file exists. +// ErrKeyNotFound is returned when no usable key is on disk (missing, +// empty, or whitespace-only file). var ErrKeyNotFound = errors.New("agent key not found") // KeyPath returns the absolute path of the device API key file. @@ -72,7 +73,12 @@ func LoadAPIKey(dir string) (string, error) { return "", fmt.Errorf("cannot read agent key: %w", err) } - return strings.TrimSpace(string(data)), nil + key := strings.TrimSpace(string(data)) + if key == "" { + return "", ErrKeyNotFound + } + + return key, nil } // DeleteAPIKey removes the API key file. diff --git a/pkg/deviceagent/server_url.go b/pkg/deviceagent/server_url.go new file mode 100644 index 000000000..0ae3f06eb --- /dev/null +++ b/pkg/deviceagent/server_url.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +const ( + USConsoleHost = "us.probo.com" + EUConsoleHost = "eu.probo.com" +) + +const ( + USConsoleURL = "https://" + USConsoleHost + EUConsoleURL = "https://" + EUConsoleHost +) + +const DefaultServerURL = USConsoleURL + +func NormalizeServerURL(host string) (string, error) { + raw := strings.TrimSpace(host) + if raw == "" { + return "", errors.New("server URL is required") + } + + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("cannot parse server URL: %w", err) + } + + scheme := strings.ToLower(parsed.Scheme) + if scheme != "https" && scheme != "http" { + return "", fmt.Errorf("unsupported server URL scheme %q", parsed.Scheme) + } + + if parsed.Hostname() == "" { + return "", errors.New("server URL must include a hostname") + } + + if parsed.User != nil { + return "", errors.New("server URL must not include user credentials") + } + + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("server URL must not include a path") + } + + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("server URL must not include query parameters or fragments") + } + + return (&url.URL{Scheme: scheme, Host: strings.ToLower(parsed.Host)}).String(), nil +} diff --git a/pkg/deviceagent/server_url_test.go b/pkg/deviceagent/server_url_test.go new file mode 100644 index 000000000..d4593e33f --- /dev/null +++ b/pkg/deviceagent/server_url_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package deviceagent + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeServerURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "adds https to bare hostnames", input: "eu.probo.com", want: EUConsoleURL}, + {name: "trims trailing slash", input: "https://us.probo.com/", want: USConsoleURL}, + {name: "rejects whitespace-only input", input: " ", wantErr: true}, + {name: "rejects paths", input: "https://probo.example.com/workspace", wantErr: true}, + {name: "accepts uppercase scheme", input: "HTTPS://eu.probo.com/", want: EUConsoleURL}, + {name: "lowercases mixed-case hostname", input: "HTTPS://US.Probo.Com/", want: USConsoleURL}, + {name: "lowercases bare mixed-case hostname", input: "EU.probo.com", want: EUConsoleURL}, + {name: "lowercases hostname with port", input: "http://LocalHost:3000", want: "http://localhost:3000"}, + {name: "rejects user credentials", input: "https://user@eu.probo.com", wantErr: true}, + {name: "rejects query parameters", input: "https://eu.probo.com?foo=bar", wantErr: true}, + {name: "rejects fragments", input: "https://eu.probo.com#fragment", wantErr: true}, + {name: "rejects port-only host", input: "https://:443", wantErr: true}, + {name: "rejects bare port", input: ":443", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := NormalizeServerURL(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/deviceagent/service/launchd.plist.tmpl b/pkg/deviceagent/service/launchd.plist.tmpl new file mode 100644 index 000000000..f2b5449d2 --- /dev/null +++ b/pkg/deviceagent/service/launchd.plist.tmpl @@ -0,0 +1,28 @@ + + + + + Label + {{xml .Label}} + ProgramArguments + + {{xml .ExePath}} + run + --dir + {{xml .Dir}} + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/probo-agent.log + StandardErrorPath + /var/log/probo-agent.log + UserName + root + GroupName + wheel + + diff --git a/pkg/deviceagent/tray/autostart_darwin.go b/pkg/deviceagent/tray/autostart_darwin.go new file mode 100644 index 000000000..13e67d38f --- /dev/null +++ b/pkg/deviceagent/tray/autostart_darwin.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package tray + +import ( + _ "embed" + "encoding/xml" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "text/template" +) + +const ( + trayLabel = "com.probo.agent.tray" + trayPlistPath = "/Library/LaunchAgents/com.probo.agent.tray.plist" +) + +var ( + //go:embed launchagent.plist.tmpl + launchAgentPlistTmpl string + + launchAgentPlist = template.Must( + template.New("launchagent").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchAgentPlistTmpl), + ) +) + +func xmlEscape(v string) (string, error) { + var sb strings.Builder + if err := xml.EscapeText(&sb, []byte(v)); err != nil { + return "", err + } + + return sb.String(), nil +} + +type launchAgentData struct { + Label string + ExePath string + RunDir string +} + +func RegisterAutoStart(exePath string, runDir string) error { + if exePath == "" { + return fmt.Errorf("executable path is required") + } + + if runDir == "" { + return fmt.Errorf("enrollment run directory is required") + } + + if err := writeTrayLaunchAgentPlist(exePath, runDir); err != nil { + return err + } + + uids := activeGUIUserUIDs() + if len(uids) == 0 { + return nil + } + + return bootstrapTrayForUIDs(uids) +} + +func writeTrayLaunchAgentPlist(exePath string, runDir string) error { + agentsDir := filepath.Dir(trayPlistPath) + if err := os.MkdirAll(agentsDir, 0o755); err != nil { + return fmt.Errorf("cannot ensure launch agents directory: %w", err) + } + + f, err := os.OpenFile(trayPlistPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("cannot write plist (need root?): %w", err) + } + + defer func() { _ = f.Close() }() + + if err := launchAgentPlist.Execute( + f, + launchAgentData{ + Label: trayLabel, + ExePath: exePath, + RunDir: runDir, + }, + ); err != nil { + return fmt.Errorf("cannot render LaunchAgent plist: %w", err) + } + + return nil +} + +// UnregisterAutoStart stops the tray LaunchAgent and removes its plist. +func UnregisterAutoStart() error { + bootoutTrayForUIDs(activeGUIUserUIDs()) + + if err := os.Remove(trayPlistPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot remove plist: %w", err) + } + + return nil +} + +func bootstrapTrayForUIDs(uids []int) error { + for _, uid := range uids { + target := fmt.Sprintf("gui/%d/%s", uid, trayLabel) + + _ = exec.Command("launchctl", "bootout", target).Run() + + if out, err := exec.Command( + "launchctl", + "bootstrap", + fmt.Sprintf("gui/%d", uid), + trayPlistPath, + ).CombinedOutput(); err != nil { + return fmt.Errorf( + "cannot run launchctl bootstrap for uid %d: %w: %s", + uid, + err, + strings.TrimSpace(string(out)), + ) + } + } + + return nil +} + +func bootoutTrayForUIDs(uids []int) { + for _, uid := range uids { + target := fmt.Sprintf("gui/%d/%s", uid, trayLabel) + _ = exec.Command("launchctl", "bootout", target).Run() + } +} + +func activeGUIUserUIDs() []int { + names := loggedInUsernames() + if len(names) == 0 { + if uid, err := currentGUIUserUID(); err == nil { + return []int{uid} + } + + return nil + } + + seen := make(map[int]struct{}, len(names)) + uids := make([]int, 0, len(names)) + + for _, name := range names { + uid, err := uidForUsername(name) + if err != nil { + continue + } + + if _, ok := seen[uid]; ok { + continue + } + + seen[uid] = struct{}{} + uids = append(uids, uid) + } + + if len(uids) == 0 { + if uid, err := currentGUIUserUID(); err == nil { + return []int{uid} + } + } + + return uids +} + +func loggedInUsernames() []string { + out, err := exec.Command("users").Output() + if err != nil { + return nil + } + + return parseLoggedInUsernames(string(out)) +} + +func parseLoggedInUsernames(usersOutput string) []string { + tokens := strings.Fields(usersOutput) + + seen := make(map[string]struct{}, len(tokens)) + names := make([]string, 0, len(tokens)) + + for _, name := range tokens { + name = strings.TrimSpace(name) + if name == "" || name == "root" || name == "loginwindow" { + continue + } + + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + return names +} + +func currentGUIUserUID() (int, error) { + name := strings.TrimSpace(consoleUserName()) + if name == "" || name == "root" || name == "loginwindow" { + name = strings.TrimSpace(os.Getenv("USER")) + } + + if name == "" || name == "root" || name == "loginwindow" { + return 0, fmt.Errorf("cannot resolve active GUI user") + } + + return uidForUsername(name) +} + +func uidForUsername(name string) (int, error) { + uidOut, err := exec.Command("id", "-u", name).Output() + if err != nil { + return 0, fmt.Errorf("cannot resolve uid for %s: %w", name, err) + } + + uidRaw := strings.TrimSpace(string(uidOut)) + + uid, err := strconv.Atoi(uidRaw) + if err != nil { + return 0, fmt.Errorf("invalid uid %q", uidRaw) + } + + return uid, nil +} + +func consoleUserName() string { + out, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(out)) +} diff --git a/pkg/deviceagent/tray/autostart_darwin_test.go b/pkg/deviceagent/tray/autostart_darwin_test.go new file mode 100644 index 000000000..0d0e3cbc1 --- /dev/null +++ b/pkg/deviceagent/tray/autostart_darwin_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package tray + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseLoggedInUsernames(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + want []string + }{ + { + name: "empty output", + output: "", + want: []string{}, + }, + { + name: "single user", + output: "alice\n", + want: []string{"alice"}, + }, + { + name: "multiple users", + output: "alice bob\n", + want: []string{"alice", "bob"}, + }, + { + name: "duplicate users", + output: "alice alice bob\n", + want: []string{"alice", "bob"}, + }, + { + name: "skips root and loginwindow", + output: "root loginwindow alice\n", + want: []string{"alice"}, + }, + { + name: "whitespace only", + output: " \n", + want: []string{}, + }, + { + name: "extra whitespace between names", + output: " alice bob \n", + want: []string{"alice", "bob"}, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + got := parseLoggedInUsernames(tt.output) + assert.Equal(t, tt.want, got) + }, + ) + } +} diff --git a/pkg/deviceagent/tray/autostart_windows.go b/pkg/deviceagent/tray/autostart_windows.go new file mode 100644 index 000000000..e62fa4582 --- /dev/null +++ b/pkg/deviceagent/tray/autostart_windows.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "errors" + "fmt" + + "golang.org/x/sys/windows/registry" +) + +const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + +const runValueName = "ProboAgentTray" + +func RegisterAutoStart(exePath string, runDir string) error { + if exePath == "" { + return fmt.Errorf("executable path is required") + } + if runDir == "" { + return fmt.Errorf("enrollment run directory is required") + } + + sid, err := currentInteractiveUserSID() + if err != nil { + return fmt.Errorf("cannot resolve interactive user for tray auto-start: %w", err) + } + + keyPath := sid + `\` + runKeyPath + + key, _, err := registry.CreateKey(registry.USERS, keyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("cannot open or create Run registry key for interactive user: %w", err) + } + + defer func() { _ = key.Close() }() + + command := fmt.Sprintf(`"%s" tray --run-dir "%s"`, exePath, runDir) + if err := key.SetStringValue(runValueName, command); err != nil { + return fmt.Errorf("cannot set Run registry value: %w", err) + } + + return nil +} + +func UnregisterAutoStart() error { + sid, err := currentInteractiveUserSID() + if err != nil { + return fmt.Errorf("cannot resolve interactive user for tray auto-start: %w", err) + } + + keyPath := sid + `\` + runKeyPath + + key, err := registry.OpenKey(registry.USERS, keyPath, registry.SET_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + + return fmt.Errorf("cannot open Run registry key for interactive user: %w", err) + } + + defer func() { _ = key.Close() }() + + if err := key.DeleteValue(runValueName); err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + + return fmt.Errorf("cannot delete Run registry value: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/tray/console_user_windows.go b/pkg/deviceagent/tray/console_user_windows.go new file mode 100644 index 000000000..63c871757 --- /dev/null +++ b/pkg/deviceagent/tray/console_user_windows.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "fmt" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const invalidSessionID = 0xFFFFFFFF + +func currentInteractiveUserSID() (string, error) { + var lastErr error + + for _, sessionID := range interactiveSessionCandidates() { + sid, err := sidFromSessionID(sessionID) + if err == nil { + return sid, nil + } + + lastErr = err + } + + if lastErr != nil { + return "", fmt.Errorf("no interactive user session available: %w", lastErr) + } + + return "", fmt.Errorf("no interactive user session available") +} + +func interactiveSessionCandidates() []uint32 { + candidates := make([]uint32, 0, 4) + + var sessionID uint32 + + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &sessionID); err == nil && sessionID != 0 { + candidates = appendUniqueSessionID(candidates, sessionID) + } + + consoleSessionID := windows.WTSGetActiveConsoleSessionId() + if consoleSessionID != invalidSessionID { + candidates = appendUniqueSessionID(candidates, consoleSessionID) + } + + for _, id := range activeWTSSessionIDs() { + candidates = appendUniqueSessionID(candidates, id) + } + + return candidates +} + +func activeWTSSessionIDs() []uint32 { + var ( + sessions *windows.WTS_SESSION_INFO + count uint32 + ) + + if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil { + return nil + } + + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions))) + + sessionSlice := unsafe.Slice(sessions, count) + + ids := make([]uint32, 0, len(sessionSlice)) + for _, session := range sessionSlice { + if session.SessionID == 0 { + continue + } + + if session.State != windows.WTSActive && session.State != windows.WTSConnected { + continue + } + + ids = append(ids, session.SessionID) + } + + return ids +} + +func appendUniqueSessionID(ids []uint32, sessionID uint32) []uint32 { + for _, id := range ids { + if id == sessionID { + return ids + } + } + + return append(ids, sessionID) +} + +func sidFromSessionID(sessionID uint32) (string, error) { + sid, err := sidFromSessionInformation(sessionID) + if err == nil { + return sid, nil + } + + if isCurrentProcessLocalSystem() { + if sid, tokenErr := sidFromSessionUserToken(sessionID); tokenErr == nil { + return sid, nil + } + } + + return "", err +} + +func sidFromSessionInformation(sessionID uint32) (string, error) { + user, domain, err := sessionUserAndDomain(sessionID) + if err != nil { + return "", err + } + + account := user + system := "" + if domain != "" { + account = domain + `\` + user + } + + sid, _, _, err := windows.LookupSID(system, account) + if err != nil { + return "", fmt.Errorf("cannot lookup session %d user SID: %w", sessionID, err) + } + + sidStr := sid.String() + if sidStr == "" { + return "", fmt.Errorf("session %d user SID is empty", sessionID) + } + + if !isInteractiveUserSID(sidStr) { + return "", fmt.Errorf("session %d has no interactive user", sessionID) + } + + return sidStr, nil +} + +func sidFromSessionUserToken(sessionID uint32) (string, error) { + var token windows.Token + + if err := windows.WTSQueryUserToken(sessionID, &token); err != nil { + return "", fmt.Errorf("cannot query session %d user token: %w", sessionID, err) + } + + defer func() { _ = token.Close() }() + + tu, err := token.GetTokenUser() + if err != nil { + return "", fmt.Errorf("cannot read session %d user: %w", sessionID, err) + } + + if tu.User.Sid == nil { + return "", fmt.Errorf("session %d user SID is empty", sessionID) + } + + sid := tu.User.Sid.String() + if sid == "" { + return "", fmt.Errorf("session %d user SID is empty", sessionID) + } + + if !isInteractiveUserSID(sid) { + return "", fmt.Errorf("session %d has no interactive user", sessionID) + } + + return sid, nil +} + +func isCurrentProcessLocalSystem() bool { + token, err := windows.OpenCurrentProcessToken() + if err != nil { + return false + } + + defer func() { _ = token.Close() }() + + tu, err := token.GetTokenUser() + if err != nil { + return false + } + + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return false + } + + return tu.User.Sid.Equals(systemSID) +} + +func isInteractiveUserSID(sid string) bool { + return strings.HasPrefix(sid, "S-1-5-21-") || + strings.HasPrefix(sid, "S-1-12-1-") +} diff --git a/pkg/deviceagent/tray/console_user_windows_test.go b/pkg/deviceagent/tray/console_user_windows_test.go new file mode 100644 index 000000000..49c070fdb --- /dev/null +++ b/pkg/deviceagent/tray/console_user_windows_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsInteractiveUserSID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sid string + want bool + }{ + { + name: "domain user sid", + sid: "S-1-5-21-1234567890-123456789-123456789-1001", + want: true, + }, + { + name: "entra user sid", + sid: "S-1-12-1-3603547745-1252762009-756918658-301435180", + want: true, + }, + { + name: "entra authority without user subauthority", + sid: "S-1-12-2-3603547745-1252762009-756918658-301435180", + want: false, + }, + { + name: "bare entra authority prefix", + sid: "S-1-12-", + want: false, + }, + { + name: "local system sid", + sid: "S-1-5-18", + want: false, + }, + { + name: "builtin administrators sid", + sid: "S-1-5-32-544", + want: false, + }, + { + name: "empty sid", + sid: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, isInteractiveUserSID(tt.sid)) + }, + ) + } +} + +func TestAppendUniqueSessionID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ids []uint32 + sessionID uint32 + want []uint32 + }{ + { + name: "append to empty slice", + ids: nil, + sessionID: 2, + want: []uint32{2}, + }, + { + name: "append new session", + ids: []uint32{1}, + sessionID: 2, + want: []uint32{1, 2}, + }, + { + name: "skip duplicate session", + ids: []uint32{1, 2}, + sessionID: 2, + want: []uint32{1, 2}, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + t.Parallel() + + got := appendUniqueSessionID(tt.ids, tt.sessionID) + assert.Equal(t, tt.want, got) + }, + ) + } +} diff --git a/pkg/deviceagent/tray/dialog_darwin.go b/pkg/deviceagent/tray/dialog_darwin.go new file mode 100644 index 000000000..1b4ef3179 --- /dev/null +++ b/pkg/deviceagent/tray/dialog_darwin.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package tray + +import ( + "fmt" + "os/exec" +) + +func showAbout(version string) { + path, ok := osascriptPath() + if !ok { + return + } + + script := fmt.Sprintf( + `display alert "Probo Device Posture Agent" message %q buttons {"OK"} default button "OK"`, + fmt.Sprintf("Version %s\n\nReports device posture to your Probo workspace.", version), + ) + _ = exec.Command(path, "-e", script).Run() +} diff --git a/pkg/deviceagent/tray/dialog_windows.go b/pkg/deviceagent/tray/dialog_windows.go new file mode 100644 index 000000000..56ad1410d --- /dev/null +++ b/pkg/deviceagent/tray/dialog_windows.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "fmt" +) + +func showAbout(version string) { + message := fmt.Sprintf( + "Version %s\r\n\r\nReports device posture to your Probo workspace.", + version, + ) + nativeMessageBox("Probo Device Posture Agent", message, mbOK|mbIconInformation) +} diff --git a/pkg/deviceagent/tray/icon.png b/pkg/deviceagent/tray/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3de3217f2dee4659751c8e639afd562e910dadb1 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fE}kxqAr-fh6C}zWMD96OpEKhX zTTlw)#vt} P<}i4=`njxgN@xNA3*aOY literal 0 HcmV?d00001 diff --git a/pkg/deviceagent/tray/icons.go b/pkg/deviceagent/tray/icons.go new file mode 100644 index 000000000..bb2b96a2f --- /dev/null +++ b/pkg/deviceagent/tray/icons.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package tray + +import _ "embed" + +// Icons are embedded PNGs; systray does not accept SVG. + +//go:embed icon.png +var iconData []byte + +//go:embed iconTemplate.png +var iconTemplateData []byte diff --git a/pkg/deviceagent/tray/icons_darwin.go b/pkg/deviceagent/tray/icons_darwin.go new file mode 100644 index 000000000..7fe34a8b5 --- /dev/null +++ b/pkg/deviceagent/tray/icons_darwin.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin && cgo + +package tray + +import "fyne.io/systray" + +func setTrayIcons() { + systray.SetTemplateIcon(iconTemplateData, iconData) +} diff --git a/pkg/deviceagent/tray/icons_windows.go b/pkg/deviceagent/tray/icons_windows.go new file mode 100644 index 000000000..7f6ede084 --- /dev/null +++ b/pkg/deviceagent/tray/icons_windows.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import "fyne.io/systray" + +func setTrayIcons() { + systray.SetIcon(iconData) +} diff --git a/pkg/deviceagent/tray/launchagent.plist.tmpl b/pkg/deviceagent/tray/launchagent.plist.tmpl new file mode 100644 index 000000000..17f290f35 --- /dev/null +++ b/pkg/deviceagent/tray/launchagent.plist.tmpl @@ -0,0 +1,20 @@ + + + + + Label + {{xml .Label}} + ProgramArguments + + {{xml .ExePath}} + tray + --run-dir + {{xml .RunDir}} + + RunAtLoad + + KeepAlive + + + diff --git a/pkg/deviceagent/tray/messagebox_windows.go b/pkg/deviceagent/tray/messagebox_windows.go new file mode 100644 index 000000000..8ef49e10b --- /dev/null +++ b/pkg/deviceagent/tray/messagebox_windows.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + mbOK = 0x00000000 + mbIconInformation = 0x00000040 +) + +var ( + modUser32 = windows.NewLazySystemDLL("user32.dll") + procMessageBoxW = modUser32.NewProc("MessageBoxW") +) + +func nativeMessageBox(title, message string, flags uint32) { + titleUTF16, err := windows.UTF16PtrFromString(title) + if err != nil { + return + } + + messageUTF16, err := windows.UTF16PtrFromString(message) + if err != nil { + return + } + + _, _, _ = procMessageBoxW.Call( + 0, + uintptr(unsafe.Pointer(messageUTF16)), + uintptr(unsafe.Pointer(titleUTF16)), + uintptr(flags), + ) +} diff --git a/pkg/deviceagent/tray/osascript_darwin.go b/pkg/deviceagent/tray/osascript_darwin.go new file mode 100644 index 000000000..f8086bdaf --- /dev/null +++ b/pkg/deviceagent/tray/osascript_darwin.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package tray + +import "go.probo.inc/probo/pkg/deviceagent/checks" + +func osascriptPath() (string, bool) { + candidates := checks.CommandCandidates("osascript") + if len(candidates) == 0 { + return "", false + } + + return candidates[0], true +} diff --git a/pkg/deviceagent/tray/tray.go b/pkg/deviceagent/tray/tray.go new file mode 100644 index 000000000..9f7c3de8f --- /dev/null +++ b/pkg/deviceagent/tray/tray.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package tray + +type Options struct { + RunDir string + ExePath string + ServerURL string + Version string +} diff --git a/pkg/deviceagent/tray/tray_nocgo.go b/pkg/deviceagent/tray/tray_nocgo.go new file mode 100644 index 000000000..7cc186ac1 --- /dev/null +++ b/pkg/deviceagent/tray/tray_nocgo.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin && !cgo + +package tray + +import "errors" + +func Run(_ Options) error { + return errors.New("tray helper requires a CGO-enabled build (set CGO_ENABLED=1)") +} diff --git a/pkg/deviceagent/tray/tray_stub.go b/pkg/deviceagent/tray/tray_stub.go new file mode 100644 index 000000000..b319843ff --- /dev/null +++ b/pkg/deviceagent/tray/tray_stub.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !darwin && !windows + +package tray + +import "errors" + +func Run(_ Options) error { + return errors.New("tray helper is only supported on macOS and Windows") +} + +func RegisterAutoStart(_ string, _ string) error { + return nil +} + +func UnregisterAutoStart() error { + return nil +} diff --git a/pkg/deviceagent/tray/tray_supported.go b/pkg/deviceagent/tray/tray_supported.go new file mode 100644 index 000000000..9aa2dcab8 --- /dev/null +++ b/pkg/deviceagent/tray/tray_supported.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build (darwin && cgo) || windows + +package tray + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "fyne.io/systray" + "go.probo.inc/probo/pkg/deviceagent" +) + +func Run(opts Options) error { + if opts.ServerURL == "" { + opts.ServerURL = deviceagent.DefaultServerURL + } + + done := make(chan struct{}) + + var shutdown sync.Once + + stop := func() { + shutdown.Do(func() { + close(done) + }) + } + + sigCh := make(chan os.Signal, 1) + + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + defer signal.Stop(sigCh) + + go func() { + select { + case <-sigCh: + stop() + systray.Quit() + case <-done: + } + }() + + systray.Run( + func() { + onReady(opts, done) + }, + stop, + ) + + return nil +} + +func onReady(opts Options, done <-chan struct{}) { + setTrayIcons() + + systray.SetTitle("Probo") + + enrollmentRequiredItem := systray.AddMenuItem("Enrollment required", "Enroll from the Probo console") + enrollmentRequiredItem.Disable() + + connectedItem := systray.AddMenuItem("Connected", "Device is enrolled and reporting") + connectedItem.Disable() + + statusUnavailableItem := systray.AddMenuItem( + "Status unavailable", + "Cannot read enrollment status", + ) + statusUnavailableItem.Disable() + statusUnavailableItem.Hide() + + systray.AddSeparator() + + aboutItem := systray.AddMenuItem( + fmt.Sprintf("About probo-agent %s", opts.Version), + "Probo device posture agent", + ) + + updateMenu := func() { + enrolled, err := deviceagent.IsEnrolled(opts.RunDir) + if err != nil { + enrollmentRequiredItem.Hide() + connectedItem.Hide() + statusUnavailableItem.Show() + systray.SetTooltip("Probo Device Posture Agent — Status unavailable") + + return + } + + statusUnavailableItem.Hide() + + if enrolled { + enrollmentRequiredItem.Hide() + connectedItem.Show() + systray.SetTooltip("Probo Device Posture Agent — Connected") + } else { + enrollmentRequiredItem.Show() + connectedItem.Hide() + systray.SetTooltip("Probo Device Posture Agent — Enrollment required") + } + } + + updateMenu() + + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + updateMenu() + case <-done: + return + } + } + }() + + go func() { + for { + select { + case <-aboutItem.ClickedCh: + showAbout(opts.Version) + case <-done: + return + } + } + }() +} diff --git a/pkg/deviceagent/tray/wts_session_windows.go b/pkg/deviceagent/tray/wts_session_windows.go new file mode 100644 index 000000000..175329a35 --- /dev/null +++ b/pkg/deviceagent/tray/wts_session_windows.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "fmt" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + wtsUserName = 5 + wtsDomainName = 7 +) + +var ( + modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSQuerySessionInformationW = modWtsapi32.NewProc("WTSQuerySessionInformationW") +) + +func wtsQuerySessionString(sessionID uint32, infoClass uint32) (string, error) { + var ( + buffer *uint16 + bytesReturned uint32 + ) + + r0, _, err := procWTSQuerySessionInformationW.Call( + 0, + uintptr(sessionID), + uintptr(infoClass), + uintptr(unsafe.Pointer(&buffer)), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if r0 == 0 { + if err != syscall.Errno(0) { + return "", err + } + + return "", syscall.EINVAL + } + + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(buffer))) + + return windows.UTF16PtrToString(buffer), nil +} + +func sessionUserAndDomain(sessionID uint32) (string, string, error) { + user, err := wtsQuerySessionString(sessionID, wtsUserName) + if err != nil { + return "", "", fmt.Errorf("cannot query session %d username: %w", sessionID, err) + } + + user = strings.TrimSpace(user) + if user == "" { + return "", "", fmt.Errorf("session %d username is empty", sessionID) + } + + domain, err := wtsQuerySessionString(sessionID, wtsDomainName) + if err != nil { + return "", "", fmt.Errorf("cannot query session %d domain: %w", sessionID, err) + } + + return user, strings.TrimSpace(domain), nil +}