Add device agent library

Provide enrollment, elevated install, posture checks, keystore, and
system-tray helpers shared by the probo-agent binary.

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

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin
package tray
import (
_ "embed"
"encoding/xml"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"text/template"
)
const (
trayLabel = "com.probo.agent.tray"
trayPlistPath = "/Library/LaunchAgents/com.probo.agent.tray.plist"
)
var (
//go:embed launchagent.plist.tmpl
launchAgentPlistTmpl string
launchAgentPlist = template.Must(
template.New("launchagent").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchAgentPlistTmpl),
)
)
func xmlEscape(v string) (string, error) {
var sb strings.Builder
if err := xml.EscapeText(&sb, []byte(v)); err != nil {
return "", err
}
return sb.String(), nil
}
type launchAgentData struct {
Label string
ExePath string
RunDir string
}
func RegisterAutoStart(exePath string, runDir string) error {
if exePath == "" {
return fmt.Errorf("executable path is required")
}
if runDir == "" {
return fmt.Errorf("enrollment run directory is required")
}
if err := writeTrayLaunchAgentPlist(exePath, runDir); err != nil {
return err
}
uids := activeGUIUserUIDs()
if len(uids) == 0 {
return nil
}
return bootstrapTrayForUIDs(uids)
}
func writeTrayLaunchAgentPlist(exePath string, runDir string) error {
agentsDir := filepath.Dir(trayPlistPath)
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
return fmt.Errorf("cannot ensure launch agents directory: %w", err)
}
f, err := os.OpenFile(trayPlistPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("cannot write plist (need root?): %w", err)
}
defer func() { _ = f.Close() }()
if err := launchAgentPlist.Execute(
f,
launchAgentData{
Label: trayLabel,
ExePath: exePath,
RunDir: runDir,
},
); err != nil {
return fmt.Errorf("cannot render LaunchAgent plist: %w", err)
}
return nil
}
// UnregisterAutoStart stops the tray LaunchAgent and removes its plist.
func UnregisterAutoStart() error {
bootoutTrayForUIDs(activeGUIUserUIDs())
if err := os.Remove(trayPlistPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot remove plist: %w", err)
}
return nil
}
func bootstrapTrayForUIDs(uids []int) error {
for _, uid := range uids {
target := fmt.Sprintf("gui/%d/%s", uid, trayLabel)
_ = exec.Command("launchctl", "bootout", target).Run()
if out, err := exec.Command(
"launchctl",
"bootstrap",
fmt.Sprintf("gui/%d", uid),
trayPlistPath,
).CombinedOutput(); err != nil {
return fmt.Errorf(
"cannot run launchctl bootstrap for uid %d: %w: %s",
uid,
err,
strings.TrimSpace(string(out)),
)
}
}
return nil
}
func bootoutTrayForUIDs(uids []int) {
for _, uid := range uids {
target := fmt.Sprintf("gui/%d/%s", uid, trayLabel)
_ = exec.Command("launchctl", "bootout", target).Run()
}
}
func activeGUIUserUIDs() []int {
names := loggedInUsernames()
if len(names) == 0 {
if uid, err := currentGUIUserUID(); err == nil {
return []int{uid}
}
return nil
}
seen := make(map[int]struct{}, len(names))
uids := make([]int, 0, len(names))
for _, name := range names {
uid, err := uidForUsername(name)
if err != nil {
continue
}
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
uids = append(uids, uid)
}
if len(uids) == 0 {
if uid, err := currentGUIUserUID(); err == nil {
return []int{uid}
}
}
return uids
}
func loggedInUsernames() []string {
out, err := exec.Command("users").Output()
if err != nil {
return nil
}
return parseLoggedInUsernames(string(out))
}
func parseLoggedInUsernames(usersOutput string) []string {
tokens := strings.Fields(usersOutput)
seen := make(map[string]struct{}, len(tokens))
names := make([]string, 0, len(tokens))
for _, name := range tokens {
name = strings.TrimSpace(name)
if name == "" || name == "root" || name == "loginwindow" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func currentGUIUserUID() (int, error) {
name := strings.TrimSpace(consoleUserName())
if name == "" || name == "root" || name == "loginwindow" {
name = strings.TrimSpace(os.Getenv("USER"))
}
if name == "" || name == "root" || name == "loginwindow" {
return 0, fmt.Errorf("cannot resolve active GUI user")
}
return uidForUsername(name)
}
func uidForUsername(name string) (int, error) {
uidOut, err := exec.Command("id", "-u", name).Output()
if err != nil {
return 0, fmt.Errorf("cannot resolve uid for %s: %w", name, err)
}
uidRaw := strings.TrimSpace(string(uidOut))
uid, err := strconv.Atoi(uidRaw)
if err != nil {
return 0, fmt.Errorf("invalid uid %q", uidRaw)
}
return uid, nil
}
func consoleUserName() string {
out, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin
package tray
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseLoggedInUsernames(t *testing.T) {
t.Parallel()
tests := []struct {
name string
output string
want []string
}{
{
name: "empty output",
output: "",
want: []string{},
},
{
name: "single user",
output: "alice\n",
want: []string{"alice"},
},
{
name: "multiple users",
output: "alice bob\n",
want: []string{"alice", "bob"},
},
{
name: "duplicate users",
output: "alice alice bob\n",
want: []string{"alice", "bob"},
},
{
name: "skips root and loginwindow",
output: "root loginwindow alice\n",
want: []string{"alice"},
},
{
name: "whitespace only",
output: " \n",
want: []string{},
},
{
name: "extra whitespace between names",
output: " alice bob \n",
want: []string{"alice", "bob"},
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
got := parseLoggedInUsernames(tt.output)
assert.Equal(t, tt.want, got)
},
)
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"errors"
"fmt"
"golang.org/x/sys/windows/registry"
)
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
const runValueName = "ProboAgentTray"
func RegisterAutoStart(exePath string, runDir string) error {
if exePath == "" {
return fmt.Errorf("executable path is required")
}
if runDir == "" {
return fmt.Errorf("enrollment run directory is required")
}
sid, err := currentInteractiveUserSID()
if err != nil {
return fmt.Errorf("cannot resolve interactive user for tray auto-start: %w", err)
}
keyPath := sid + `\` + runKeyPath
key, _, err := registry.CreateKey(registry.USERS, keyPath, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("cannot open or create Run registry key for interactive user: %w", err)
}
defer func() { _ = key.Close() }()
command := fmt.Sprintf(`"%s" tray --run-dir "%s"`, exePath, runDir)
if err := key.SetStringValue(runValueName, command); err != nil {
return fmt.Errorf("cannot set Run registry value: %w", err)
}
return nil
}
func UnregisterAutoStart() error {
sid, err := currentInteractiveUserSID()
if err != nil {
return fmt.Errorf("cannot resolve interactive user for tray auto-start: %w", err)
}
keyPath := sid + `\` + runKeyPath
key, err := registry.OpenKey(registry.USERS, keyPath, registry.SET_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
return nil
}
return fmt.Errorf("cannot open Run registry key for interactive user: %w", err)
}
defer func() { _ = key.Close() }()
if err := key.DeleteValue(runValueName); err != nil {
if errors.Is(err, registry.ErrNotExist) {
return nil
}
return fmt.Errorf("cannot delete Run registry value: %w", err)
}
return nil
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"fmt"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
const invalidSessionID = 0xFFFFFFFF
func currentInteractiveUserSID() (string, error) {
var lastErr error
for _, sessionID := range interactiveSessionCandidates() {
sid, err := sidFromSessionID(sessionID)
if err == nil {
return sid, nil
}
lastErr = err
}
if lastErr != nil {
return "", fmt.Errorf("no interactive user session available: %w", lastErr)
}
return "", fmt.Errorf("no interactive user session available")
}
func interactiveSessionCandidates() []uint32 {
candidates := make([]uint32, 0, 4)
var sessionID uint32
if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &sessionID); err == nil && sessionID != 0 {
candidates = appendUniqueSessionID(candidates, sessionID)
}
consoleSessionID := windows.WTSGetActiveConsoleSessionId()
if consoleSessionID != invalidSessionID {
candidates = appendUniqueSessionID(candidates, consoleSessionID)
}
for _, id := range activeWTSSessionIDs() {
candidates = appendUniqueSessionID(candidates, id)
}
return candidates
}
func activeWTSSessionIDs() []uint32 {
var (
sessions *windows.WTS_SESSION_INFO
count uint32
)
if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil {
return nil
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
sessionSlice := unsafe.Slice(sessions, count)
ids := make([]uint32, 0, len(sessionSlice))
for _, session := range sessionSlice {
if session.SessionID == 0 {
continue
}
if session.State != windows.WTSActive && session.State != windows.WTSConnected {
continue
}
ids = append(ids, session.SessionID)
}
return ids
}
func appendUniqueSessionID(ids []uint32, sessionID uint32) []uint32 {
for _, id := range ids {
if id == sessionID {
return ids
}
}
return append(ids, sessionID)
}
func sidFromSessionID(sessionID uint32) (string, error) {
sid, err := sidFromSessionInformation(sessionID)
if err == nil {
return sid, nil
}
if isCurrentProcessLocalSystem() {
if sid, tokenErr := sidFromSessionUserToken(sessionID); tokenErr == nil {
return sid, nil
}
}
return "", err
}
func sidFromSessionInformation(sessionID uint32) (string, error) {
user, domain, err := sessionUserAndDomain(sessionID)
if err != nil {
return "", err
}
account := user
system := ""
if domain != "" {
account = domain + `\` + user
}
sid, _, _, err := windows.LookupSID(system, account)
if err != nil {
return "", fmt.Errorf("cannot lookup session %d user SID: %w", sessionID, err)
}
sidStr := sid.String()
if sidStr == "" {
return "", fmt.Errorf("session %d user SID is empty", sessionID)
}
if !isInteractiveUserSID(sidStr) {
return "", fmt.Errorf("session %d has no interactive user", sessionID)
}
return sidStr, nil
}
func sidFromSessionUserToken(sessionID uint32) (string, error) {
var token windows.Token
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return "", fmt.Errorf("cannot query session %d user token: %w", sessionID, err)
}
defer func() { _ = token.Close() }()
tu, err := token.GetTokenUser()
if err != nil {
return "", fmt.Errorf("cannot read session %d user: %w", sessionID, err)
}
if tu.User.Sid == nil {
return "", fmt.Errorf("session %d user SID is empty", sessionID)
}
sid := tu.User.Sid.String()
if sid == "" {
return "", fmt.Errorf("session %d user SID is empty", sessionID)
}
if !isInteractiveUserSID(sid) {
return "", fmt.Errorf("session %d has no interactive user", sessionID)
}
return sid, nil
}
func isCurrentProcessLocalSystem() bool {
token, err := windows.OpenCurrentProcessToken()
if err != nil {
return false
}
defer func() { _ = token.Close() }()
tu, err := token.GetTokenUser()
if err != nil {
return false
}
systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
if err != nil {
return false
}
return tu.User.Sid.Equals(systemSID)
}
func isInteractiveUserSID(sid string) bool {
return strings.HasPrefix(sid, "S-1-5-21-") ||
strings.HasPrefix(sid, "S-1-12-1-")
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsInteractiveUserSID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sid string
want bool
}{
{
name: "domain user sid",
sid: "S-1-5-21-1234567890-123456789-123456789-1001",
want: true,
},
{
name: "entra user sid",
sid: "S-1-12-1-3603547745-1252762009-756918658-301435180",
want: true,
},
{
name: "entra authority without user subauthority",
sid: "S-1-12-2-3603547745-1252762009-756918658-301435180",
want: false,
},
{
name: "bare entra authority prefix",
sid: "S-1-12-",
want: false,
},
{
name: "local system sid",
sid: "S-1-5-18",
want: false,
},
{
name: "builtin administrators sid",
sid: "S-1-5-32-544",
want: false,
},
{
name: "empty sid",
sid: "",
want: false,
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, isInteractiveUserSID(tt.sid))
},
)
}
}
func TestAppendUniqueSessionID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ids []uint32
sessionID uint32
want []uint32
}{
{
name: "append to empty slice",
ids: nil,
sessionID: 2,
want: []uint32{2},
},
{
name: "append new session",
ids: []uint32{1},
sessionID: 2,
want: []uint32{1, 2},
},
{
name: "skip duplicate session",
ids: []uint32{1, 2},
sessionID: 2,
want: []uint32{1, 2},
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
got := appendUniqueSessionID(tt.ids, tt.sessionID)
assert.Equal(t, tt.want, got)
},
)
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin
package tray
import (
"fmt"
"os/exec"
)
func showAbout(version string) {
path, ok := osascriptPath()
if !ok {
return
}
script := fmt.Sprintf(
`display alert "Probo Device Posture Agent" message %q buttons {"OK"} default button "OK"`,
fmt.Sprintf("Version %s\n\nReports device posture to your Probo workspace.", version),
)
_ = exec.Command(path, "-e", script).Run()
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"fmt"
)
func showAbout(version string) {
message := fmt.Sprintf(
"Version %s\r\n\r\nReports device posture to your Probo workspace.",
version,
)
nativeMessageBox("Probo Device Posture Agent", message, mbOK|mbIconInformation)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package tray
import _ "embed"
// Icons are embedded PNGs; systray does not accept SVG.
//go:embed icon.png
var iconData []byte
//go:embed iconTemplate.png
var iconTemplateData []byte

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin && cgo
package tray
import "fyne.io/systray"
func setTrayIcons() {
systray.SetTemplateIcon(iconTemplateData, iconData)
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import "fyne.io/systray"
func setTrayIcons() {
systray.SetIcon(iconData)
}

View File

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

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"unsafe"
"golang.org/x/sys/windows"
)
const (
mbOK = 0x00000000
mbIconInformation = 0x00000040
)
var (
modUser32 = windows.NewLazySystemDLL("user32.dll")
procMessageBoxW = modUser32.NewProc("MessageBoxW")
)
func nativeMessageBox(title, message string, flags uint32) {
titleUTF16, err := windows.UTF16PtrFromString(title)
if err != nil {
return
}
messageUTF16, err := windows.UTF16PtrFromString(message)
if err != nil {
return
}
_, _, _ = procMessageBoxW.Call(
0,
uintptr(unsafe.Pointer(messageUTF16)),
uintptr(unsafe.Pointer(titleUTF16)),
uintptr(flags),
)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin
package tray
import "go.probo.inc/probo/pkg/deviceagent/checks"
func osascriptPath() (string, bool) {
candidates := checks.CommandCandidates("osascript")
if len(candidates) == 0 {
return "", false
}
return candidates[0], true
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package tray
type Options struct {
RunDir string
ExePath string
ServerURL string
Version string
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build darwin && !cgo
package tray
import "errors"
func Run(_ Options) error {
return errors.New("tray helper requires a CGO-enabled build (set CGO_ENABLED=1)")
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build !darwin && !windows
package tray
import "errors"
func Run(_ Options) error {
return errors.New("tray helper is only supported on macOS and Windows")
}
func RegisterAutoStart(_ string, _ string) error {
return nil
}
func UnregisterAutoStart() error {
return nil
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build (darwin && cgo) || windows
package tray
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"fyne.io/systray"
"go.probo.inc/probo/pkg/deviceagent"
)
func Run(opts Options) error {
if opts.ServerURL == "" {
opts.ServerURL = deviceagent.DefaultServerURL
}
done := make(chan struct{})
var shutdown sync.Once
stop := func() {
shutdown.Do(func() {
close(done)
})
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
stop()
systray.Quit()
case <-done:
}
}()
systray.Run(
func() {
onReady(opts, done)
},
stop,
)
return nil
}
func onReady(opts Options, done <-chan struct{}) {
setTrayIcons()
systray.SetTitle("Probo")
enrollmentRequiredItem := systray.AddMenuItem("Enrollment required", "Enroll from the Probo console")
enrollmentRequiredItem.Disable()
connectedItem := systray.AddMenuItem("Connected", "Device is enrolled and reporting")
connectedItem.Disable()
statusUnavailableItem := systray.AddMenuItem(
"Status unavailable",
"Cannot read enrollment status",
)
statusUnavailableItem.Disable()
statusUnavailableItem.Hide()
systray.AddSeparator()
aboutItem := systray.AddMenuItem(
fmt.Sprintf("About probo-agent %s", opts.Version),
"Probo device posture agent",
)
updateMenu := func() {
enrolled, err := deviceagent.IsEnrolled(opts.RunDir)
if err != nil {
enrollmentRequiredItem.Hide()
connectedItem.Hide()
statusUnavailableItem.Show()
systray.SetTooltip("Probo Device Posture Agent — Status unavailable")
return
}
statusUnavailableItem.Hide()
if enrolled {
enrollmentRequiredItem.Hide()
connectedItem.Show()
systray.SetTooltip("Probo Device Posture Agent — Connected")
} else {
enrollmentRequiredItem.Show()
connectedItem.Hide()
systray.SetTooltip("Probo Device Posture Agent — Enrollment required")
}
}
updateMenu()
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
updateMenu()
case <-done:
return
}
}
}()
go func() {
for {
select {
case <-aboutItem.ClickedCh:
showAbout(opts.Version)
case <-done:
return
}
}
}()
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//go:build windows
package tray
import (
"fmt"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
wtsUserName = 5
wtsDomainName = 7
)
var (
modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSQuerySessionInformationW = modWtsapi32.NewProc("WTSQuerySessionInformationW")
)
func wtsQuerySessionString(sessionID uint32, infoClass uint32) (string, error) {
var (
buffer *uint16
bytesReturned uint32
)
r0, _, err := procWTSQuerySessionInformationW.Call(
0,
uintptr(sessionID),
uintptr(infoClass),
uintptr(unsafe.Pointer(&buffer)),
uintptr(unsafe.Pointer(&bytesReturned)),
)
if r0 == 0 {
if err != syscall.Errno(0) {
return "", err
}
return "", syscall.EINVAL
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(buffer)))
return windows.UTF16PtrToString(buffer), nil
}
func sessionUserAndDomain(sessionID uint32) (string, string, error) {
user, err := wtsQuerySessionString(sessionID, wtsUserName)
if err != nil {
return "", "", fmt.Errorf("cannot query session %d username: %w", sessionID, err)
}
user = strings.TrimSpace(user)
if user == "" {
return "", "", fmt.Errorf("session %d username is empty", sessionID)
}
domain, err := wtsQuerySessionString(sessionID, wtsDomainName)
if err != nil {
return "", "", fmt.Errorf("cannot query session %d domain: %w", sessionID, err)
}
return user, strings.TrimSpace(domain), nil
}