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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
77
pkg/deviceagent/client_test.go
Normal file
77
pkg/deviceagent/client_test.go
Normal 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))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
46
pkg/deviceagent/elevate/elevate.go
Normal file
46
pkg/deviceagent/elevate/elevate.go
Normal 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)
|
||||
}
|
||||
72
pkg/deviceagent/elevate/elevate_darwin.go
Normal file
72
pkg/deviceagent/elevate/elevate_darwin.go
Normal 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 + `"`
|
||||
}
|
||||
70
pkg/deviceagent/elevate/elevate_windows.go
Normal file
70
pkg/deviceagent/elevate/elevate_windows.go
Normal 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)
|
||||
}
|
||||
29
pkg/deviceagent/elevate/install_stub.go
Normal file
29
pkg/deviceagent/elevate/install_stub.go
Normal 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")
|
||||
}
|
||||
34
pkg/deviceagent/elevate/install_supported.go
Normal file
34
pkg/deviceagent/elevate/install_supported.go
Normal 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,
|
||||
)
|
||||
}
|
||||
29
pkg/deviceagent/elevate/powershell_windows.go
Normal file
29
pkg/deviceagent/elevate/powershell_windows.go
Normal 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
117
pkg/deviceagent/enroll.go
Normal 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
|
||||
}
|
||||
257
pkg/deviceagent/enroll_test.go
Normal file
257
pkg/deviceagent/enroll_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
62
pkg/deviceagent/enroll_url.go
Normal file
62
pkg/deviceagent/enroll_url.go
Normal 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
|
||||
}
|
||||
98
pkg/deviceagent/enroll_url_test.go
Normal file
98
pkg/deviceagent/enroll_url_test.go
Normal 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")
|
||||
},
|
||||
)
|
||||
}
|
||||
112
pkg/deviceagent/enrollment_state.go
Normal file
112
pkg/deviceagent/enrollment_state.go
Normal 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
|
||||
}
|
||||
108
pkg/deviceagent/enrollment_state_test.go
Normal file
108
pkg/deviceagent/enrollment_state_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
79
pkg/deviceagent/server_url.go
Normal file
79
pkg/deviceagent/server_url.go
Normal 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
|
||||
}
|
||||
68
pkg/deviceagent/server_url_test.go
Normal file
68
pkg/deviceagent/server_url_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
28
pkg/deviceagent/service/launchd.plist.tmpl
Normal file
28
pkg/deviceagent/service/launchd.plist.tmpl
Normal 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>
|
||||
261
pkg/deviceagent/tray/autostart_darwin.go
Normal file
261
pkg/deviceagent/tray/autostart_darwin.go
Normal 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))
|
||||
}
|
||||
87
pkg/deviceagent/tray/autostart_darwin_test.go
Normal file
87
pkg/deviceagent/tray/autostart_darwin_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
94
pkg/deviceagent/tray/autostart_windows.go
Normal file
94
pkg/deviceagent/tray/autostart_windows.go
Normal 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
|
||||
}
|
||||
213
pkg/deviceagent/tray/console_user_windows.go
Normal file
213
pkg/deviceagent/tray/console_user_windows.go
Normal 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-")
|
||||
}
|
||||
128
pkg/deviceagent/tray/console_user_windows_test.go
Normal file
128
pkg/deviceagent/tray/console_user_windows_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
41
pkg/deviceagent/tray/dialog_darwin.go
Normal file
41
pkg/deviceagent/tray/dialog_darwin.go
Normal 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()
|
||||
}
|
||||
35
pkg/deviceagent/tray/dialog_windows.go
Normal file
35
pkg/deviceagent/tray/dialog_windows.go
Normal 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)
|
||||
}
|
||||
BIN
pkg/deviceagent/tray/icon.png
Normal file
BIN
pkg/deviceagent/tray/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 B |
BIN
pkg/deviceagent/tray/iconTemplate.png
Normal file
BIN
pkg/deviceagent/tray/iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 B |
31
pkg/deviceagent/tray/icons.go
Normal file
31
pkg/deviceagent/tray/icons.go
Normal 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
|
||||
29
pkg/deviceagent/tray/icons_darwin.go
Normal file
29
pkg/deviceagent/tray/icons_darwin.go
Normal 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)
|
||||
}
|
||||
29
pkg/deviceagent/tray/icons_windows.go
Normal file
29
pkg/deviceagent/tray/icons_windows.go
Normal 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)
|
||||
}
|
||||
20
pkg/deviceagent/tray/launchagent.plist.tmpl
Normal file
20
pkg/deviceagent/tray/launchagent.plist.tmpl
Normal 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>
|
||||
58
pkg/deviceagent/tray/messagebox_windows.go
Normal file
58
pkg/deviceagent/tray/messagebox_windows.go
Normal 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),
|
||||
)
|
||||
}
|
||||
34
pkg/deviceagent/tray/osascript_darwin.go
Normal file
34
pkg/deviceagent/tray/osascript_darwin.go
Normal 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
|
||||
}
|
||||
28
pkg/deviceagent/tray/tray.go
Normal file
28
pkg/deviceagent/tray/tray.go
Normal 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
|
||||
}
|
||||
29
pkg/deviceagent/tray/tray_nocgo.go
Normal file
29
pkg/deviceagent/tray/tray_nocgo.go
Normal 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)")
|
||||
}
|
||||
37
pkg/deviceagent/tray/tray_stub.go
Normal file
37
pkg/deviceagent/tray/tray_stub.go
Normal 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
|
||||
}
|
||||
151
pkg/deviceagent/tray/tray_supported.go
Normal file
151
pkg/deviceagent/tray/tray_supported.go
Normal 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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
87
pkg/deviceagent/tray/wts_session_windows.go
Normal file
87
pkg/deviceagent/tray/wts_session_windows.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user