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 <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-14 20:40:05 +02:00
parent e767dd8377
commit b442e1ed76
44 changed files with 2814 additions and 88 deletions

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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")
}

View File

@@ -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

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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))
}

View File

@@ -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"
}

View File

@@ -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")
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 + `"`
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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")
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
)
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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, "'", "''")
}

117
pkg/deviceagent/enroll.go Normal file
View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,257 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
)
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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")
},
)
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
)
}

View File

@@ -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.

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
})
}
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{xml .Label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{xml .ExePath}}</string>
<string>run</string>
<string>--dir</string>
<string>{{xml .Dir}}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/probo-agent.log</string>
<key>StandardErrorPath</key>
<string>/var/log/probo-agent.log</string>
<key>UserName</key>
<string>root</string>
<key>GroupName</key>
<string>wheel</string>
</dict>
</plist>

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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))
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
)
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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-")
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
)
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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()
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{xml .Label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{xml .ExePath}}</string>
<string>tray</string>
<string>--run-dir</string>
<string>{{xml .RunDir}}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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),
)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)")
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}
}
}()
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}