diff --git a/cmd/probo-agent/desktop.go b/cmd/probo-agent/desktop.go index 6b6f9297f..d85709e72 100644 --- a/cmd/probo-agent/desktop.go +++ b/cmd/probo-agent/desktop.go @@ -44,7 +44,10 @@ func registerTrayAutoStart(exePath string, runDir string) error { } func newTrayCmd() *cobra.Command { - var runDir string + var ( + runDir string + serverURL string + ) cmd := &cobra.Command{ Use: "tray", @@ -54,6 +57,15 @@ func newTrayCmd() *cobra.Command { runDir = deviceagent.DefaultEnrollmentRunDir() } + if serverURL != "" { + normalized, err := deviceagent.NormalizeServerURL(serverURL) + if err != nil { + return fmt.Errorf("invalid --server: %w", err) + } + + serverURL = normalized + } + exePath, err := os.Executable() if err != nil { return fmt.Errorf("cannot resolve current executable path: %w", err) @@ -63,7 +75,7 @@ func newTrayCmd() *cobra.Command { tray.Options{ RunDir: runDir, ExePath: exePath, - ServerURL: deviceagent.DefaultServerURL, + ServerURL: serverURL, Version: version, }, ) @@ -76,6 +88,12 @@ func newTrayCmd() *cobra.Command { deviceagent.DefaultEnrollmentRunDir(), "directory containing the public enrollment marker", ) + cmd.Flags().StringVar( + &serverURL, + "server", + "", + "Probo console base URL; skips region picker when set (for local dev)", + ) return cmd } diff --git a/pkg/deviceagent/server_url.go b/pkg/deviceagent/server_url.go index 0ae3f06eb..3bc319c4d 100644 --- a/pkg/deviceagent/server_url.go +++ b/pkg/deviceagent/server_url.go @@ -77,3 +77,18 @@ func NormalizeServerURL(host string) (string, error) { return (&url.URL{Scheme: scheme, Host: strings.ToLower(parsed.Host)}).String(), nil } + +// ConsoleEnrollURL returns the browser enrollment page URL for serverURL. +func ConsoleEnrollURL(serverURL string) (string, error) { + normalized, err := NormalizeServerURL(serverURL) + if err != nil { + return "", err + } + + enrollURL, err := url.JoinPath(normalized, "enroll") + if err != nil { + return "", fmt.Errorf("cannot build console enroll URL: %w", err) + } + + return enrollURL, nil +} diff --git a/pkg/deviceagent/server_url_test.go b/pkg/deviceagent/server_url_test.go index d4593e33f..ab05603eb 100644 --- a/pkg/deviceagent/server_url_test.go +++ b/pkg/deviceagent/server_url_test.go @@ -66,3 +66,45 @@ func TestNormalizeServerURL(t *testing.T) { }) } } + +func TestConsoleEnrollURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "hosted US console", + input: USConsoleURL, + want: USConsoleURL + "/enroll", + }, + { + name: "local dev console", + input: "http://localhost:3000", + want: "http://localhost:3000/enroll", + }, + { + name: "rejects whitespace-only input", + input: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ConsoleEnrollURL(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/deviceagent/tray/browser.go b/pkg/deviceagent/tray/browser.go new file mode 100644 index 000000000..22e87c382 --- /dev/null +++ b/pkg/deviceagent/tray/browser.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin || windows + +package tray + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" + "strings" +) + +func openBrowser(rawURL string) error { + if err := validateBrowserURL(rawURL); err != nil { + return fmt.Errorf("cannot open browser URL: %w", err) + } + + switch runtime.GOOS { + case "darwin": + return exec.Command("open", rawURL).Start() + case "windows": + return exec.Command( + "rundll32", + "url.dll,FileProtocolHandler", + rawURL, + ).Start() + default: + return fmt.Errorf("unsupported platform %q", runtime.GOOS) + } +} + +func validateBrowserURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("cannot parse URL: %w", err) + } + + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("unsupported URL scheme %q", parsed.Scheme) + } + + return nil +} diff --git a/pkg/deviceagent/tray/browser_test.go b/pkg/deviceagent/tray/browser_test.go new file mode 100644 index 000000000..443ad74cc --- /dev/null +++ b/pkg/deviceagent/tray/browser_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin || windows + +package tray + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateBrowserURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "accepts https", + input: "https://us.probo.com/enroll", + }, + { + name: "accepts http", + input: "http://localhost:3000/enroll", + }, + { + name: "accepts uppercase https", + input: "HTTPS://eu.probo.com/enroll", + }, + { + name: "rejects file scheme", + input: "file:///etc/passwd", + wantErr: `unsupported URL scheme "file"`, + }, + { + name: "rejects javascript scheme", + input: "javascript:alert(1)", + wantErr: `unsupported URL scheme "javascript"`, + }, + { + name: "rejects custom scheme", + input: "slack://open", + wantErr: `unsupported URL scheme "slack"`, + }, + { + name: "rejects empty input", + input: "", + wantErr: `unsupported URL scheme ""`, + }, + { + name: "rejects scheme-less input", + input: "us.probo.com/enroll", + wantErr: `unsupported URL scheme ""`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateBrowserURL(tt.input) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestOpenBrowser_RejectsDisallowedScheme(t *testing.T) { + t.Parallel() + + err := openBrowser("file:///tmp/evil") + require.Error(t, err) + assert.ErrorContains(t, err, "cannot open browser URL") + assert.ErrorContains(t, err, `unsupported URL scheme "file"`) +} diff --git a/pkg/deviceagent/tray/enroll.go b/pkg/deviceagent/tray/enroll.go new file mode 100644 index 000000000..f6f3b1985 --- /dev/null +++ b/pkg/deviceagent/tray/enroll.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build (darwin && cgo) || windows + +package tray + +import ( + "fmt" + "os" + + "go.probo.inc/probo/pkg/deviceagent" +) + +func openConsoleEnroll(serverURL string) { + enrollURL, err := deviceagent.ConsoleEnrollURL(serverURL) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot open enrollment page: %v\n", err) + return + } + + if err := openBrowser(enrollURL); err != nil { + fmt.Fprintf(os.Stderr, "cannot open browser: %v\n", err) + } +} + +func openSelfHostedEnroll() { + hostname, ok := promptSelfHostedHostname() + if !ok { + return + } + + normalized, err := deviceagent.NormalizeServerURL(hostname) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid hostname: %v\n", err) + return + } + + openConsoleEnroll(normalized) +} diff --git a/pkg/deviceagent/tray/prompt_hostname_darwin.go b/pkg/deviceagent/tray/prompt_hostname_darwin.go new file mode 100644 index 000000000..48a935b05 --- /dev/null +++ b/pkg/deviceagent/tray/prompt_hostname_darwin.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package tray + +import ( + "os/exec" + "strings" +) + +func promptSelfHostedHostname() (string, bool) { + path, ok := osascriptPath() + if !ok { + return "", false + } + + const script = `text returned of (display dialog "Enter your Probo hostname:" default answer "" with title "Probo Device Posture Agent")` + + out, err := exec.Command(path, "-e", script).Output() + if err != nil { + return "", false + } + + hostname := strings.TrimSpace(string(out)) + if hostname == "" { + return "", false + } + + return hostname, true +} diff --git a/pkg/deviceagent/tray/prompt_hostname_windows.go b/pkg/deviceagent/tray/prompt_hostname_windows.go new file mode 100644 index 000000000..12741bf9b --- /dev/null +++ b/pkg/deviceagent/tray/prompt_hostname_windows.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build windows + +package tray + +import ( + "os/exec" + "strings" +) + +func promptSelfHostedHostname() (string, bool) { + script := ` +Add-Type -AssemblyName Microsoft.VisualBasic +[Microsoft.VisualBasic.Interaction]::InputBox( + 'Enter your Probo hostname:', + 'Probo Device Posture Agent', + '' +) +` + out, err := exec.Command( + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ).Output() + if err != nil { + return "", false + } + + hostname := strings.TrimSpace(string(out)) + if hostname == "" { + return "", false + } + + return hostname, true +} diff --git a/pkg/deviceagent/tray/tray_supported.go b/pkg/deviceagent/tray/tray_supported.go index 9aa2dcab8..887978b7f 100644 --- a/pkg/deviceagent/tray/tray_supported.go +++ b/pkg/deviceagent/tray/tray_supported.go @@ -35,10 +35,6 @@ import ( ) func Run(opts Options) error { - if opts.ServerURL == "" { - opts.ServerURL = deviceagent.DefaultServerURL - } - done := make(chan struct{}) var shutdown sync.Once @@ -73,13 +69,28 @@ func Run(opts Options) error { return nil } +type enrollmentMenu struct { + items []*systray.MenuItem +} + +func (m enrollmentMenu) show() { + for _, item := range m.items { + item.Show() + } +} + +func (m enrollmentMenu) hide() { + for _, item := range m.items { + item.Hide() + } +} + func onReady(opts Options, done <-chan struct{}) { setTrayIcons() systray.SetTitle("Probo") - enrollmentRequiredItem := systray.AddMenuItem("Enrollment required", "Enroll from the Probo console") - enrollmentRequiredItem.Disable() + enrollMenu := setupEnrollmentMenu(opts, done) connectedItem := systray.AddMenuItem("Connected", "Device is enrolled and reporting") connectedItem.Disable() @@ -101,7 +112,7 @@ func onReady(opts Options, done <-chan struct{}) { updateMenu := func() { enrolled, err := deviceagent.IsEnrolled(opts.RunDir) if err != nil { - enrollmentRequiredItem.Hide() + enrollMenu.hide() connectedItem.Hide() statusUnavailableItem.Show() systray.SetTooltip("Probo Device Posture Agent — Status unavailable") @@ -112,11 +123,11 @@ func onReady(opts Options, done <-chan struct{}) { statusUnavailableItem.Hide() if enrolled { - enrollmentRequiredItem.Hide() + enrollMenu.hide() connectedItem.Show() systray.SetTooltip("Probo Device Posture Agent — Connected") } else { - enrollmentRequiredItem.Show() + enrollMenu.show() connectedItem.Hide() systray.SetTooltip("Probo Device Posture Agent — Enrollment required") } @@ -149,3 +160,55 @@ func onReady(opts Options, done <-chan struct{}) { } }() } + +func setupEnrollmentMenu(opts Options, done <-chan struct{}) enrollmentMenu { + const enrollTooltip = "Open enrollment in your browser" + + if opts.ServerURL != "" { + enrollItem := systray.AddMenuItem("Enroll in browser", enrollTooltip) + bindMenuClick(enrollItem, done, func() { + openConsoleEnroll(opts.ServerURL) + }) + + return enrollmentMenu{items: []*systray.MenuItem{enrollItem}} + } + + enrollRoot := systray.AddMenuItem("Enroll in browser", enrollTooltip) + + usItem := enrollRoot.AddSubMenuItem( + "United States (us.probo.com)", + "Open US console enrollment", + ) + bindMenuClick(usItem, done, func() { + openConsoleEnroll(deviceagent.USConsoleURL) + }) + + euItem := enrollRoot.AddSubMenuItem( + "European Union (eu.probo.com)", + "Open EU console enrollment", + ) + bindMenuClick(euItem, done, func() { + openConsoleEnroll(deviceagent.EUConsoleURL) + }) + + selfHostedItem := enrollRoot.AddSubMenuItem( + "Self hosted…", + "Enter your Probo hostname", + ) + bindMenuClick(selfHostedItem, done, openSelfHostedEnroll) + + return enrollmentMenu{items: []*systray.MenuItem{enrollRoot}} +} + +func bindMenuClick(item *systray.MenuItem, done <-chan struct{}, fn func()) { + go func() { + for { + select { + case <-item.ClickedCh: + fn() + case <-done: + return + } + } + }() +}