From 4f7752e4644bc4afc93aeff8500e87f0e50b0f4d Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 30 Oct 2026 09:53:51 -0400 Subject: [PATCH 1/6] Push client settings --- cmd/config/config.go | 180 +++++++++++++++++++++++++++++-- cmd/select/exitnode/exitnode.go | 6 ++ cmd/select/org/org.go | 6 +- cmd/up/client/client.go | 138 ++++++++++++++++++------ docs/pangolin_config.md | 1 + docs/pangolin_config_get.md | 3 +- docs/pangolin_config_reset.md | 23 ++++ docs/pangolin_config_set.md | 14 ++- internal/api/client.go | 18 ++++ internal/api/types.go | 7 ++ internal/config/accounts.go | 105 +++++++++++++++++- internal/config/config.go | 180 +++++++++++++++++++++++++++++++ internal/config/settings_test.go | 170 +++++++++++++++++++++++++++++ internal/utils/clientsettings.go | 31 ++++++ 14 files changed, 838 insertions(+), 44 deletions(-) create mode 100644 docs/pangolin_config_reset.md create mode 100644 internal/config/settings_test.go create mode 100644 internal/utils/clientsettings.go diff --git a/cmd/config/config.go b/cmd/config/config.go index b348a01..49a4f0a 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -3,9 +3,15 @@ package configcmd import ( "encoding/json" "fmt" + "os" "strings" + "github.com/charmbracelet/huh" + "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/utils" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" ) @@ -21,10 +27,63 @@ func ConfigCmd() *cobra.Command { cmd.AddCommand(configListCmd()) cmd.AddCommand(configGetCmd()) cmd.AddCommand(configSetCmd()) + cmd.AddCommand(configResetCmd()) return cmd } +// activeOrg returns the active account and its selected org, if any. +func activeOrg(cmd *cobra.Command) (*config.Account, string) { + accountStore := config.AccountStoreFromContext(cmd.Context()) + if accountStore == nil { + return nil, "" + } + account, err := accountStore.ActiveAccount() + if err != nil { + return nil, "" + } + return account, account.OrgID +} + +// fetchServerSettings returns the settings the active org's server manages. +func fetchServerSettings(cmd *cobra.Command, account *config.Account, orgID string) *config.ClientSettings { + if account == nil || orgID == "" { + return nil + } + accountStore := config.AccountStoreFromContext(cmd.Context()) + apiClient := api.FromContext(cmd.Context()) + return utils.RefreshClientSettings(apiClient.GetMyClientSettings, accountStore, account, orgID) +} + +// resolveSetTarget picks the org a value is saved for; "" means global. With +// neither flag it asks when it can, the same choice the desktop apps offer on +// save; otherwise it stays global, as the command always has. +func resolveSetTarget(cmd *cobra.Command, key, flagOrg string, global bool, activeOrgID string) (string, error) { + if flagOrg != "" || global || !config.IsOrgConfigKey(key) { + return flagOrg, nil + } + if key == "up.exit_node_resource_id" { + if activeOrgID == "" { + return "", fmt.Errorf("%s can only be set per organization: pass --org", key) + } + return activeOrgID, nil + } + if activeOrgID == "" || !isatty.IsTerminal(os.Stdin.Fd()) { + return "", nil + } + + target := "" + err := huh.NewSelect[string](). + Title("Apply this setting to"). + Options( + huh.NewOption("All organizations (global)", ""), + huh.NewOption(fmt.Sprintf("Only %s", activeOrgID), activeOrgID), + ). + Value(&target). + Run() + return target, err +} + func configPathCmd() *cobra.Command { return &cobra.Command{ Use: "path", @@ -45,11 +104,29 @@ func configShowCmd() *cobra.Command { Use: "show", Short: "Print the current config contents", RunE: func(cmd *cobra.Command, args []string) error { - return dumpConfig(config.ConfigFromContext(cmd.Context())) + cfg := config.ConfigFromContext(cmd.Context()) + + if account, orgID := activeOrg(cmd); orgID != "" { + settings := fetchServerSettings(cmd, account, orgID) + fmt.Fprintln(os.Stderr, settingsModeMessage(cfg, orgID, settings)) + } + return dumpConfig(cfg) }, } } +// settingsModeMessage says which settings apply to the org. +func settingsModeMessage(cfg *config.Config, orgID string, settings *config.ClientSettings) string { + mode := "uses the global config" + if org, ok := cfg.Orgs[orgID]; ok && !org.IsEmpty() { + mode = "has its own config" + } + if settings != nil { + return fmt.Sprintf("Organization %s %s; some settings are managed by its server", orgID, mode) + } + return fmt.Sprintf("Organization %s %s", orgID, mode) +} + func configListCmd() *cobra.Command { return &cobra.Command{ Use: "list", @@ -64,14 +141,22 @@ func configListCmd() *cobra.Command { } func configGetCmd() *cobra.Command { - return &cobra.Command{ + var orgID string + + cmd := &cobra.Command{ Use: "get ", Short: "Get a config value", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.ConfigFromContext(cmd.Context()) - value, err := cfg.GetKey(args[0]) + var value string + var err error + if orgID != "" { + value, err = cfg.GetOrgKey(orgID, args[0]) + } else { + value, err = cfg.GetKey(args[0]) + } if err != nil { return err } @@ -79,41 +164,116 @@ func configGetCmd() *cobra.Command { return nil }, } + + cmd.Flags().StringVar(&orgID, "org", "", "Get the value set for this organization only") + return cmd } func configSetCmd() *cobra.Command { - return &cobra.Command{ + var orgID string + var global bool + + cmd := &cobra.Command{ Use: "set ", Short: "Set a config value", Long: `Set a config value and write it to the config file. +A value can apply to every organization (--global) or to one (--org), which +takes precedence over the global value. Without either flag you're asked when +running interactively with an organization selected; otherwise it's global. +Settings managed by your organization's server take precedence over both. + Supported keys: ` + strings.Join(config.SupportedConfigKeys(), "\n ") + ` +Per-organization only: + up.exit_node_resource_id + Examples: pangolin config set up.tunnel_dns true pangolin config set up.upstream_dns 10.0.0.53 pangolin config set up.upstream_dns 10.0.0.53,10.0.0.54 + pangolin config set up.mtu 1400 --org my-org `, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.ConfigFromContext(cmd.Context()) + key, value := args[0], args[1] - if err := cfg.SetKey(args[0], args[1]); err != nil { + account, activeOrgID := activeOrg(cmd) + target, err := resolveSetTarget(cmd, key, orgID, global, activeOrgID) + if err != nil { + return err + } + + if target != "" { + err = cfg.SetOrgKey(target, key, value) + } else { + err = cfg.SetKey(key, value) + } + if err != nil { return err } if err := cfg.Save(); err != nil { return err } - value, err := cfg.GetKey(args[0]) + var saved string + if target != "" { + saved, err = cfg.GetOrgKey(target, key) + } else { + saved, err = cfg.GetKey(key) + } if err != nil { return err } - fmt.Printf("%s = %s\n", args[0], value) + if target != "" { + fmt.Printf("%s = %s (organization %s)\n", key, saved, target) + } else { + fmt.Printf("%s = %s\n", key, saved) + } + + if config.IsOrgConfigKey(key) && fetchServerSettings(cmd, account, activeOrgID).ManagesKey(key) { + logger.Warning("%s is managed by %s's server, so this value won't apply there", key, activeOrgID) + } + return nil + }, + } + + cmd.Flags().StringVar(&orgID, "org", "", "Set the value for this organization only") + cmd.Flags().BoolVar(&global, "global", false, "Set the value for all organizations") + cmd.MarkFlagsMutuallyExclusive("org", "global") + return cmd +} + +func configResetCmd() *cobra.Command { + var orgID string + + cmd := &cobra.Command{ + Use: "reset", + Short: "Make an organization use the global config again", + Long: `Remove the values set for an organization (the selected one by default), so it uses the global config again.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.ConfigFromContext(cmd.Context()) + + if orgID == "" { + _, orgID = activeOrg(cmd) + } + if orgID == "" { + return fmt.Errorf("no organization selected: pass --org") + } + + cfg.ResetOrg(orgID) + if err := cfg.Save(); err != nil { + return err + } + fmt.Printf("Organization %s now uses the global config\n", orgID) return nil }, } + + cmd.Flags().StringVar(&orgID, "org", "", "Organization to reset (default: selected organization)") + return cmd } func dumpConfig(cfg *config.Config) error { @@ -144,9 +304,15 @@ func dumpConfig(cfg *config.Config) error { if cfg.IsSet("up.exit_node_takes_precedence") { up["exit_node_takes_precedence"] = cfg.GetBool("up.exit_node_takes_precedence") } + if cfg.Up.MTU != nil { + up["mtu"] = *cfg.Up.MTU + } if len(up) > 0 { out["up"] = up } + if len(cfg.Orgs) > 0 { + out["orgs"] = cfg.Orgs + } data, err := json.MarshalIndent(out, "", " ") if err != nil { diff --git a/cmd/select/exitnode/exitnode.go b/cmd/select/exitnode/exitnode.go index 64d000a..0ed3191 100644 --- a/cmd/select/exitnode/exitnode.go +++ b/cmd/select/exitnode/exitnode.go @@ -95,6 +95,12 @@ func exitNodeMain(cmd *cobra.Command, opts *ExitNodeCmdOpts) error { usable = append(usable, g) } } + if utils.RefreshClientSettings(apiClient.GetMyClientSettings, accountStore, activeAccount, orgID).LockedExitNode() != 0 { + err := fmt.Errorf("the exit node is locked by your organization") + logger.Error("%v", err) + return err + } + // The saved exit node, scoped to the account's current org. savedResourceID := activeAccount.ExitNode(orgID) // With a running client, the active exit node is the one it reports; diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 44e4bd9..b631fe1 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -111,8 +111,10 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { switched := utils.SwitchActiveClientOrg(selectedOrgID) if switched { monitorOrgSwitch(selectedOrgID) - // Restore this org's saved exit node in the running client. - utils.ApplySavedExitNodeToActiveClient(account.ExitNode(selectedOrgID), selectedOrgID, apiClient.ListGatewayResources) + // Restore this org's exit node in the running client. + settings := utils.RefreshClientSettings(apiClient.GetMyClientSettings, accountStore, &account, selectedOrgID) + exitNode := config.EffectiveExitNode(settings, account.ExitNode(selectedOrgID), config.ConfigFromContext(cmd.Context()).Orgs[selectedOrgID]) + utils.ApplySavedExitNodeToActiveClient(exitNode, selectedOrgID, apiClient.ListGatewayResources) } else { logger.Success("Successfully selected organization: %s", selectedOrgID) utils.WarnUnmanagedClient(selectedOrgID) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 4af66ce..df5a4cc 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -292,6 +292,81 @@ func applyUpDefaults(cmd *cobra.Command, opts *ClientUpCmdOpts, cfg *config.Conf if !cmd.Flags().Changed("exit-node-takes-precedence") && cfg.IsSet("up.exit_node_takes_precedence") { opts.ExitNodeTakesPrecedence = cfg.GetBool("up.exit_node_takes_precedence") } + if !cmd.Flags().Changed("mtu") && cfg.Up.MTU != nil { + opts.MTU = *cfg.Up.MTU + } +} + +// applyOrgConfig layers the org's entry in the config file over the global +// defaults applyUpDefaults applied. Explicit flags still win. +func applyOrgConfig(cmd *cobra.Command, opts *ClientUpCmdOpts, org config.OrgConfig) { + if !cmd.Flags().Changed("tunnel-dns") && org.TunnelDNS != nil { + opts.TunnelDNS = *org.TunnelDNS + } + if !cmd.Flags().Changed("override-dns") && org.OverrideDNS != nil { + opts.OverrideDNS = *org.OverrideDNS + } + if !cmd.Flags().Changed("upstream-dns") && org.UpstreamDNS != nil { + opts.UpstreamDNS = org.UpstreamDNS + } + if !cmd.Flags().Changed("match-domains") && org.MatchDomains != nil { + opts.MatchDomains = org.MatchDomains + } + if !cmd.Flags().Changed("prefer-local-routes") && org.PreferLocalRoutes != nil { + opts.PreferLocalRoutes = *org.PreferLocalRoutes + } + if !cmd.Flags().Changed("exit-node-takes-precedence") && org.ExitNodeTakesPrecedence != nil { + opts.ExitNodeTakesPrecedence = *org.ExitNodeTakesPrecedence + } + if !cmd.Flags().Changed("mtu") && org.MTU != nil { + opts.MTU = *org.MTU + } +} + +// applyServerSettings applies what the org's server manages, which beats +// everything else, flags included. +func applyServerSettings(cmd *cobra.Command, opts *ClientUpCmdOpts, settings *config.ClientSettings) { + if settings == nil { + return + } + + warnIfIgnored := func(flag string) { + if cmd.Flags().Changed(flag) { + logger.Warning("Ignoring --%s: this setting is managed by your organization", flag) + } + } + + if settings.TunnelDNS != nil { + warnIfIgnored("tunnel-dns") + opts.TunnelDNS = *settings.TunnelDNS + } + if settings.OverrideDNS != nil { + warnIfIgnored("override-dns") + opts.OverrideDNS = *settings.OverrideDNS + } + if servers := settings.UpstreamDNS(); len(servers) > 0 { + warnIfIgnored("upstream-dns") + opts.UpstreamDNS = make([]string, len(servers)) + for i, server := range servers { + opts.UpstreamDNS[i] = net.JoinHostPort(server, "53") + } + } + if len(settings.MatchDomains) > 0 { + warnIfIgnored("match-domains") + opts.MatchDomains = settings.MatchDomains + } + if settings.PreferLocalRoutes != nil { + warnIfIgnored("prefer-local-routes") + opts.PreferLocalRoutes = *settings.PreferLocalRoutes + } + if settings.ExitNodeTakesPrecedence != nil { + warnIfIgnored("exit-node-takes-precedence") + opts.ExitNodeTakesPrecedence = *settings.ExitNodeTakesPrecedence + } + if settings.MTU != nil { + warnIfIgnored("mtu") + opts.MTU = *settings.MTU + } } func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) error { @@ -434,20 +509,38 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) orgID = activeAccount.OrgID } + // Settings are resolved here, in the process that has the user's session + // and config, and forwarded to the subprocess as flags. Machine clients and + // the subprocess itself have no user session, so no server settings. + orgConfig := cfg.Orgs[orgID] + applyOrgConfig(cmd, opts, orgConfig) + + var serverSettings *config.ClientSettings + if credentialsFromKeyring { + if activeAccount, err := accountStore.ActiveAccount(); err == nil { + serverSettings = utils.RefreshClientSettings(apiClient.GetMyClientSettings, accountStore, activeAccount, orgID) + } + } + applyServerSettings(cmd, opts, serverSettings) + // Same as match-domains: resolved here so it can be forwarded to the // subprocess, which may not have access to the user's config. Verified // against the server when we have a user session, so a deleted exit node - // isn't applied. - if !cmd.Flags().Changed("exit-node-site-ids") { + // isn't applied. An exit node the server locks beats the exit node flags. + exitNodeLocked := serverSettings.LockedExitNode() != 0 + if exitNodeLocked && (opts.ExitNode != "" || cmd.Flags().Changed("exit-node-site-ids")) { + logger.Warning("Ignoring the exit node flags: the exit node is locked by your organization") + } + if !cmd.Flags().Changed("exit-node-site-ids") || exitNodeLocked { var list func(string) ([]api.SiteResource, error) savedResourceID := 0 if credentialsFromKeyring { list = apiClient.ListGatewayResources if activeAccount, err := accountStore.ActiveAccount(); err == nil { - savedResourceID = activeAccount.ExitNode(orgID) + savedResourceID = config.EffectiveExitNode(serverSettings, activeAccount.ExitNode(orgID), orgConfig) } } - if opts.ExitNode != "" { + if opts.ExitNode != "" && !exitNodeLocked { // Overrides the saved exit node for this run only; the saved one // is left as is. var err error @@ -502,9 +595,10 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) cmdArgs = append(cmdArgs, "--endpoint", endpoint) // Optional flags - only include if they were explicitly set - if cmd.Flags().Changed("mtu") { - cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", opts.MTU)) - } + // Always forwarded, as are the other settings below that org config or + // the server can set: the subprocess must not fall back to its own + // config for a value already resolved here. + cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", opts.MTU)) if cmd.Flags().Changed("netstack-dns") { cmdArgs = append(cmdArgs, "--netstack-dns", opts.DNS) } @@ -533,21 +627,9 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) if cmd.Flags().Changed("tls-client-cert") { cmdArgs = append(cmdArgs, "--tls-client-cert", opts.TlsClientCert) } - if cmd.Flags().Changed("override-dns") { - if opts.OverrideDNS { - cmdArgs = append(cmdArgs, "--override-dns") - } else { - cmdArgs = append(cmdArgs, "--override-dns=false") - } - } - if cmd.Flags().Changed("tunnel-dns") { - if opts.TunnelDNS { - cmdArgs = append(cmdArgs, "--tunnel-dns") - } else { - cmdArgs = append(cmdArgs, "--tunnel-dns=false") - } - } - if cmd.Flags().Changed("upstream-dns") { + cmdArgs = append(cmdArgs, fmt.Sprintf("--override-dns=%t", opts.OverrideDNS)) + cmdArgs = append(cmdArgs, fmt.Sprintf("--tunnel-dns=%t", opts.TunnelDNS)) + if len(opts.UpstreamDNS) > 0 { // Comma sep cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(opts.UpstreamDNS, ",")) } @@ -557,16 +639,8 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) // subprocess (running as root) may not have access to that config. cmdArgs = append(cmdArgs, "--match-domains", strings.Join(opts.MatchDomains, ",")) } - if opts.PreferLocalRoutes { - // Always forwarded when true (rather than gated on Changed) for the - // same reason as MatchDomains above - it may have come from config. - cmdArgs = append(cmdArgs, "--prefer-local-routes") - } - if opts.ExitNodeTakesPrecedence { - // Always forwarded when true (rather than gated on Changed) for the - // same reason as MatchDomains above - it may have come from config. - cmdArgs = append(cmdArgs, "--exit-node-takes-precedence") - } + cmdArgs = append(cmdArgs, fmt.Sprintf("--prefer-local-routes=%t", opts.PreferLocalRoutes)) + cmdArgs = append(cmdArgs, fmt.Sprintf("--exit-node-takes-precedence=%t", opts.ExitNodeTakesPrecedence)) if opts.DisableRelay { cmdArgs = append(cmdArgs, "--disable-relay") } diff --git a/docs/pangolin_config.md b/docs/pangolin_config.md index e232889..4ec13c3 100644 --- a/docs/pangolin_config.md +++ b/docs/pangolin_config.md @@ -18,6 +18,7 @@ View and edit persistent CLI configuration without manually editing the config f * [pangolin config get](pangolin_config_get.md) - Get a config value * [pangolin config list](pangolin_config_list.md) - List settable config keys * [pangolin config path](pangolin_config_path.md) - Print the config file path +* [pangolin config reset](pangolin_config_reset.md) - Make an organization use the global config again * [pangolin config set](pangolin_config_set.md) - Set a config value * [pangolin config show](pangolin_config_show.md) - Print the current config contents diff --git a/docs/pangolin_config_get.md b/docs/pangolin_config_get.md index 2fac4ac..1eca0e0 100644 --- a/docs/pangolin_config_get.md +++ b/docs/pangolin_config_get.md @@ -9,7 +9,8 @@ pangolin config get [flags] ### Options ``` - -h, --help help for get + -h, --help help for get + --org string Get the value set for this organization only ``` ### SEE ALSO diff --git a/docs/pangolin_config_reset.md b/docs/pangolin_config_reset.md new file mode 100644 index 0000000..0cf8dba --- /dev/null +++ b/docs/pangolin_config_reset.md @@ -0,0 +1,23 @@ +## pangolin config reset + +Make an organization use the global config again + +### Synopsis + +Remove the values set for an organization (the selected one by default), so it uses the global config again. + +``` +pangolin config reset [flags] +``` + +### Options + +``` + -h, --help help for reset + --org string Organization to reset (default: selected organization) +``` + +### SEE ALSO + +* [pangolin config](pangolin_config.md) - View and edit CLI configuration + diff --git a/docs/pangolin_config_set.md b/docs/pangolin_config_set.md index 5af1965..b0f3e42 100644 --- a/docs/pangolin_config_set.md +++ b/docs/pangolin_config_set.md @@ -6,6 +6,11 @@ Set a config value Set a config value and write it to the config file. +A value can apply to every organization (--global) or to one (--org), which +takes precedence over the global value. Without either flag you're asked when +running interactively with an organization selected; otherwise it's global. +Settings managed by your organization's server take precedence over both. + Supported keys: log_level disable_update_check @@ -16,12 +21,17 @@ Supported keys: up.match_domains_dns up.prefer_local_routes up.exit_node_takes_precedence + up.mtu session_cookie_name +Per-organization only: + up.exit_node_resource_id + Examples: pangolin config set up.tunnel_dns true pangolin config set up.upstream_dns 10.0.0.53 pangolin config set up.upstream_dns 10.0.0.53,10.0.0.54 + pangolin config set up.mtu 1400 --org my-org ``` @@ -31,7 +41,9 @@ pangolin config set [flags] ### Options ``` - -h, --help help for set + --global Set the value for all organizations + -h, --help help for set + --org string Set the value for this organization only ``` ### SEE ALSO diff --git a/internal/api/client.go b/internal/api/client.go index f8ac1c5..1f7b601 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,6 +13,7 @@ import ( "strings" "time" + "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/version" yaml "go.yaml.in/yaml/v3" ) @@ -553,6 +555,22 @@ func (c *Client) GetMyDevice(olmID string) (*MyDeviceResponse, error) { return &response, nil } +// GetMyClientSettings returns the settings the org manages for the current +// user, or nil if it manages none. Servers that predate client settings answer +// 404, which means the same thing. +func (c *Client) GetMyClientSettings(orgID string) (*config.ClientSettings, error) { + path := fmt.Sprintf("/org/%s/my-client-settings", url.PathEscape(orgID)) + var response MyClientSettingsResponse + if err := c.Get(path, &response); err != nil { + var errResp *ErrorResponse + if errors.As(err, &errResp) && errResp.Status == http.StatusNotFound { + return nil, nil + } + return nil, err + } + return response.Settings, nil +} + // TestConnection tests the connection to the API server func (c *Client) TestConnection() (bool, error) { // Create a temporary client with shorter timeout for connection test diff --git a/internal/api/types.go b/internal/api/types.go index e6c8d4b..e5cd7bd 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "github.com/fosrl/cli/internal/config" "strconv" "time" ) @@ -238,6 +239,12 @@ type Olm struct { Blocked *bool `json:"blocked,omitempty"` // Indicates if the OLM is blocked } +// MyClientSettingsResponse represents the response for getting the settings an +// org manages for the current user +type MyClientSettingsResponse struct { + Settings *config.ClientSettings `json:"settings"` +} + // MyDeviceResponse represents the response for getting my device type MyDeviceResponse struct { User MyDeviceUser `json:"user"` diff --git a/internal/config/accounts.go b/internal/config/accounts.go index 0335a54..45ae3a6 100644 --- a/internal/config/accounts.go +++ b/internal/config/accounts.go @@ -39,10 +39,100 @@ type OrgSettings struct { // resource ID is stored (not the niceId, which can be renamed); its sites // are looked up from the server on every start so they can't go stale. ExitNodeResourceID int `mapstructure:"exitNodeResourceId" json:"exitNodeResourceId,omitempty"` + + // The settings the org's server manages for this user, as last fetched + // from it. Refreshed by the server on every connect; only kept here so they + // still apply if the server can't be reached. + ServerSettings *ClientSettings `mapstructure:"serverSettings" json:"serverSettings,omitempty"` } func (s OrgSettings) IsEmpty() bool { - return s.ExitNodeResourceID == 0 + return s.ExitNodeResourceID == 0 && s.ServerSettings == nil +} + +// ClientSettings is the server's GET /org/:orgId/my-client-settings response. +// A nil/empty field isn't managed by the server. +type ClientSettings struct { + OverrideDNS *bool `mapstructure:"overrideDns" json:"overrideDns,omitempty"` + MTU *int `mapstructure:"mtu" json:"mtu,omitempty"` + TunnelDNS *bool `mapstructure:"tunnelDns" json:"tunnelDns,omitempty"` + PrimaryDNS string `mapstructure:"primaryDns" json:"primaryDns,omitempty"` + SecondaryDNS string `mapstructure:"secondaryDns" json:"secondaryDns,omitempty"` + ExitNodeTakesPrecedence *bool `mapstructure:"exitNodeTakesPrecedence" json:"exitNodeTakesPrecedence,omitempty"` + ExitNodeSiteResourceID int `mapstructure:"exitNodeSiteResourceId" json:"exitNodeSiteResourceId,omitempty"` + ExitNodeLocked bool `mapstructure:"exitNodeLocked" json:"exitNodeLocked,omitempty"` + MatchDomains []string `mapstructure:"matchDomains" json:"matchDomains,omitempty"` + PreferLocalRoutes *bool `mapstructure:"preferLocalRoutes" json:"preferLocalRoutes,omitempty"` +} + +// LockedExitNode returns the exit node the server forces, or 0 if it forces +// none. +func (s *ClientSettings) LockedExitNode() int { + if s == nil || !s.ExitNodeLocked { + return 0 + } + return s.ExitNodeSiteResourceID +} + +// UpstreamDNS returns the managed primary and secondary DNS servers, if any. +func (s *ClientSettings) UpstreamDNS() []string { + if s == nil { + return nil + } + var servers []string + for _, server := range []string{s.PrimaryDNS, s.SecondaryDNS} { + if server != "" { + servers = append(servers, server) + } + } + return servers +} + +// ManagesKey reports whether the server manages the given config key (as named +// by ConfigOptions). +func (s *ClientSettings) ManagesKey(key string) bool { + if s == nil { + return false + } + switch key { + case "up.tunnel_dns": + return s.TunnelDNS != nil + case "up.override_dns": + return s.OverrideDNS != nil + case "up.upstream_dns": + return len(s.UpstreamDNS()) > 0 + case "up.match_domains_dns": + return len(s.MatchDomains) > 0 + case "up.prefer_local_routes": + return s.PreferLocalRoutes != nil + case "up.exit_node_takes_precedence": + return s.ExitNodeTakesPrecedence != nil + case "up.mtu": + return s.MTU != nil + case "up.exit_node_resource_id": + return s.LockedExitNode() != 0 + default: + return false + } +} + +// EffectiveExitNode picks the exit node to use for an org: one the server locks +// always wins; next the user's own selection (savedResourceID), then the org's +// entry in the config file, and last the server's default. +func EffectiveExitNode(server *ClientSettings, savedResourceID int, org OrgConfig) int { + if locked := server.LockedExitNode(); locked != 0 { + return locked + } + if savedResourceID != 0 { + return savedResourceID + } + if org.ExitNodeResourceID != nil { + return *org.ExitNodeResourceID + } + if server != nil { + return server.ExitNodeSiteResourceID + } + return 0 } // GetOrgSettings returns the account's settings for orgID, or empty defaults @@ -71,6 +161,19 @@ func (a *Account) UpdateOrgSettings(orgID string, update func(*OrgSettings)) { a.OrgSettings = settings } +// ServerSettings returns the settings last fetched from the server for orgID, +// or nil if it manages none. +func (a *Account) ServerSettings(orgID string) *ClientSettings { + return a.GetOrgSettings(orgID).ServerSettings +} + +// SetServerSettings records the settings fetched from the server for orgID. +func (a *Account) SetServerSettings(orgID string, settings *ClientSettings) { + a.UpdateOrgSettings(orgID, func(s *OrgSettings) { + s.ServerSettings = settings + }) +} + // ExitNode returns the resource ID of the exit node selected for orgID, or 0 // for none. func (a *Account) ExitNode(orgID string) int { diff --git a/internal/config/config.go b/internal/config/config.go index fc206a1..1c6e017 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "os/user" "path/filepath" "runtime" + "slices" + "strconv" "strings" "github.com/fosrl/cli/internal/logger" @@ -28,6 +30,31 @@ type Config struct { // SessionCookieName overrides the cookie name used for the CLI's session // token. Empty means the API client's built-in default is used. SessionCookieName string `mapstructure:"session_cookie_name" json:"session_cookie_name,omitempty"` + + // Orgs holds per-org overrides of Up, keyed by org ID. Precedence when + // connecting to an org: server settings > its entry here > Up. + Orgs map[string]OrgConfig `mapstructure:"orgs" json:"orgs,omitempty"` +} + +// OrgConfig is the per-org version of UpConfig, plus ExitNodeResourceID: an +// exit node only means something within one org, so it has no global +// equivalent. +type OrgConfig struct { + TunnelDNS *bool `mapstructure:"tunnel_dns" json:"tunnel_dns,omitempty"` + UpstreamDNS []string `mapstructure:"upstream_dns" json:"upstream_dns,omitempty"` + OverrideDNS *bool `mapstructure:"override_dns" json:"override_dns,omitempty"` + MatchDomains []string `mapstructure:"match_domains_dns" json:"match_domains_dns,omitempty"` + PreferLocalRoutes *bool `mapstructure:"prefer_local_routes" json:"prefer_local_routes,omitempty"` + ExitNodeTakesPrecedence *bool `mapstructure:"exit_node_takes_precedence" json:"exit_node_takes_precedence,omitempty"` + MTU *int `mapstructure:"mtu" json:"mtu,omitempty"` + ExitNodeResourceID *int `mapstructure:"exit_node_resource_id" json:"exit_node_resource_id,omitempty"` +} + +// IsEmpty reports whether the org overrides nothing. +func (o OrgConfig) IsEmpty() bool { + return o.TunnelDNS == nil && o.UpstreamDNS == nil && o.OverrideDNS == nil && + o.MatchDomains == nil && o.PreferLocalRoutes == nil && + o.ExitNodeTakesPrecedence == nil && o.MTU == nil && o.ExitNodeResourceID == nil } // UpConfig holds persistent defaults for pangolin up DNS-related flags. @@ -59,6 +86,10 @@ type UpConfig struct { // --exit-node-takes-precedence flag when the flag isn't passed // explicitly. Defaults to false. ExitNodeTakesPrecedence *bool `mapstructure:"exit_node_takes_precedence" json:"exit_node_takes_precedence,omitempty"` + + // MTU is the tunnel MTU. Used as the default for `pangolin up`'s --mtu flag + // when the flag isn't passed explicitly. + MTU *int `mapstructure:"mtu" json:"mtu,omitempty"` } // CompanionAppDataDirs holds per-platform overrides for the desktop app data directory. @@ -78,9 +109,28 @@ var ConfigOptions = []string{ "up.match_domains_dns", "up.prefer_local_routes", "up.exit_node_takes_precedence", + "up.mtu", "session_cookie_name", } +// OrgConfigOptions is the registry of keys that can be set per org with +// pangolin config set --org. up.exit_node_resource_id is only valid per org. +var OrgConfigOptions = []string{ + "up.tunnel_dns", + "up.upstream_dns", + "up.override_dns", + "up.match_domains_dns", + "up.prefer_local_routes", + "up.exit_node_takes_precedence", + "up.mtu", + "up.exit_node_resource_id", +} + +// IsOrgConfigKey reports whether key can be set per org. +func IsOrgConfigKey(key string) bool { + return slices.Contains(OrgConfigOptions, key) +} + // SupportedConfigKeys returns the settable config keys. func SupportedConfigKeys() []string { out := make([]string, len(ConfigOptions)) @@ -272,6 +322,15 @@ func (c *Config) SetKey(key, value string) error { } c.Up.ExitNodeTakesPrecedence = &b c.v.Set(key, b) + case "up.mtu": + mtu, err := parseMTU(value) + if err != nil { + return err + } + c.Up.MTU = &mtu + c.v.Set(key, mtu) + case "up.exit_node_resource_id": + return fmt.Errorf("%s can only be set per organization: pass --org", key) case "session_cookie_name": c.SessionCookieName = value c.v.Set(key, value) @@ -281,6 +340,104 @@ func (c *Config) SetKey(key, value string) error { return nil } +// SetOrgKey sets a config key for a single org, overriding its global value. +func (c *Config) SetOrgKey(orgID, key, value string) error { + org := c.Orgs[orgID] + + switch key { + case "up.tunnel_dns", "up.override_dns", "up.prefer_local_routes", "up.exit_node_takes_precedence": + b, err := parseBool(value) + if err != nil { + return err + } + switch key { + case "up.tunnel_dns": + org.TunnelDNS = &b + case "up.override_dns": + org.OverrideDNS = &b + case "up.prefer_local_routes": + org.PreferLocalRoutes = &b + case "up.exit_node_takes_precedence": + org.ExitNodeTakesPrecedence = &b + } + case "up.upstream_dns": + org.UpstreamDNS = splitCommaList(value) + case "up.match_domains_dns": + org.MatchDomains = splitCommaList(value) + case "up.mtu": + mtu, err := parseMTU(value) + if err != nil { + return err + } + org.MTU = &mtu + case "up.exit_node_resource_id": + id, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || id <= 0 { + return fmt.Errorf("invalid exit node resource ID %q", value) + } + org.ExitNodeResourceID = &id + default: + return fmt.Errorf("%q can't be set per organization; supported keys: %s", key, strings.Join(OrgConfigOptions, ", ")) + } + + if c.Orgs == nil { + c.Orgs = map[string]OrgConfig{} + } + c.Orgs[orgID] = org + return nil +} + +// GetOrgKey returns a string representation of the org's own value for key. +func (c *Config) GetOrgKey(orgID, key string) (string, error) { + org := c.Orgs[orgID] + + formatBool := func(b *bool) (string, error) { + if b == nil { + return "", errOrgConfigKeyUnset(orgID, key) + } + return fmt.Sprintf("%t", *b), nil + } + formatInt := func(i *int) (string, error) { + if i == nil { + return "", errOrgConfigKeyUnset(orgID, key) + } + return strconv.Itoa(*i), nil + } + formatList := func(list []string) (string, error) { + if list == nil { + return "", errOrgConfigKeyUnset(orgID, key) + } + return strings.Join(list, ","), nil + } + + switch key { + case "up.tunnel_dns": + return formatBool(org.TunnelDNS) + case "up.override_dns": + return formatBool(org.OverrideDNS) + case "up.prefer_local_routes": + return formatBool(org.PreferLocalRoutes) + case "up.exit_node_takes_precedence": + return formatBool(org.ExitNodeTakesPrecedence) + case "up.upstream_dns": + return formatList(org.UpstreamDNS) + case "up.match_domains_dns": + return formatList(org.MatchDomains) + case "up.mtu": + return formatInt(org.MTU) + case "up.exit_node_resource_id": + return formatInt(org.ExitNodeResourceID) + default: + return "", fmt.Errorf("%q can't be set per organization; supported keys: %s", key, strings.Join(OrgConfigOptions, ", ")) + } +} + +// ResetOrg removes every override for orgID, so it follows the global config +// again. +func (c *Config) ResetOrg(orgID string) { + delete(c.Orgs, orgID) +} + // GetKey returns a string representation of a supported configuration key. func (c *Config) GetKey(key string) (string, error) { switch key { @@ -322,6 +479,11 @@ func (c *Config) GetKey(key string) (string, error) { return "", errConfigKeyUnset(key) } return fmt.Sprintf("%t", c.GetBool(key)), nil + case "up.mtu": + if !c.IsSet(key) { + return "", errConfigKeyUnset(key) + } + return strconv.Itoa(c.v.GetInt(key)), nil case "session_cookie_name": return c.SessionCookieName, nil default: @@ -333,6 +495,18 @@ func errConfigKeyUnset(key string) error { return fmt.Errorf("config key %q is not set", key) } +func errOrgConfigKeyUnset(orgID, key string) error { + return fmt.Errorf("config key %q is not set for organization %q", key, orgID) +} + +func parseMTU(value string) (int, error) { + mtu, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || mtu < 576 || mtu > 65535 { + return 0, fmt.Errorf("invalid MTU %q: must be between 576 and 65535", value) + } + return mtu, nil +} + func parseBool(value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "true", "1", "yes", "on": @@ -386,6 +560,12 @@ func (c *Config) Save() error { if c.Up.ExitNodeTakesPrecedence != nil { c.v.Set("up.exit_node_takes_precedence", *c.Up.ExitNodeTakesPrecedence) } + if c.Up.MTU != nil { + c.v.Set("up.mtu", *c.Up.MTU) + } + if c.Orgs != nil { + c.v.Set("orgs", c.Orgs) + } dir, err := GetPangolinConfigDir() if err != nil { diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go new file mode 100644 index 0000000..112bf83 --- /dev/null +++ b/internal/config/settings_test.go @@ -0,0 +1,170 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func intPtr(i int) *int { return &i } + +func TestEffectiveExitNode(t *testing.T) { + orgOverride := OrgConfig{ExitNodeResourceID: intPtr(30)} + + tests := []struct { + name string + server *ClientSettings + saved int + org OrgConfig + want int + }{ + {"nothing set", nil, 0, OrgConfig{}, 0}, + {"server default only", &ClientSettings{ExitNodeSiteResourceID: 10}, 0, OrgConfig{}, 10}, + {"org config beats server default", &ClientSettings{ExitNodeSiteResourceID: 10}, 0, orgOverride, 30}, + {"user selection beats org config and default", &ClientSettings{ExitNodeSiteResourceID: 10}, 20, orgOverride, 20}, + {"locked beats everything", &ClientSettings{ExitNodeSiteResourceID: 10, ExitNodeLocked: true}, 20, orgOverride, 10}, + {"no server settings", nil, 20, orgOverride, 20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EffectiveExitNode(tt.server, tt.saved, tt.org); got != tt.want { + t.Errorf("EffectiveExitNode() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestManagesKeyNilSettings(t *testing.T) { + var settings *ClientSettings + if settings.ManagesKey("up.mtu") || settings.LockedExitNode() != 0 || settings.UpstreamDNS() != nil { + t.Error("nil settings should manage nothing") + } +} + +func TestSetKeyRejectsOrgOnlyKeyGlobally(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if err := cfg.SetKey("up.exit_node_resource_id", "5"); err == nil { + t.Error("expected the exit node to be rejected as a global setting") + } +} + +func TestOrgConfigRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SUDO_USER", "") + if err := os.MkdirAll(filepath.Join(home, ".config", "pangolin"), 0o755); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if err := cfg.SetKey("up.mtu", "1400"); err != nil { + t.Fatal(err) + } + if err := cfg.SetOrgKey("my-org", "up.mtu", "1300"); err != nil { + t.Fatal(err) + } + if err := cfg.SetOrgKey("my-org", "up.tunnel_dns", "false"); err != nil { + t.Fatal(err) + } + if err := cfg.SetOrgKey("my-org", "up.exit_node_resource_id", "7"); err != nil { + t.Fatal(err) + } + if err := cfg.SetOrgKey("my-org", "up.mtu", "1"); err == nil { + t.Error("expected an out of range MTU to be rejected") + } + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + loaded, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if loaded.Up.MTU == nil || *loaded.Up.MTU != 1400 { + t.Errorf("global MTU = %v, want 1400", loaded.Up.MTU) + } + org := loaded.Orgs["my-org"] + if org.MTU == nil || *org.MTU != 1300 { + t.Errorf("org MTU = %v, want 1300", org.MTU) + } + if org.TunnelDNS == nil || *org.TunnelDNS { + t.Errorf("org tunnel DNS = %v, want an explicit false", org.TunnelDNS) + } + if org.ExitNodeResourceID == nil || *org.ExitNodeResourceID != 7 { + t.Errorf("org exit node = %v, want 7", org.ExitNodeResourceID) + } + + loaded.ResetOrg("my-org") + if err := loaded.Save(); err != nil { + t.Fatal(err) + } + reset, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if !reset.Orgs["my-org"].IsEmpty() { + t.Errorf("org should be empty after a reset, got %+v", reset.Orgs["my-org"]) + } + if reset.Up.MTU == nil || *reset.Up.MTU != 1400 { + t.Errorf("global MTU should survive a reset, got %v", reset.Up.MTU) + } +} + +func TestServerSettingsRoundTripThroughAccountStore(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("SUDO_USER", "") + if err := os.MkdirAll(filepath.Join(home, ".config", "pangolin"), 0o755); err != nil { + t.Fatal(err) + } + + store, err := LoadAccountStore(nil) + if err != nil { + t.Fatal(err) + } + account := Account{UserID: "u1", SessionToken: "t"} + enabled := false + account.SetServerSettings("my-org", &ClientSettings{ + OverrideDNS: &enabled, + MatchDomains: []string{"*.corp"}, + PrimaryDNS: "10.0.0.1", + }) + account.SetExitNode("my-org", 9) + store.ActiveUserID = "u1" + store.Accounts["u1"] = account + if err := store.Save(); err != nil { + t.Fatal(err) + } + + loaded, err := LoadAccountStore(nil) + if err != nil { + t.Fatal(err) + } + active, err := loaded.ActiveAccount() + if err != nil { + t.Fatal(err) + } + settings := active.ServerSettings("my-org") + if settings == nil || settings.OverrideDNS == nil || *settings.OverrideDNS { + t.Fatalf("explicit false should survive, got %+v", settings) + } + if len(settings.MatchDomains) != 1 || settings.PrimaryDNS != "10.0.0.1" || active.ExitNode("my-org") != 9 { + t.Errorf("settings didn't round trip: %+v", settings) + } + + active.SetServerSettings("my-org", nil) + if active.ServerSettings("my-org") != nil { + t.Error("clearing should remove the settings") + } + if active.ExitNode("my-org") != 9 { + t.Error("clearing server settings must keep the user's exit node") + } +} diff --git a/internal/utils/clientsettings.go b/internal/utils/clientsettings.go new file mode 100644 index 0000000..7f88721 --- /dev/null +++ b/internal/utils/clientsettings.go @@ -0,0 +1,31 @@ +package utils + +import ( + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" +) + +// RefreshClientSettings fetches the settings the org manages for the active +// account and records them in the account store. It runs every time the client +// connects and whenever settings are looked at. If the server can't be reached +// the last fetched settings keep applying. +func RefreshClientSettings(fetch func(orgID string) (*config.ClientSettings, error), accountStore *config.AccountStore, account *config.Account, orgID string) *config.ClientSettings { + settings, err := fetch(orgID) + if err != nil { + logger.Warning("Could not fetch client settings from the server (%v); using the last known settings", err) + return account.ServerSettings(orgID) + } + + account.SetServerSettings(orgID, settings) + if accountStore.IsReadOnly() { + return settings + } + err = accountStore.UpdateActiveAccount(account) + if err == nil { + err = accountStore.Save() + } + if err != nil { + logger.Warning("Could not save client settings: %v", err) + } + return settings +} From 809c419f767c300be7404382b1dea0024506bc37 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 2 Nov 2026 14:28:43 -0500 Subject: [PATCH 2/6] Implement companion mode for the linux app --- ...panion_windows.go => companion_desktop.go} | 2 +- cmd/companion/companion_other.go | 2 +- cmd/config/config.go | 17 +- cmd/config/effective.go | 146 +++++++++++++++++ cmd/root.go | 10 +- cmd/up/client/client.go | 10 +- docs/pangolin_config.md | 1 + docs/pangolin_config_effective.md | 34 ++++ go.mod | 4 +- go.sum | 2 - ...nt_windows.go => accounts_hint_desktop.go} | 2 +- internal/companion/accounts_hint_other.go | 2 +- internal/companion/provider_desktop.go | 133 +++++++++++++++ internal/companion/provider_linux.go | 68 ++++++++ internal/companion/provider_other.go | 2 +- internal/companion/provider_windows.go | 121 +------------- internal/companion/secrets_desktop.go | 81 ++++++++++ internal/companion/secrets_linux.go | 63 ++++++++ internal/companion/secrets_windows.go | 71 +------- internal/companion/status.go | 10 +- internal/companion/version.go | 4 + internal/config/config.go | 3 + internal/config/context.go | 7 + internal/config/effective.go | 151 ++++++++++++++++++ internal/config/effective_test.go | 79 +++++++++ internal/notice/notices.go | 14 +- internal/olm/client_linux.go | 11 ++ internal/olm/client_unix.go | 25 ++- internal/olm/client_unix_other.go | 6 + 29 files changed, 868 insertions(+), 213 deletions(-) rename cmd/companion/{companion_windows.go => companion_desktop.go} (99%) create mode 100644 cmd/config/effective.go create mode 100644 docs/pangolin_config_effective.md rename internal/companion/{accounts_hint_windows.go => accounts_hint_desktop.go} (95%) create mode 100644 internal/companion/provider_desktop.go create mode 100644 internal/companion/provider_linux.go create mode 100644 internal/companion/secrets_desktop.go create mode 100644 internal/companion/secrets_linux.go create mode 100644 internal/config/effective.go create mode 100644 internal/config/effective_test.go create mode 100644 internal/olm/client_linux.go create mode 100644 internal/olm/client_unix_other.go diff --git a/cmd/companion/companion_windows.go b/cmd/companion/companion_desktop.go similarity index 99% rename from cmd/companion/companion_windows.go rename to cmd/companion/companion_desktop.go index 90b4b5c..18c7228 100644 --- a/cmd/companion/companion_windows.go +++ b/cmd/companion/companion_desktop.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build windows || linux package companioncmd diff --git a/cmd/companion/companion_other.go b/cmd/companion/companion_other.go index 9ae7322..c49bb45 100644 --- a/cmd/companion/companion_other.go +++ b/cmd/companion/companion_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package companioncmd diff --git a/cmd/config/config.go b/cmd/config/config.go index 49a4f0a..2d6b959 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -28,14 +28,15 @@ func ConfigCmd() *cobra.Command { cmd.AddCommand(configGetCmd()) cmd.AddCommand(configSetCmd()) cmd.AddCommand(configResetCmd()) + cmd.AddCommand(configEffectiveCmd()) return cmd } // activeOrg returns the active account and its selected org, if any. func activeOrg(cmd *cobra.Command) (*config.Account, string) { - accountStore := config.AccountStoreFromContext(cmd.Context()) - if accountStore == nil { + accountStore, ok := config.LookupAccountStore(cmd.Context()) + if !ok || accountStore == nil { return nil, "" } account, err := accountStore.ActiveAccount() @@ -50,8 +51,14 @@ func fetchServerSettings(cmd *cobra.Command, account *config.Account, orgID stri if account == nil || orgID == "" { return nil } - accountStore := config.AccountStoreFromContext(cmd.Context()) - apiClient := api.FromContext(cmd.Context()) + accountStore, ok := config.LookupAccountStore(cmd.Context()) + if !ok { + return nil + } + apiClient, ok := api.ClientFromContext(cmd.Context()) + if !ok { + return nil + } return utils.RefreshClientSettings(apiClient.GetMyClientSettings, accountStore, account, orgID) } @@ -122,7 +129,7 @@ func settingsModeMessage(cfg *config.Config, orgID string, settings *config.Clie mode = "has its own config" } if settings != nil { - return fmt.Sprintf("Organization %s %s; some settings are managed by its server", orgID, mode) + return fmt.Sprintf("Organization %s %s; some settings are managed by its server (see `pangolin config effective`)", orgID, mode) } return fmt.Sprintf("Organization %s %s", orgID, mode) } diff --git a/cmd/config/effective.go b/cmd/config/effective.go new file mode 100644 index 0000000..da71ab8 --- /dev/null +++ b/cmd/config/effective.go @@ -0,0 +1,146 @@ +package configcmd + +import ( + "encoding/json" + "fmt" + "io" + "text/tabwriter" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/utils" + "github.com/spf13/cobra" +) + +// Where the server settings shown came from. +const ( + serverSettingsFetched = "fetched" // from the server just now + serverSettingsLastFetched = "last_fetched" // fetch failed; as last fetched + serverSettingsNone = "none" // not logged in +) + +func configEffectiveCmd() *cobra.Command { + var orgID string + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "effective", + Short: "Show the settings that apply to an organization and where each comes from", + Long: `Show, for each per-organization setting, the global value, the organization's +own value, the value its server manages, and the value that applies when +connecting with ` + "`pangolin up`" + `. + +The value that applies is the first set of: the server's, the organization's, +the global one, the default. The exit node is the exception: one the server +locks wins, then the one picked with ` + "`pangolin select exit-node`" + `, then the +organization's, then the server's default. + +Flags passed to ` + "`pangolin up`" + ` override global and organization values, +but not ones the server manages.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.ConfigFromContext(cmd.Context()) + + account, activeOrgID := activeOrg(cmd) + if orgID == "" { + orgID = activeOrgID + } + if orgID == "" { + return fmt.Errorf("no organization selected: pass --org") + } + + var server *config.ClientSettings + status := serverSettingsNone + selectedExitNode := 0 + if account != nil { + selectedExitNode = account.ExitNode(orgID) + server, status = loadServerSettings(cmd, account, orgID) + } + + settings := config.EffectiveSettings(cfg, orgID, server, selectedExitNode) + if jsonOutput { + return printEffectiveJSON(cmd.OutOrStdout(), orgID, status, settings) + } + printEffectiveTable(cmd.OutOrStdout(), orgID, status, settings) + return nil + }, + } + + cmd.Flags().StringVar(&orgID, "org", "", "Organization to show (default: selected organization)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Print as JSON") + return cmd +} + +// loadServerSettings fetches the settings the org's server manages, falling +// back to the ones it last sent. +func loadServerSettings(cmd *cobra.Command, account *config.Account, orgID string) (*config.ClientSettings, string) { + accountStore, ok := config.LookupAccountStore(cmd.Context()) + if !ok { + return account.ServerSettings(orgID), serverSettingsLastFetched + } + apiClient, ok := api.ClientFromContext(cmd.Context()) + if !ok { + return account.ServerSettings(orgID), serverSettingsLastFetched + } + + var fetchErr error + fetch := func(orgID string) (*config.ClientSettings, error) { + settings, err := apiClient.GetMyClientSettings(orgID) + fetchErr = err + return settings, err + } + settings := utils.RefreshClientSettings(fetch, accountStore, account, orgID) + if fetchErr != nil { + return settings, serverSettingsLastFetched + } + return settings, serverSettingsFetched +} + +func printEffectiveTable(w io.Writer, orgID, status string, settings []config.EffectiveSetting) { + fmt.Fprintf(w, "Organization: %s\n", orgID) + switch status { + case serverSettingsFetched: + fmt.Fprintln(w, "Server settings: fetched from the server just now") + case serverSettingsLastFetched: + fmt.Fprintln(w, "Server settings: could not fetch them, showing the ones last fetched") + default: + fmt.Fprintln(w, "Server settings: none (not logged in)") + } + fmt.Fprintln(w) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "KEY\tGLOBAL\tORG\tSERVER\tEFFECTIVE\tSOURCE") + for _, s := range settings { + global := orDash(s.Global) + if s.Key == "up.exit_node_resource_id" { + global = "n/a" + } + effective := s.Effective + if effective == "" { + effective = "none" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + s.Key, global, orDash(s.Org), orDash(s.Server), effective, s.Source) + } + tw.Flush() +} + +func printEffectiveJSON(w io.Writer, orgID, status string, settings []config.EffectiveSetting) error { + data, err := json.MarshalIndent(struct { + OrgID string `json:"orgId"` + ServerSettings string `json:"serverSettings"` + Settings []config.EffectiveSetting `json:"settings"` + }{orgID, status, settings}, "", " ") + if err != nil { + return err + } + fmt.Fprintln(w, string(data)) + return nil +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/cmd/root.go b/cmd/root.go index 6c2e8ab..9e3ee8e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -205,7 +205,15 @@ func mainCommandPreRun(cmd *cobra.Command, args []string) error { logger.Debug("Failed to show pending notices: %v", err) } - if commandNeedsAuthInit(cmd) { + if commandHasAncestor(cmd, "config") { + // Config commands must work without an account (or with the desktop + // app not running), but use the active org when there is one. + if ctx, err := initAuthContext(cmd.Context(), cfg); err == nil { + cmd.SetContext(ctx) + } else { + logger.Debug("Continuing without account for %q: %v", cmd.Name(), err) + } + } else if commandNeedsAuthInit(cmd) { ctx, err := initAuthContext(cmd.Context(), cfg) if err != nil { return err diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index df5a4cc..c246cf7 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -181,7 +181,7 @@ logs, and removes the service again when you press Ctrl+C.`, // Optional flags cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization if logged in)") cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Client endpoint (required if not logged in)") - cmd.Flags().IntVar(&opts.MTU, "mtu", 1280, "Maximum transmission unit") + cmd.Flags().IntVar(&opts.MTU, "mtu", config.DefaultMTU, "Maximum transmission unit") cmd.Flags().StringVar(&opts.DNS, "netstack-dns", "", "DNS `server` to use for Netstack. This handles DNS resolution outside of the upstream servers.") cmd.Flags().StringVar(&opts.InterfaceName, "interface-name", "pangolin", "Interface `name`") cmd.Flags().StringVar(&opts.LogLevel, "log-level", "info", "Log level") @@ -190,12 +190,12 @@ logs, and removes the service again when you press Ctrl+C.`, cmd.Flags().DurationVar(&opts.PingTimeout, "ping-timeout", 5*time.Second, "Ping `timeout`") cmd.Flags().BoolVar(&opts.Holepunch, "holepunch", true, "Enable holepunching") cmd.Flags().StringVar(&opts.TlsClientCert, "tls-client-cert", "", "TLS client certificate `path`") - cmd.Flags().BoolVar(&opts.OverrideDNS, "override-dns", true, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server.") - cmd.Flags().BoolVar(&opts.TunnelDNS, "tunnel-dns", false, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server.") + cmd.Flags().BoolVar(&opts.OverrideDNS, "override-dns", config.DefaultOverrideDNS, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server.") + cmd.Flags().BoolVar(&opts.TunnelDNS, "tunnel-dns", config.DefaultTunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server.") cmd.Flags().StringSliceVar(&opts.UpstreamDNS, "upstream-dns", []string{}, "List of DNS servers to use for external DNS resolution if overriding system DNS") cmd.Flags().StringSliceVar(&opts.MatchDomains, "match-domains", nil, "FQDN wildcard patterns (e.g. '*.proxy.internal') to check against local records/upstream DNS; queries for non-matching domains go directly to the system's DNS servers (default: match all domains, or the value from config if set)") - cmd.Flags().BoolVar(&opts.PreferLocalRoutes, "prefer-local-routes", false, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)") - cmd.Flags().BoolVar(&opts.ExitNodeTakesPrecedence, "exit-node-takes-precedence", false, "Do not add routes or resolve aliases for individual resources, so all traffic is sent through the exit node instead of directly to resources (default false)") + cmd.Flags().BoolVar(&opts.PreferLocalRoutes, "prefer-local-routes", config.DefaultPreferLocalRoutes, "Add tunnel routes with a high metric so overlapping local/connected routes take precedence (default false)") + cmd.Flags().BoolVar(&opts.ExitNodeTakesPrecedence, "exit-node-takes-precedence", config.DefaultExitNodeTakesPrecedence, "Do not add routes or resolve aliases for individual resources, so all traffic is sent through the exit node instead of directly to resources (default false)") cmd.Flags().BoolVar(&opts.DisableRelay, "disable-relay", false, "Disable relay connections (default false)") cmd.Flags().BoolVar(&opts.SubnetRouter, "subnet-router", false, "Enable this client to act as a subnet router: traffic forwarded from the local network is NATed to this client's own tunnel IP before going out over the tunnel. Linux only, requires CAP_NET_ADMIN. (default false)") cmd.Flags().StringVar(&opts.ExitNode, "exit-node", "", "Exit node `NICE-ID` to route all traffic through for this connection, overriding the exit node saved by 'pangolin select exit-node'. Requires being logged in") diff --git a/docs/pangolin_config.md b/docs/pangolin_config.md index 4ec13c3..2cd0ef8 100644 --- a/docs/pangolin_config.md +++ b/docs/pangolin_config.md @@ -15,6 +15,7 @@ View and edit persistent CLI configuration without manually editing the config f ### SEE ALSO * [pangolin](pangolin.md) - Pangolin CLI +* [pangolin config effective](pangolin_config_effective.md) - Show the settings that apply to an organization and where each comes from * [pangolin config get](pangolin_config_get.md) - Get a config value * [pangolin config list](pangolin_config_list.md) - List settable config keys * [pangolin config path](pangolin_config_path.md) - Print the config file path diff --git a/docs/pangolin_config_effective.md b/docs/pangolin_config_effective.md new file mode 100644 index 0000000..062d3c9 --- /dev/null +++ b/docs/pangolin_config_effective.md @@ -0,0 +1,34 @@ +## pangolin config effective + +Show the settings that apply to an organization and where each comes from + +### Synopsis + +Show, for each per-organization setting, the global value, the organization's +own value, the value its server manages, and the value that applies when +connecting with `pangolin up`. + +The value that applies is the first set of: the server's, the organization's, +the global one, the default. The exit node is the exception: one the server +locks wins, then the one picked with `pangolin select exit-node`, then the +organization's, then the server's default. + +Flags passed to `pangolin up` override global and organization values, +but not ones the server manages. + +``` +pangolin config effective [flags] +``` + +### Options + +``` + -h, --help help for effective + --json Print as JSON + --org string Organization to show (default: selected organization) +``` + +### SEE ALSO + +* [pangolin config](pangolin_config.md) - View and edit CLI configuration + diff --git a/go.mod b/go.mod index a50eb73..351952a 100644 --- a/go.mod +++ b/go.mod @@ -132,6 +132,6 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -// replace github.com/fosrl/olm => ../olm +replace github.com/fosrl/olm => ../olm -// replace github.com/fosrl/newt => ../newt +replace github.com/fosrl/newt => ../newt diff --git a/go.sum b/go.sum index 4fbe8cd..53b3b97 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,6 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fosrl/newt v1.19.1 h1:Pz5EoUOYmTgZaxldtj7YUoZ9OlpBbtD6K7UKFNZoIxU= github.com/fosrl/newt v1.19.1/go.mod h1:2chBJ4TyLOmRqligDe3M5HXxpIWh1MWP3JS1fHSIMSM= -github.com/fosrl/olm v1.10.2 h1:pj72Fu+b+fzydOv4QZRi+CpeM/iLKL+5jyrk1+oqlVY= -github.com/fosrl/olm v1.10.2/go.mod h1:EBZ9lHq5cbHxIP3J9WyL3w8jlkso8qWKI9vIalOMdWE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= diff --git a/internal/companion/accounts_hint_windows.go b/internal/companion/accounts_hint_desktop.go similarity index 95% rename from internal/companion/accounts_hint_windows.go rename to internal/companion/accounts_hint_desktop.go index b95761c..93ed2f4 100644 --- a/internal/companion/accounts_hint_windows.go +++ b/internal/companion/accounts_hint_desktop.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build windows || linux package companion diff --git a/internal/companion/accounts_hint_other.go b/internal/companion/accounts_hint_other.go index 5822478..ba8bb76 100644 --- a/internal/companion/accounts_hint_other.go +++ b/internal/companion/accounts_hint_other.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package companion diff --git a/internal/companion/provider_desktop.go b/internal/companion/provider_desktop.go new file mode 100644 index 0000000..a1e1715 --- /dev/null +++ b/internal/companion/provider_desktop.go @@ -0,0 +1,133 @@ +//go:build windows || linux + +package companion + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" +) + +const accountsFileName = "accounts.json" + +// desktopProvider reads the Pangolin desktop client's session, on Windows and +// Linux alike: account details from its accounts.json, and secrets from its +// manager service, which only the manager can read. +type desktopProvider struct { + secrets SecretsClient +} + +type desktopAccountsFile struct { + ActiveUserID string `json:"activeUserId"` + Accounts map[string]desktopAccount `json:"accounts"` +} + +type desktopAccount struct { + UserID string `json:"userId"` + Email string `json:"email"` + OrgID string `json:"orgId"` + Username string `json:"username"` + Name string `json:"name"` + Hostname string `json:"hostname"` +} + +func (desktopProvider) Name() string { + return olm.CompanionAgentName +} + +func (desktopProvider) DefaultDataDir() string { + return defaultDesktopDataDir() +} + +func (p desktopProvider) LoadSession(dataDir string) (*Session, error) { + if dataDir == "" { + return nil, nil + } + + accountsPath := filepath.Join(dataDir, accountsFileName) + data, err := os.ReadFile(accountsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var file desktopAccountsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + if file.Accounts == nil { + return nil, nil + } + + accounts := make(map[string]config.Account, len(file.Accounts)) + for userID, appAccount := range file.Accounts { + secrets, err := p.secrets.GetUserSecrets(userID) + if err != nil { + logger.Debug("companion: failed to load secrets for %s: %v", userID, err) + continue + } + if secrets.SessionToken == "" { + continue + } + + account := mapDesktopAccount(appAccount, secrets) + accounts[userID] = account + } + + if len(accounts) == 0 { + return nil, nil + } + + activeUserID := file.ActiveUserID + if activeUserID != "" { + if _, ok := accounts[activeUserID]; !ok { + activeUserID = "" + } + } + if activeUserID == "" { + for userID := range accounts { + activeUserID = userID + break + } + } + + session := &Session{ + ActiveUserID: activeUserID, + Accounts: accounts, + } + if !session.Active() { + return nil, nil + } + return session, nil +} + +func mapDesktopAccount(app desktopAccount, secrets UserSecrets) config.Account { + account := config.Account{ + UserID: app.UserID, + Host: app.Hostname, + Email: app.Email, + OrgID: app.OrgID, + SessionToken: secrets.SessionToken, + } + if app.Username != "" { + username := app.Username + account.Username = &username + } + if app.Name != "" { + name := app.Name + account.Name = &name + } + if secrets.OlmID != "" || secrets.OlmSecret != "" { + account.OlmCredentials = &config.OlmCredentials{ + ID: secrets.OlmID, + Secret: secrets.OlmSecret, + } + } + return account +} diff --git a/internal/companion/provider_linux.go b/internal/companion/provider_linux.go new file mode 100644 index 0000000..89d8434 --- /dev/null +++ b/internal/companion/provider_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package companion + +import ( + "os" + "os/exec" + "os/user" + "path/filepath" +) + +// The Pangolin Linux client keeps its per-user files under +// $XDG_CONFIG_HOME/pangolin-client (normally ~/.config/pangolin-client), apart +// from the CLI's ~/.config/pangolin. +const linuxAppDirName = "pangolin-client" + +// linuxAppBinary is the desktop client's executable, installed by its +// deb, rpm and Arch packages. +const linuxAppBinary = "pangolin-client" + +func defaultDesktopDataDir() string { + if u := sudoUser(); u != nil { + return filepath.Join(u.HomeDir, ".config", linuxAppDirName) + } + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, linuxAppDirName) + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".config", linuxAppDirName) + } + return "" +} + +// platformProvider offers companion mode only when the desktop client is +// installed. Most Linux CLI installs have no desktop client (servers, other +// desktops), and turning companion mode on there would lock them out of +// their own CLI logins. +func platformProvider() Provider { + if !desktopAppInstalled() { + return nil + } + return desktopProvider{secrets: newSocketSecretsClient()} +} + +func desktopAppInstalled() bool { + if _, err := os.Stat(linuxDaemonSocketPath); err == nil { + return true + } + _, err := exec.LookPath(linuxAppBinary) + return err == nil +} + +// sudoUser returns the user who ran sudo, when this process is root under +// sudo, so the CLI uses that user's desktop session rather than root's. +func sudoUser() *user.User { + if os.Geteuid() != 0 { + return nil + } + uid := os.Getenv("SUDO_UID") + if uid == "" { + return nil + } + u, err := user.LookupId(uid) + if err != nil { + return nil + } + return u +} diff --git a/internal/companion/provider_other.go b/internal/companion/provider_other.go index 8acba8d..50d0672 100644 --- a/internal/companion/provider_other.go +++ b/internal/companion/provider_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package companion diff --git a/internal/companion/provider_windows.go b/internal/companion/provider_windows.go index 3ab193f..31db43a 100644 --- a/internal/companion/provider_windows.go +++ b/internal/companion/provider_windows.go @@ -3,41 +3,13 @@ package companion import ( - "encoding/json" "os" "path/filepath" - - "github.com/fosrl/cli/internal/config" - "github.com/fosrl/cli/internal/logger" - "github.com/fosrl/cli/internal/olm" ) const windowsAppName = "Pangolin" -const accountsFileName = "accounts.json" - -type windowsProvider struct { - secrets SecretsClient -} - -type windowsAccountsFile struct { - ActiveUserID string `json:"activeUserId"` - Accounts map[string]windowsAccount `json:"accounts"` -} - -type windowsAccount struct { - UserID string `json:"userId"` - Email string `json:"email"` - OrgID string `json:"orgId"` - Username string `json:"username"` - Name string `json:"name"` - Hostname string `json:"hostname"` -} - -func (windowsProvider) Name() string { - return olm.CompanionAgentName -} -func (windowsProvider) DefaultDataDir() string { +func defaultDesktopDataDir() string { appData := os.Getenv("LOCALAPPDATA") if appData == "" { appData = os.Getenv("APPDATA") @@ -45,95 +17,6 @@ func (windowsProvider) DefaultDataDir() string { return filepath.Join(appData, windowsAppName) } -func (p windowsProvider) LoadSession(dataDir string) (*Session, error) { - if dataDir == "" { - return nil, nil - } - - accountsPath := filepath.Join(dataDir, accountsFileName) - data, err := os.ReadFile(accountsPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var file windowsAccountsFile - if err := json.Unmarshal(data, &file); err != nil { - return nil, err - } - if file.Accounts == nil { - return nil, nil - } - - accounts := make(map[string]config.Account, len(file.Accounts)) - for userID, winAccount := range file.Accounts { - secrets, err := p.secrets.GetUserSecrets(userID) - if err != nil { - logger.Debug("companion: failed to load secrets for %s: %v", userID, err) - continue - } - if secrets.SessionToken == "" { - continue - } - - account := mapWindowsAccount(winAccount, secrets) - accounts[userID] = account - } - - if len(accounts) == 0 { - return nil, nil - } - - activeUserID := file.ActiveUserID - if activeUserID != "" { - if _, ok := accounts[activeUserID]; !ok { - activeUserID = "" - } - } - if activeUserID == "" { - for userID := range accounts { - activeUserID = userID - break - } - } - - session := &Session{ - ActiveUserID: activeUserID, - Accounts: accounts, - } - if !session.Active() { - return nil, nil - } - return session, nil -} - -func mapWindowsAccount(win windowsAccount, secrets UserSecrets) config.Account { - account := config.Account{ - UserID: win.UserID, - Host: win.Hostname, - Email: win.Email, - OrgID: win.OrgID, - SessionToken: secrets.SessionToken, - } - if win.Username != "" { - username := win.Username - account.Username = &username - } - if win.Name != "" { - name := win.Name - account.Name = &name - } - if secrets.OlmID != "" || secrets.OlmSecret != "" { - account.OlmCredentials = &config.OlmCredentials{ - ID: secrets.OlmID, - Secret: secrets.OlmSecret, - } - } - return account -} - func platformProvider() Provider { - return windowsProvider{secrets: newPipeSecretsClient()} + return desktopProvider{secrets: newPipeSecretsClient()} } diff --git a/internal/companion/secrets_desktop.go b/internal/companion/secrets_desktop.go new file mode 100644 index 0000000..6acc308 --- /dev/null +++ b/internal/companion/secrets_desktop.go @@ -0,0 +1,81 @@ +//go:build windows || linux + +package companion + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +const ( + secretsStatusOK uint32 = 0 + secretsStatusNotFound uint32 = 1 + secretsStatusError uint32 = 2 +) + +// UserSecrets matches the desktop client manager's secret store JSON shape. +type UserSecrets struct { + SessionToken string `json:"sessionToken,omitempty"` + OlmID string `json:"olmId,omitempty"` + OlmSecret string `json:"olmSecret,omitempty"` +} + +// SecretsClient fetches user secrets from the desktop client's manager service. +type SecretsClient interface { + GetUserSecrets(userID string) (UserSecrets, error) +} + +// requestSecrets sends a secrets request for userID on a connection to the +// manager and reads the reply. The framing is the same on every platform. +func requestSecrets(conn io.ReadWriter, userID string) (UserSecrets, error) { + userIDBytes := []byte(userID) + if err := binary.Write(conn, binary.LittleEndian, uint32(len(userIDBytes))); err != nil { + return UserSecrets{}, fmt.Errorf("write user id length: %w", err) + } + if _, err := conn.Write(userIDBytes); err != nil { + return UserSecrets{}, fmt.Errorf("write user id: %w", err) + } + + var status uint32 + if err := binary.Read(conn, binary.LittleEndian, &status); err != nil { + return UserSecrets{}, fmt.Errorf("read secrets status: %w", err) + } + + payloadLen, err := readUint32(conn) + if err != nil { + return UserSecrets{}, err + } + if payloadLen == 0 { + return UserSecrets{}, fmt.Errorf("empty secrets response payload") + } + + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(conn, payload); err != nil { + return UserSecrets{}, fmt.Errorf("read secrets payload: %w", err) + } + + switch status { + case secretsStatusOK: + var secrets UserSecrets + if err := json.Unmarshal(payload, &secrets); err != nil { + return UserSecrets{}, fmt.Errorf("decode secrets json: %w", err) + } + return secrets, nil + case secretsStatusNotFound: + return UserSecrets{}, nil + case secretsStatusError: + return UserSecrets{}, fmt.Errorf("%s", string(payload)) + default: + return UserSecrets{}, fmt.Errorf("unexpected secrets status: %d", status) + } +} + +func readUint32(r io.Reader) (uint32, error) { + var value uint32 + if err := binary.Read(r, binary.LittleEndian, &value); err != nil { + return 0, fmt.Errorf("read uint32: %w", err) + } + return value, nil +} diff --git a/internal/companion/secrets_linux.go b/internal/companion/secrets_linux.go new file mode 100644 index 0000000..27fa78d --- /dev/null +++ b/internal/companion/secrets_linux.go @@ -0,0 +1,63 @@ +//go:build linux + +package companion + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strconv" + "time" +) + +// linuxDaemonSocketPath is the Pangolin Linux client daemon's socket. +const linuxDaemonSocketPath = "/run/pangolin-client/manager.sock" + +// daemonRequestCLISecrets is the request byte the daemon answers with a +// user's secrets. It is followed by the UID whose secrets are wanted: the +// daemon checks it against the caller's, except for root. +const daemonRequestCLISecrets byte = 'S' + +type socketSecretsClient struct { + socketPath string +} + +func newSocketSecretsClient() SecretsClient { + return &socketSecretsClient{socketPath: linuxDaemonSocketPath} +} + +func (c *socketSecretsClient) GetUserSecrets(userID string) (UserSecrets, error) { + uid, err := secretsUID() + if err != nil { + return UserSecrets{}, err + } + + conn, err := net.DialTimeout("unix", c.socketPath, 3*time.Second) + if err != nil { + return UserSecrets{}, fmt.Errorf("connect to the Pangolin client daemon: %w", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + if _, err := conn.Write([]byte{daemonRequestCLISecrets}); err != nil { + return UserSecrets{}, fmt.Errorf("write request: %w", err) + } + if err := binary.Write(conn, binary.LittleEndian, uid); err != nil { + return UserSecrets{}, fmt.Errorf("write uid: %w", err) + } + return requestSecrets(conn, userID) +} + +// secretsUID is the user whose secrets to ask for: the user who ran sudo +// when this is root under sudo, otherwise the current user. +func secretsUID() (uint32, error) { + if u := sudoUser(); u != nil { + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid SUDO_UID %q", u.Uid) + } + return uint32(uid), nil + } + return uint32(os.Getuid()), nil +} diff --git a/internal/companion/secrets_windows.go b/internal/companion/secrets_windows.go index 3a985df..bcc9122 100644 --- a/internal/companion/secrets_windows.go +++ b/internal/companion/secrets_windows.go @@ -3,10 +3,7 @@ package companion import ( - "encoding/binary" - "encoding/json" "fmt" - "io" "time" "github.com/Microsoft/go-winio" @@ -14,24 +11,6 @@ import ( const cliSecretsPipePath = `\\.\pipe\pangolin-manager-cli-secrets` -const ( - secretsStatusOK uint32 = 0 - secretsStatusNotFound uint32 = 1 - secretsStatusError uint32 = 2 -) - -// UserSecrets matches the Windows manager secret store JSON shape. -type UserSecrets struct { - SessionToken string `json:"sessionToken,omitempty"` - OlmID string `json:"olmId,omitempty"` - OlmSecret string `json:"olmSecret,omitempty"` -} - -// SecretsClient fetches user secrets from the Pangolin Manager service. -type SecretsClient interface { - GetUserSecrets(userID string) (UserSecrets, error) -} - type pipeSecretsClient struct { pipePath string } @@ -47,53 +26,5 @@ func (c *pipeSecretsClient) GetUserSecrets(userID string) (UserSecrets, error) { return UserSecrets{}, fmt.Errorf("connect to manager secrets pipe: %w", err) } defer conn.Close() - - userIDBytes := []byte(userID) - if err := binary.Write(conn, binary.LittleEndian, uint32(len(userIDBytes))); err != nil { - return UserSecrets{}, fmt.Errorf("write user id length: %w", err) - } - if _, err := conn.Write(userIDBytes); err != nil { - return UserSecrets{}, fmt.Errorf("write user id: %w", err) - } - - var status uint32 - if err := binary.Read(conn, binary.LittleEndian, &status); err != nil { - return UserSecrets{}, fmt.Errorf("read secrets status: %w", err) - } - - payloadLen, err := readUint32(conn) - if err != nil { - return UserSecrets{}, err - } - if payloadLen == 0 { - return UserSecrets{}, fmt.Errorf("empty secrets response payload") - } - - payload := make([]byte, payloadLen) - if _, err := io.ReadFull(conn, payload); err != nil { - return UserSecrets{}, fmt.Errorf("read secrets payload: %w", err) - } - - switch status { - case secretsStatusOK: - var secrets UserSecrets - if err := json.Unmarshal(payload, &secrets); err != nil { - return UserSecrets{}, fmt.Errorf("decode secrets json: %w", err) - } - return secrets, nil - case secretsStatusNotFound: - return UserSecrets{}, nil - case secretsStatusError: - return UserSecrets{}, fmt.Errorf("%s", string(payload)) - default: - return UserSecrets{}, fmt.Errorf("unexpected secrets status: %d", status) - } -} - -func readUint32(r io.Reader) (uint32, error) { - var value uint32 - if err := binary.Read(r, binary.LittleEndian, &value); err != nil { - return 0, fmt.Errorf("read uint32: %w", err) - } - return value, nil + return requestSecrets(conn, userID) } diff --git a/internal/companion/status.go b/internal/companion/status.go index 28a9a9a..3d58665 100644 --- a/internal/companion/status.go +++ b/internal/companion/status.go @@ -2,6 +2,7 @@ package companion import ( "fmt" + "runtime" "github.com/fosrl/cli/internal/config" ) @@ -30,7 +31,7 @@ func EvaluateStatus(cfg *config.Config) StatusReport { if provider == nil { return StatusReport{ ConfigState: statusUnavailable, - ClientName: "not supported on this platform", + ClientName: unavailableClientName(), } } @@ -54,3 +55,10 @@ func EvaluateStatus(cfg *config.Config) StatusReport { report.Suggestion = notReadySuggestion(provider, dataDir, session) return report } + +func unavailableClientName() string { + if runtime.GOOS == "linux" { + return "Pangolin Linux client is not installed" + } + return "not supported on this platform" +} diff --git a/internal/companion/version.go b/internal/companion/version.go index 48493b4..d24d69b 100644 --- a/internal/companion/version.go +++ b/internal/companion/version.go @@ -5,6 +5,8 @@ import "runtime" const ( windowsMinDesktopAppVersion = "0.11.0" darwinMinDesktopAppVersion = "" + // Every Pangolin Linux client release supports companion mode. + linuxMinDesktopAppVersion = "" ) // RequiredDesktopAppVersion returns the minimum desktop app version required for companion mode on this platform. @@ -14,6 +16,8 @@ func RequiredDesktopAppVersion() string { return windowsMinDesktopAppVersion case "darwin": return darwinMinDesktopAppVersion + case "linux": + return linuxMinDesktopAppVersion default: return "" } diff --git a/internal/config/config.go b/internal/config/config.go index 1c6e017..4eb5428 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -96,6 +96,7 @@ type UpConfig struct { type CompanionAppDataDirs struct { Windows string `mapstructure:"windows" json:"windows,omitempty"` Darwin string `mapstructure:"darwin" json:"darwin,omitempty"` + Linux string `mapstructure:"linux" json:"linux,omitempty"` } // ConfigOptions is the registry of keys supported by pangolin config get/set/list. @@ -149,6 +150,8 @@ func companionAppDataDirForGOOS(c *Config, goos string) string { return c.CompanionAppDataDirs.Windows case "darwin": return c.CompanionAppDataDirs.Darwin + case "linux": + return c.CompanionAppDataDirs.Linux default: return "" } diff --git a/internal/config/context.go b/internal/config/context.go index 0b4befb..ac6235c 100644 --- a/internal/config/context.go +++ b/internal/config/context.go @@ -14,6 +14,13 @@ func WithAccountStore(ctx context.Context, store *AccountStore) context.Context return context.WithValue(ctx, accountStoreCtxKey, store) } +// LookupAccountStore returns the account store if one was set up, for +// commands that work without an account. +func LookupAccountStore(ctx context.Context) (*AccountStore, bool) { + store, ok := ctx.Value(accountStoreCtxKey).(*AccountStore) + return store, ok +} + func AccountStoreFromContext(ctx context.Context) *AccountStore { logger, ok := ctx.Value(accountStoreCtxKey).(*AccountStore) if !ok { diff --git a/internal/config/effective.go b/internal/config/effective.go new file mode 100644 index 0000000..6b7f2a7 --- /dev/null +++ b/internal/config/effective.go @@ -0,0 +1,151 @@ +package config + +import ( + "strconv" + "strings" +) + +// Defaults `pangolin up` uses for a setting nothing else sets. They're the +// defaults of its flags. +const ( + DefaultTunnelDNS = false + DefaultOverrideDNS = true + DefaultPreferLocalRoutes = false + DefaultExitNodeTakesPrecedence = false + DefaultMTU = 1280 +) + +// SettingSource says where a setting's effective value comes from. +type SettingSource string + +const ( + SourceDefault SettingSource = "default" + SourceGlobal SettingSource = "global" + SourceOrg SettingSource = "org" + SourceServer SettingSource = "server" + SourceSelected SettingSource = "selected" // exit node chosen with `pangolin select exit-node` +) + +// EffectiveSetting is one setting as each source has it and the value that +// applies. An empty Global, Org or Server means that source doesn't set it. +type EffectiveSetting struct { + Key string `json:"key"` + Global string `json:"global,omitempty"` + Org string `json:"org,omitempty"` + Server string `json:"server,omitempty"` + Effective string `json:"effective"` + Source SettingSource `json:"source"` +} + +// EffectiveSettings resolves every per-org setting for orgID the way +// `pangolin up` does without flags: server settings > the org's entry in the +// config file > the global config > defaults. The exit node is the exception; +// see EffectiveExitNode. selectedExitNode is the exit node saved by +// `pangolin select exit-node`, or 0. +func EffectiveSettings(cfg *Config, orgID string, server *ClientSettings, selectedExitNode int) []EffectiveSetting { + settings := make([]EffectiveSetting, 0, len(OrgConfigOptions)) + for _, key := range OrgConfigOptions { + s := EffectiveSetting{Key: key} + if key != "up.exit_node_resource_id" { + s.Global, _ = cfg.GetKey(key) + } + s.Org, _ = cfg.GetOrgKey(orgID, key) + s.Server = serverValue(server, key) + + switch { + case key == "up.exit_node_resource_id": + s.Effective, s.Source = effectiveExitNode(server, selectedExitNode, cfg.Orgs[orgID]) + case server.ManagesKey(key): + s.Effective, s.Source = s.Server, SourceServer + case s.Org != "": + s.Effective, s.Source = s.Org, SourceOrg + case s.Global != "": + s.Effective, s.Source = s.Global, SourceGlobal + default: + s.Effective, s.Source = defaultValue(key), SourceDefault + } + settings = append(settings, s) + } + return settings +} + +func effectiveExitNode(server *ClientSettings, selected int, org OrgConfig) (string, SettingSource) { + id := EffectiveExitNode(server, selected, org) + var source SettingSource + switch { + case id == 0: + return "", SourceDefault + case server.LockedExitNode() != 0: + source = SourceServer + case selected != 0: + source = SourceSelected + case org.ExitNodeResourceID != nil: + source = SourceOrg + default: + source = SourceServer + } + return strconv.Itoa(id), source +} + +// serverValue formats what the server sets for key, or "" if it doesn't. +func serverValue(s *ClientSettings, key string) string { + if s == nil { + return "" + } + formatBool := func(b *bool) string { + if b == nil { + return "" + } + return strconv.FormatBool(*b) + } + + switch key { + case "up.tunnel_dns": + return formatBool(s.TunnelDNS) + case "up.override_dns": + return formatBool(s.OverrideDNS) + case "up.upstream_dns": + return strings.Join(s.UpstreamDNS(), ",") + case "up.match_domains_dns": + return strings.Join(s.MatchDomains, ",") + case "up.prefer_local_routes": + return formatBool(s.PreferLocalRoutes) + case "up.exit_node_takes_precedence": + return formatBool(s.ExitNodeTakesPrecedence) + case "up.mtu": + if s.MTU == nil { + return "" + } + return strconv.Itoa(*s.MTU) + case "up.exit_node_resource_id": + // Unlocked, the server's exit node is only a default. + if s.ExitNodeSiteResourceID == 0 { + return "" + } + if s.ExitNodeLocked { + return strconv.Itoa(s.ExitNodeSiteResourceID) + " (locked)" + } + return strconv.Itoa(s.ExitNodeSiteResourceID) + " (default)" + default: + return "" + } +} + +// defaultValue is what `pangolin up` uses when nothing sets key; "" for the +// list settings and the exit node, which default to none. +func defaultValue(key string) string { + switch key { + case "up.tunnel_dns": + return strconv.FormatBool(DefaultTunnelDNS) + case "up.override_dns": + return strconv.FormatBool(DefaultOverrideDNS) + case "up.prefer_local_routes": + return strconv.FormatBool(DefaultPreferLocalRoutes) + case "up.exit_node_takes_precedence": + return strconv.FormatBool(DefaultExitNodeTakesPrecedence) + case "up.mtu": + return strconv.Itoa(DefaultMTU) + default: + return "" + } +} diff --git a/internal/config/effective_test.go b/internal/config/effective_test.go new file mode 100644 index 0000000..e84b115 --- /dev/null +++ b/internal/config/effective_test.go @@ -0,0 +1,79 @@ +package config + +import "testing" + +func boolPtr(b bool) *bool { return &b } + +func TestEffectiveSettings(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("SUDO_USER", "") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + + for key, value := range map[string]string{ + "up.mtu": "1400", + "up.tunnel_dns": "true", + "up.upstream_dns": "1.1.1.1", + } { + if err := cfg.SetKey(key, value); err != nil { + t.Fatal(err) + } + } + for key, value := range map[string]string{ + "up.mtu": "1380", + "up.tunnel_dns": "false", + "up.exit_node_resource_id": "30", + } { + if err := cfg.SetOrgKey("org1", key, value); err != nil { + t.Fatal(err) + } + } + server := &ClientSettings{ + TunnelDNS: boolPtr(true), + PrimaryDNS: "10.0.0.53", + ExitNodeSiteResourceID: 10, + } + + got := map[string]EffectiveSetting{} + for _, s := range EffectiveSettings(cfg, "org1", server, 20) { + got[s.Key] = s + } + + tests := []struct { + key string + effective string + source SettingSource + }{ + {"up.mtu", "1380", SourceOrg}, + {"up.tunnel_dns", "true", SourceServer}, + {"up.upstream_dns", "10.0.0.53", SourceServer}, + {"up.override_dns", "true", SourceDefault}, + {"up.match_domains_dns", "", SourceDefault}, + {"up.exit_node_resource_id", "20", SourceSelected}, + } + for _, tt := range tests { + s := got[tt.key] + if s.Effective != tt.effective || s.Source != tt.source { + t.Errorf("%s = %q from %s, want %q from %s", tt.key, s.Effective, s.Source, tt.effective, tt.source) + } + } + + if s := got["up.mtu"]; s.Global != "1400" || s.Org != "1380" || s.Server != "" { + t.Errorf("up.mtu sources = %+v", s) + } + if s := got["up.exit_node_resource_id"]; s.Server != "10 (default)" || s.Global != "" { + t.Errorf("exit node sources = %+v", s) + } + + // An org with no overrides follows the global config. + for _, s := range EffectiveSettings(cfg, "org2", nil, 0) { + if s.Key == "up.mtu" && (s.Effective != "1400" || s.Source != SourceGlobal) { + t.Errorf("org2 up.mtu = %q from %s, want 1400 from global", s.Effective, s.Source) + } + if s.Key == "up.exit_node_resource_id" && s.Effective != "" { + t.Errorf("org2 exit node = %q, want none", s.Effective) + } + } +} diff --git a/internal/notice/notices.go b/internal/notice/notices.go index 4356686..ef772da 100644 --- a/internal/notice/notices.go +++ b/internal/notice/notices.go @@ -7,7 +7,10 @@ import ( "github.com/fosrl/cli/internal/config" ) -const companionModeIntroID = "companion-mode-intro-windows-v1" +const ( + companionModeIntroID = "companion-mode-intro-windows-v1" + companionModeIntroLinuxID = "companion-mode-intro-linux-v1" +) var registeredNotices = []Notice{ { @@ -19,6 +22,15 @@ var registeredNotices = []Notice{ }, Lines: companionModeIntroLines, }, + { + // Not tied to a CLI version: Linux offers companion mode only once + // the desktop client is installed, which can happen at any time. + ID: companionModeIntroLinuxID, + Condition: func(cfg *config.Config) bool { + return runtime.GOOS == "linux" && companion.PlatformAvailable() + }, + Lines: companionModeIntroLines, + }, } func companionModeIntroLines(cfg *config.Config) []string { diff --git a/internal/olm/client_linux.go b/internal/olm/client_linux.go new file mode 100644 index 0000000..923ce26 --- /dev/null +++ b/internal/olm/client_linux.go @@ -0,0 +1,11 @@ +//go:build linux + +package olm + +// CompanionAgentName is the OLM agent identifier used by the Pangolin Linux Client. +// Must match the Agent value in the Linux app's OLM tunnel config. +const CompanionAgentName = "Pangolin Linux" + +// desktopAppSocketPath is the socket the Pangolin Linux client's OLM listens +// on. It is distinct from defaultSocketPath, which the CLI's own client uses. +const desktopAppSocketPath = "/run/pangolin-client/olm.sock" diff --git a/internal/olm/client_unix.go b/internal/olm/client_unix.go index 6a2f711..f0888d4 100644 --- a/internal/olm/client_unix.go +++ b/internal/olm/client_unix.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "os" + "time" ) const defaultSocketPath = "/var/run/olm.sock" @@ -21,8 +22,17 @@ func GetDefaultSocketPath() string { } // resolveClientSocketPath picks which socket a client should talk to when none -// was given explicitly. +// was given explicitly: the CLI's own if something is listening on it, else +// the desktop app's if that is up, otherwise the default (so error messages +// still name the expected socket). Unlike on Windows the two don't share a +// path, so both can be up; the CLI's goes first so 'pangolin down' can stop it. func resolveClientSocketPath() string { + if desktopAppSocketPath == "" || socketListening(defaultSocketPath) { + return defaultSocketPath + } + if socketListening(desktopAppSocketPath) { + return desktopAppSocketPath + } return defaultSocketPath } @@ -37,4 +47,15 @@ func newHTTPTransport(socketPath string) *http.Transport { func socketExists(path string) bool { _, err := os.Stat(path) return err == nil -} \ No newline at end of file +} + +// socketListening reports whether something accepts connections on path. A +// stale socket file left by a killed process doesn't count. +func socketListening(path string) bool { + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + return false + } + conn.Close() + return true +} diff --git a/internal/olm/client_unix_other.go b/internal/olm/client_unix_other.go new file mode 100644 index 0000000..018ff6c --- /dev/null +++ b/internal/olm/client_unix_other.go @@ -0,0 +1,6 @@ +//go:build !windows && !linux + +package olm + +// desktopAppSocketPath is empty where the desktop app's OLM isn't supported. +const desktopAppSocketPath = "" From 68aac68a984b87089504d9d6998a3fb4eb5aff08 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 3 Nov 2026 16:30:18 -0500 Subject: [PATCH 3/6] Sign and build installer in CICD --- .github/workflows/cicd.yml | 311 ++++++++++++++++++++++++++++++++++++- pangolin-cli.wxs | 2 +- scripts/build-msi.bat | 20 ++- scripts/reset-version.sh | 22 +-- scripts/set-version.sh | 25 +-- 5 files changed, 329 insertions(+), 51 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 31de2de..13a5ae7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -9,6 +9,12 @@ name: CI/CD Pipeline (AWS Self-Hosted Runners) # - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN # - GITHUB_TOKEN # - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY +# - Windows code signing (DigiCert KeyLocker / DigiCert ONE, via jsign): +# SM_API_KEY, SM_CLIENT_CERT_FILE_B64 (base64 of Certificate_pkcs12.p12), SM_CLIENT_CERT_PASSWORD +# optional repo/org variable SM_KEY_ALIAS: certificate alias or ID (defaults to cert 8657e61d-c2bb-4603-8907-530e879e00bf) +# - macOS code signing + notarization: +# APPLE_CERT_P12_B64 (base64 of Developer ID Application .p12), APPLE_CERT_PASSWORD, +# APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD (app-specific password for notarytool) permissions: contents: write # gh-release @@ -648,14 +654,18 @@ jobs: done # --------------------------------------------------------------------------- - # 6) Sign/attest + build binaries + draft release (x86 runner) + # 6) Sign/attest images + build binaries (x86 runner) # --------------------------------------------------------------------------- sign-and-release: - name: Sign, attest, and release + name: Sign, attest, and build binaries needs: [create-manifest, build-amd] if: ${{ needs.create-manifest.result == 'success' && needs.build-amd.result == 'success' }} runs-on: [self-hosted, linux, x64] # NOTE: ensure label exists on runner timeout-minutes: 120 + outputs: + ghcr_digest: ${{ steps.digests.outputs.ghcr_digest }} + dh_digest: ${{ steps.digests.outputs.dh_digest }} + dh2_digest: ${{ steps.digests.outputs.dh2_digest }} env: DOCKERHUB_IMAGE: docker.io/fosrl/cli DOCKERHUB_IMAGE2: docker.io/fosrl/pangolin-cli @@ -720,6 +730,7 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Resolve multi-arch digest refs (by TAG) + id: digests shell: bash run: | set -euo pipefail @@ -747,17 +758,20 @@ jobs: GHCR_DIGEST="$(get_digest "${GHCR_IMAGE}:${TAG}")" echo "GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}" >> "$GITHUB_ENV" echo "GHCR_DIGEST=${GHCR_DIGEST}" >> "$GITHUB_ENV" + echo "ghcr_digest=${GHCR_DIGEST}" >> "$GITHUB_OUTPUT" echo "Resolved GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}" if [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ]; then DH_DIGEST="$(get_digest "${DOCKERHUB_IMAGE}:${TAG}")" echo "DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}" >> "$GITHUB_ENV" echo "DH_DIGEST=${DH_DIGEST}" >> "$GITHUB_ENV" + echo "dh_digest=${DH_DIGEST}" >> "$GITHUB_OUTPUT" echo "Resolved DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}" DH2_DIGEST="$(get_digest "${DOCKERHUB_IMAGE2}:${TAG}")" echo "DH2_REF=${DOCKERHUB_IMAGE2}@${DH2_DIGEST}" >> "$GITHUB_ENV" echo "DH2_DIGEST=${DH2_DIGEST}" >> "$GITHUB_ENV" + echo "dh2_digest=${DH2_DIGEST}" >> "$GITHUB_OUTPUT" echo "Resolved DH2_REF=${DOCKERHUB_IMAGE2}@${DH2_DIGEST}" fi @@ -993,6 +1007,299 @@ jobs: set -euo pipefail make -j 10 go-build-release VERSION="${TAG}" + - name: Upload unsigned binaries + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: binaries + path: bin/* + if-no-files-found: error + retention-days: 7 + + # --------------------------------------------------------------------------- + # 6b) Sign + notarize macOS binaries (GitHub-hosted macOS runner) + # --------------------------------------------------------------------------- + sign-macos: + name: Sign + notarize macOS binaries + needs: [sign-and-release] + if: ${{ needs.sign-and-release.result == 'success' }} + runs-on: macos-latest + timeout-minutes: 60 + env: + # Developer ID Application certificate (SHA-1) and identifier used for codesign + SIGN_ID: 84F39F4BE29E7F96DFBE13431010525C534FBC67 + BUNDLE_ID: io.fossorial + steps: + - name: Download unsigned binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: binaries + path: bin + + - name: Import Developer ID certificate into a temporary keychain + shell: bash + env: + APPLE_CERT_P12_B64: ${{ secrets.APPLE_CERT_P12_B64 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN="${RUNNER_TEMP}/signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" + P12="${RUNNER_TEMP}/developer-id.p12" + + printf '%s' "$APPLE_CERT_P12_B64" | base64 --decode > "$P12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 3600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$P12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -f pkcs12 -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + # Prepend to the user search list so codesign can find the identity + chain + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm -f "$P12" + + security find-identity -v -p codesigning "$KEYCHAIN" + echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" + + - name: Codesign darwin binaries (hardened runtime + timestamp) + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + files=(bin/*_darwin_*) + if [ ${#files[@]} -eq 0 ]; then + echo "No darwin binaries found in bin/" >&2 + exit 1 + fi + for f in "${files[@]}"; do + echo "Signing $f" + chmod +x "$f" + codesign --sign "$SIGN_ID" --keychain "$KEYCHAIN" --force --timestamp \ + --options runtime --identifier "$BUNDLE_ID" "$f" + codesign --verify --strict --verbose=2 "$f" + done + + - name: Notarize with Apple Notary Service + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + set -euo pipefail + ZIP="${RUNNER_TEMP}/notarize.zip" + /usr/bin/zip -j "$ZIP" bin/*_darwin_* + + AUTH=(--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD") + OUT="$(xcrun notarytool submit "$ZIP" "${AUTH[@]}" --wait --timeout 45m --output-format json)" || { + echo "$OUT"; exit 1; + } + echo "$OUT" + + ID="$(jq -r '.id // empty' <<<"$OUT")" + STATUS="$(jq -r '.status // empty' <<<"$OUT")" + if [ -n "$ID" ]; then + xcrun notarytool log "$ID" "${AUTH[@]}" || true + fi + if [ "$STATUS" != "Accepted" ]; then + echo "Notarization failed (status: ${STATUS:-unknown})" >&2 + exit 1 + fi + # Note: Apple does not support stapling tickets to bare Mach-O binaries; + # Gatekeeper fetches the notarization ticket online on first launch. + + - name: Upload signed macOS binaries + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signed-macos + path: bin/*_darwin_* + if-no-files-found: error + retention-days: 7 + + - name: Remove temporary keychain + if: ${{ always() }} + shell: bash + run: | + if [ -n "${KEYCHAIN:-}" ]; then security delete-keychain "$KEYCHAIN" || true; fi + + # --------------------------------------------------------------------------- + # 6c) Sign Windows binary, build MSI, sign MSI (GitHub-hosted Windows runner) + # --------------------------------------------------------------------------- + sign-windows: + name: Sign Windows binary + build/sign MSI + needs: [build-amd, sign-and-release] + if: ${{ needs.sign-and-release.result == 'success' }} + runs-on: windows-latest + timeout-minutes: 60 + defaults: + run: + shell: pwsh + env: + TAG: ${{ needs.build-amd.outputs.tag }} + EXE: bin/pangolin-cli_windows_amd64.exe + MSI: bin/pangolin-cli_windows_installer.msi + WXS: pangolin-cli.wxs + PRODUCT_NAME: Pangolin CLI + PRODUCT_URL: https://pangolin.net + JSIGN_VERSION: "7.6" + JSIGN_SHA256: 6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 + WIX_VERSION: "5.0.2" + WINTUN_VERSION: "0.14.1" + WINTUN_SHA256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51 + TSA_URL: http://timestamp.digicert.com + SM_KEY_ALIAS: ${{ vars.SM_KEY_ALIAS || '8657e61d-c2bb-4603-8907-530e879e00bf' }} # DigiCert ONE certificate ID (not the keypair alias) + steps: + - name: Checkout tagged commit (for pangolin-cli.wxs) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.build-amd.outputs.tag }} + + - name: Download unsigned binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: binaries + path: bin + + - name: Install jsign, WiX and Wintun + run: | + $jar = Join-Path $env:RUNNER_TEMP "jsign.jar" + Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$env:JSIGN_VERSION/jsign-$env:JSIGN_VERSION.jar" -OutFile $jar + $hash = (Get-FileHash $jar -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne $env:JSIGN_SHA256) { throw "jsign checksum mismatch: $hash" } + "JSIGN_JAR=$jar" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + $zip = Join-Path $env:RUNNER_TEMP "wintun.zip" + Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-$env:WINTUN_VERSION.zip" -OutFile $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne $env:WINTUN_SHA256) { throw "wintun checksum mismatch: $hash" } + $wt = Join-Path $env:RUNNER_TEMP "wintun" + Expand-Archive -Path $zip -DestinationPath $wt -Force + New-Item -ItemType Directory -Force -Path third_party/wintun/amd64 | Out-Null + Copy-Item (Join-Path $wt "wintun/bin/amd64/wintun.dll") third_party/wintun/amd64/wintun.dll + + dotnet tool install --global wix --version $env:WIX_VERSION + if ($LASTEXITCODE -ne 0) { throw "failed to install wix" } + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + + java -version + + - name: Write DigiCert client certificate + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + run: | + $p12 = Join-Path $env:RUNNER_TEMP "digicert-client.p12" + [IO.File]::WriteAllBytes($p12, [Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE_B64)) + "SM_CLIENT_CERT_FILE=$p12" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Sign Windows binary + env: + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + run: | + java -jar $env:JSIGN_JAR --storetype DIGICERTONE ` + --storepass "$env:SM_API_KEY|$env:SM_CLIENT_CERT_FILE|$env:SM_CLIENT_CERT_PASSWORD" ` + --alias $env:SM_KEY_ALIAS --alg SHA-256 --tsaurl $env:TSA_URL ` + --name "$env:PRODUCT_NAME" --url $env:PRODUCT_URL $env:EXE + if ($LASTEXITCODE -ne 0) { throw "jsign failed for $env:EXE" } + + - name: Build MSI (with signed binary) + run: | + # MSI ProductVersion must be numeric (X.Y.Z); drop any -rc.N suffix + $msiVersion = $env:TAG -replace '-.*$', '' + wix build -arch x64 -d "Version=$msiVersion" -d "BuildDir=bin" -o $env:MSI $env:WXS + if ($LASTEXITCODE -ne 0) { throw "wix build failed" } + + - name: Sign MSI + env: + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + run: | + java -jar $env:JSIGN_JAR --storetype DIGICERTONE ` + --storepass "$env:SM_API_KEY|$env:SM_CLIENT_CERT_FILE|$env:SM_CLIENT_CERT_PASSWORD" ` + --alias $env:SM_KEY_ALIAS --alg SHA-256 --tsaurl $env:TSA_URL ` + --name "$env:PRODUCT_NAME" --url $env:PRODUCT_URL $env:MSI + if ($LASTEXITCODE -ne 0) { throw "jsign failed for $env:MSI" } + + - name: Verify Authenticode signatures + run: | + foreach ($f in @($env:EXE, $env:MSI)) { + $sig = Get-AuthenticodeSignature -FilePath $f + Write-Host "$f -> $($sig.Status) | signer: $($sig.SignerCertificate.Subject) | timestamp: $($sig.TimeStamperCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "Invalid signature on $f ($($sig.StatusMessage))" } + if (-not $sig.TimeStamperCertificate) { throw "Missing timestamp on $f" } + } + + - name: Upload signed Windows artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signed-windows + path: | + ${{ env.EXE }} + ${{ env.MSI }} + if-no-files-found: error + retention-days: 7 + + - name: Remove DigiCert client certificate + if: ${{ always() }} + run: | + if ($env:SM_CLIENT_CERT_FILE) { Remove-Item -Force -ErrorAction SilentlyContinue $env:SM_CLIENT_CERT_FILE } + + # --------------------------------------------------------------------------- + # 6d) Assemble signed artifacts and create the draft release + # --------------------------------------------------------------------------- + release: + name: Create GitHub release (draft) + needs: [build-amd, sign-and-release, sign-macos, sign-windows] + if: ${{ needs.sign-and-release.result == 'success' && needs.sign-macos.result == 'success' && needs.sign-windows.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ needs.build-amd.outputs.tag }} + IS_RC: ${{ needs.build-amd.outputs.is_rc }} + steps: + - name: Download unsigned binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: binaries + path: bin + + - name: Download signed macOS binaries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: signed-macos + path: signed/macos + + - name: Download signed Windows artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: signed-windows + path: signed/windows + + - name: Replace unsigned binaries with signed ones + shell: bash + run: | + set -euo pipefail + cp -fv signed/macos/* signed/windows/* bin/ + chmod +x bin/* || true + ls -l bin + + - name: Resolve image refs for release notes + shell: bash + env: + OWNER: ${{ github.repository_owner }} + REPO: ${{ github.event.repository.name }} + GHCR_DIGEST: ${{ needs.sign-and-release.outputs.ghcr_digest }} + DH_DIGEST: ${{ needs.sign-and-release.outputs.dh_digest }} + DH2_DIGEST: ${{ needs.sign-and-release.outputs.dh2_digest }} + run: | + set -euo pipefail + OWNER="${OWNER,,}" + REPO="${REPO,,}" + { + echo "GHCR_REF=ghcr.io/${OWNER}/${REPO}@${GHCR_DIGEST}" + if [ -n "${DH_DIGEST}" ]; then echo "DH_REF=docker.io/fosrl/cli@${DH_DIGEST}"; fi + if [ -n "${DH2_DIGEST}" ]; then echo "DH2_REF=docker.io/fosrl/pangolin-cli@${DH2_DIGEST}"; fi + } >> "$GITHUB_ENV" + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: diff --git a/pangolin-cli.wxs b/pangolin-cli.wxs index 18755a4..554a8c1 100644 --- a/pangolin-cli.wxs +++ b/pangolin-cli.wxs @@ -2,7 +2,7 @@ diff --git a/scripts/build-msi.bat b/scripts/build-msi.bat index c66a296..1f5a767 100644 --- a/scripts/build-msi.bat +++ b/scripts/build-msi.bat @@ -1,17 +1,25 @@ @echo off -REM Build MSI for Pangolin CLI (WiX v4). Package Version comes from pangolin-cli.wxs (set via scripts\set-version.sh). -REM Requires the `wix` CLI on PATH. +REM Build MSI for Pangolin CLI (WiX v4+). CI does this automatically (sign-windows job in +REM .github/workflows/cicd.yml); this script is for local/manual builds. +REM Requires the `wix` CLI on PATH (dotnet tool install --global wix --version 5.0.2). REM 1) Build: make go-build-release-windows-amd64 VERSION=... -REM 2) Run from repo root: scripts\build-msi.bat -REM Or: scripts\build-msi.bat C:\path\to\folder-with-exe +REM 2) Run from repo root: scripts\build-msi.bat +REM Or: scripts\build-msi.bat C:\path\to\folder-with-exe +REM must be numeric X.Y.Z (MSI versions cannot carry an -rc suffix). setlocal cd /d "%~dp0.." if "%~1"=="" ( + echo Usage: %~nx0 ^ [builddir] 1>&2 + exit /b 1 +) +set "MSIVERSION=%~1" + +if "%~2"=="" ( set "BUILDDIR=bin" ) else ( - set "BUILDDIR=%~1" + set "BUILDDIR=%~2" ) if not exist "%BUILDDIR%\pangolin-cli_windows_amd64.exe" ( @@ -24,6 +32,6 @@ if not exist "third_party\wintun\amd64\wintun.dll" ( exit /b 1 ) -wix build -arch x64 -define "BuildDir=%BUILDDIR%" -o "%BUILDDIR%\pangolin-cli_windows_installer.msi" "pangolin-cli.wxs" +wix build -arch x64 -define "Version=%MSIVERSION%" -define "BuildDir=%BUILDDIR%" -o "%BUILDDIR%\pangolin-cli_windows_installer.msi" "pangolin-cli.wxs" if errorlevel 1 exit /b %errorlevel% echo Created "%BUILDDIR%\pangolin-cli_windows_installer.msi" diff --git a/scripts/reset-version.sh b/scripts/reset-version.sh index e651cf1..eb5867f 100755 --- a/scripts/reset-version.sh +++ b/scripts/reset-version.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Reset version placeholders in internal/version/consts.go and pangolin-cli.wxs +# Reset version placeholders in internal/version/consts.go and Makefile # Usage: ./reset-version.sh set -e @@ -7,11 +7,9 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" -INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" MAKEFILE_PATH="${REPO_ROOT}/Makefile" DEFAULT_CLI_VERSION="version_replaceme" -DEFAULT_INSTALLER_VERSION="0.0.0" DEFAULT_MAKEFILE_VERSION="version_replaceme" cd "$REPO_ROOT" @@ -21,11 +19,6 @@ if [ ! -f "$VERSION_FILE" ]; then exit 1 fi -if [ ! -f "$INSTALLER_WXS" ]; then - echo "Error: $INSTALLER_WXS not found" - exit 1 -fi - if [ ! -f "$MAKEFILE_PATH" ]; then echo "Error: $MAKEFILE_PATH not found" exit 1 @@ -46,18 +39,6 @@ else exit 1 fi -echo "Updating pangolin-cli.wxs..." -case $(uname) in - Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; - *) sed -i "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; -esac -if grep -q "Version=\"$DEFAULT_INSTALLER_VERSION\"" "$INSTALLER_WXS"; then - echo " OK" -else - echo "Error: failed to reset Version in $INSTALLER_WXS" - exit 1 -fi - echo "Updating $MAKEFILE_PATH..." case $(uname) in Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $DEFAULT_MAKEFILE_VERSION/" "$MAKEFILE_PATH" ;; @@ -73,5 +54,4 @@ fi echo "" echo "Versions reset:" echo " internal/version/consts.go -> $DEFAULT_CLI_VERSION" -echo " pangolin-cli.wxs -> $DEFAULT_INSTALLER_VERSION" echo " Makefile -> $DEFAULT_MAKEFILE_VERSION" diff --git a/scripts/set-version.sh b/scripts/set-version.sh index fcb20b1..974f077 100755 --- a/scripts/set-version.sh +++ b/scripts/set-version.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Set version in internal/version/consts.go and installer pangolin-cli.wxs +# Set version in internal/version/consts.go and Makefile +# (the MSI version is passed to `wix build -d Version=...`, see scripts/build-msi.bat) # Usage: ./set-version.sh # version: Version string (e.g., 1.0.3) @@ -18,7 +19,6 @@ VERSION="$1" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" -INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" MAKEFILE_PATH="${REPO_ROOT}/Makefile" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9]+)?$ ]]; then @@ -39,11 +39,6 @@ if [ ! -f "$VERSION_FILE" ]; then exit 1 fi -if [ ! -f "$INSTALLER_WXS" ]; then - echo "Error: $INSTALLER_WXS not found" - exit 1 -fi - if [ ! -f "$MAKEFILE_PATH" ]; then echo "Error: $MAKEFILE_PATH not found" exit 1 @@ -64,18 +59,6 @@ else exit 1 fi -echo "Updating pangolin-cli.wxs..." -case $(uname) in - Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; - *) sed -i "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; -esac -if grep -q "Version=\"$VERSION\"" "$INSTALLER_WXS"; then - echo " OK" -else - echo "Error: failed to update Version in $INSTALLER_WXS" - exit 1 -fi - echo "Updating $MAKEFILE_PATH..." case $(uname) in Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $VERSION/" "$MAKEFILE_PATH" ;; @@ -89,6 +72,6 @@ else fi echo "" -echo "Version updated to $VERSION in internal/version/consts.go, pangolin-cli.wxs, and Makefile" +echo "Version updated to $VERSION in internal/version/consts.go and Makefile" echo "" -echo "Next: build the Windows binary, then: scripts\\build-msi.bat" +echo "Next: build the Windows binary, then: scripts\\build-msi.bat $VERSION" From 8c566bcf6e3cf032dc046fb243c6735c35ae2229 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 5 Nov 2026 15:01:04 -0500 Subject: [PATCH 4/6] Show notice about linux desktop --- internal/notice/notices.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/notice/notices.go b/internal/notice/notices.go index ef772da..38ca3b8 100644 --- a/internal/notice/notices.go +++ b/internal/notice/notices.go @@ -10,8 +10,11 @@ import ( const ( companionModeIntroID = "companion-mode-intro-windows-v1" companionModeIntroLinuxID = "companion-mode-intro-linux-v1" + desktopClientLinuxID = "desktop-client-available-linux-v1" ) +const downloadsURL = "https://pangolin.net/downloads" + var registeredNotices = []Notice{ { ID: companionModeIntroID, @@ -31,6 +34,23 @@ var registeredNotices = []Notice{ }, Lines: companionModeIntroLines, }, + { + // Shown once to Linux users without the desktop client. Those who + // already have it get the companion mode notice above instead. + ID: desktopClientLinuxID, + Condition: func(cfg *config.Config) bool { + return runtime.GOOS == "linux" && !companion.PlatformAvailable() + }, + Lines: desktopClientLinuxLines, + }, +} + +func desktopClientLinuxLines(cfg *config.Config) []string { + return []string{ + "A Pangolin desktop client is now available for Linux.", + "It adds a tray icon for connecting, and the CLI can use its login.", + "Download it at " + downloadsURL, + } } func companionModeIntroLines(cfg *config.Config) []string { From 06c9797fb590fd5e604d4d2e0714a88a6f5c4a77 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Nov 2026 12:02:36 -0500 Subject: [PATCH 5/6] Pull the version from ldflags and vars --- scripts/reset-version.sh | 77 -------------------------------- scripts/set-version.sh | 94 ---------------------------------------- 2 files changed, 171 deletions(-) delete mode 100755 scripts/reset-version.sh delete mode 100755 scripts/set-version.sh diff --git a/scripts/reset-version.sh b/scripts/reset-version.sh deleted file mode 100755 index e651cf1..0000000 --- a/scripts/reset-version.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Reset version placeholders in internal/version/consts.go and pangolin-cli.wxs -# Usage: ./reset-version.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" -INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" -MAKEFILE_PATH="${REPO_ROOT}/Makefile" - -DEFAULT_CLI_VERSION="version_replaceme" -DEFAULT_INSTALLER_VERSION="0.0.0" -DEFAULT_MAKEFILE_VERSION="version_replaceme" - -cd "$REPO_ROOT" - -if [ ! -f "$VERSION_FILE" ]; then - echo "Error: $VERSION_FILE not found" - exit 1 -fi - -if [ ! -f "$INSTALLER_WXS" ]; then - echo "Error: $INSTALLER_WXS not found" - exit 1 -fi - -if [ ! -f "$MAKEFILE_PATH" ]; then - echo "Error: $MAKEFILE_PATH not found" - exit 1 -fi - -echo "Resetting versions to defaults..." -echo "" - -echo "Updating $VERSION_FILE..." -case $(uname) in - Darwin) sed -i '' "s/var Version = \"[^\"]*\"/var Version = \"$DEFAULT_CLI_VERSION\"/" "$VERSION_FILE" ;; - *) sed -i "s/var Version = \"[^\"]*\"/var Version = \"$DEFAULT_CLI_VERSION\"/" "$VERSION_FILE" ;; -esac -if grep -q "var Version = \"$DEFAULT_CLI_VERSION\"" "$VERSION_FILE"; then - echo " OK" -else - echo "Error: failed to reset version in $VERSION_FILE" - exit 1 -fi - -echo "Updating pangolin-cli.wxs..." -case $(uname) in - Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; - *) sed -i "s/Version=\"[^\"]*\"/Version=\"$DEFAULT_INSTALLER_VERSION\"/" "$INSTALLER_WXS" ;; -esac -if grep -q "Version=\"$DEFAULT_INSTALLER_VERSION\"" "$INSTALLER_WXS"; then - echo " OK" -else - echo "Error: failed to reset Version in $INSTALLER_WXS" - exit 1 -fi - -echo "Updating $MAKEFILE_PATH..." -case $(uname) in - Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $DEFAULT_MAKEFILE_VERSION/" "$MAKEFILE_PATH" ;; - *) sed -i "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $DEFAULT_MAKEFILE_VERSION/" "$MAKEFILE_PATH" ;; -esac -if grep -q "^VERSION[[:space:]]*?=[[:space:]]*$DEFAULT_MAKEFILE_VERSION$" "$MAKEFILE_PATH"; then - echo " OK" -else - echo "Error: failed to reset VERSION in $MAKEFILE_PATH" - exit 1 -fi - -echo "" -echo "Versions reset:" -echo " internal/version/consts.go -> $DEFAULT_CLI_VERSION" -echo " pangolin-cli.wxs -> $DEFAULT_INSTALLER_VERSION" -echo " Makefile -> $DEFAULT_MAKEFILE_VERSION" diff --git a/scripts/set-version.sh b/scripts/set-version.sh deleted file mode 100755 index fcb20b1..0000000 --- a/scripts/set-version.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# Set version in internal/version/consts.go and installer pangolin-cli.wxs -# Usage: ./set-version.sh -# version: Version string (e.g., 1.0.3) - -set -e - -if [ -z "$1" ]; then - echo "Usage: $0 " - echo " version: Version string (e.g., \"1.0.3\")" - echo "" - echo "Example:" - echo " $0 1.0.3" - exit 1 -fi - -VERSION="$1" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -VERSION_FILE="${REPO_ROOT}/internal/version/consts.go" -INSTALLER_WXS="${REPO_ROOT}/pangolin-cli.wxs" -MAKEFILE_PATH="${REPO_ROOT}/Makefile" - -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9]+)?$ ]]; then - echo "Warning: Version format may be invalid. Expected format: X.Y.Z or X.Y.Z-suffix" - echo " Example: 1.0.3 or 1.0.3-beta" - read -p "Continue anyway? (y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 - fi -fi - -cd "$REPO_ROOT" - -if [ ! -f "$VERSION_FILE" ]; then - echo "Error: $VERSION_FILE not found" - exit 1 -fi - -if [ ! -f "$INSTALLER_WXS" ]; then - echo "Error: $INSTALLER_WXS not found" - exit 1 -fi - -if [ ! -f "$MAKEFILE_PATH" ]; then - echo "Error: $MAKEFILE_PATH not found" - exit 1 -fi - -echo "Setting version to: ${VERSION}" -echo "" - -echo "Updating $VERSION_FILE..." -case $(uname) in - Darwin) sed -i '' "s/var Version = \"[^\"]*\"/var Version = \"$VERSION\"/" "$VERSION_FILE" ;; - *) sed -i "s/var Version = \"[^\"]*\"/var Version = \"$VERSION\"/" "$VERSION_FILE" ;; -esac -if grep -q "var Version = \"$VERSION\"" "$VERSION_FILE"; then - echo " OK" -else - echo "Error: failed to update version in $VERSION_FILE" - exit 1 -fi - -echo "Updating pangolin-cli.wxs..." -case $(uname) in - Darwin) sed -i '' "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; - *) sed -i "s/Version=\"[^\"]*\"/Version=\"$VERSION\"/" "$INSTALLER_WXS" ;; -esac -if grep -q "Version=\"$VERSION\"" "$INSTALLER_WXS"; then - echo " OK" -else - echo "Error: failed to update Version in $INSTALLER_WXS" - exit 1 -fi - -echo "Updating $MAKEFILE_PATH..." -case $(uname) in - Darwin) sed -i '' "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $VERSION/" "$MAKEFILE_PATH" ;; - *) sed -i "s/^VERSION[[:space:]]*?=[[:space:]]*.*/VERSION ?= $VERSION/" "$MAKEFILE_PATH" ;; -esac -if grep -q "^VERSION[[:space:]]*?=[[:space:]]*$VERSION$" "$MAKEFILE_PATH"; then - echo " OK" -else - echo "Error: failed to update VERSION in $MAKEFILE_PATH" - exit 1 -fi - -echo "" -echo "Version updated to $VERSION in internal/version/consts.go, pangolin-cli.wxs, and Makefile" -echo "" -echo "Next: build the Windows binary, then: scripts\\build-msi.bat" From dbacf25234fc7c95cd1719ee0f40cfb8f69d35a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Nov 2026 13:53:05 -0500 Subject: [PATCH 6/6] Make the version a var --- Makefile | 2 +- flake.nix | 1 + internal/version/consts.go | 8 +++++--- pangolin-cli.wxs | 9 ++++++++- scripts/build-msi.bat | 23 +++++++++++++++-------- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index efbbe03..50cca31 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY_NAME=pangolin OUTPUT_DIR=bin -VERSION ?= 0.19.1 +VERSION ?= dev NEWT_VERSION := $(shell go list -m -f '{{.Version}}' github.com/fosrl/newt | sed 's/^v//') OLM_VERSION := $(shell go list -m -f '{{.Version}}' github.com/fosrl/olm | sed 's/^v//') LDFLAGS = -s -w -X github.com/fosrl/cli/internal/version.Version=$(VERSION) -X github.com/fosrl/cli/internal/version.NewtVersionOverride=$(NEWT_VERSION) -X github.com/fosrl/cli/internal/version.OlmVersionOverride=$(OLM_VERSION) diff --git a/flake.nix b/flake.nix index 7ca174d..2cfb6c5 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ ldflags = [ "-s" "-w" + "-X github.com/fosrl/cli/internal/version.Version=${version}" ]; meta = with pkgs.lib; { diff --git a/internal/version/consts.go b/internal/version/consts.go index 8456947..d49288f 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -1,7 +1,9 @@ package version -// Version is the current version of the Pangolin CLI. -// This value can be overridden at build time using ldflags: +// Version is the current version of the Pangolin CLI. It is set at build time +// via -ldflags (see Makefile, Dockerfile and flake.nix): // // go build -ldflags "-X github.com/fosrl/cli/internal/version.Version=" -var Version = "0.19.1" +// +// A plain `go build` leaves it as "dev". +var Version = "dev" diff --git a/pangolin-cli.wxs b/pangolin-cli.wxs index aa4c03a..255c23d 100644 --- a/pangolin-cli.wxs +++ b/pangolin-cli.wxs @@ -1,8 +1,15 @@ + diff --git a/scripts/build-msi.bat b/scripts/build-msi.bat index c66a296..cf078d5 100644 --- a/scripts/build-msi.bat +++ b/scripts/build-msi.bat @@ -1,17 +1,24 @@ @echo off -REM Build MSI for Pangolin CLI (WiX v4). Package Version comes from pangolin-cli.wxs (set via scripts\set-version.sh). -REM Requires the `wix` CLI on PATH. -REM 1) Build: make go-build-release-windows-amd64 VERSION=... -REM 2) Run from repo root: scripts\build-msi.bat -REM Or: scripts\build-msi.bat C:\path\to\folder-with-exe +REM Build MSI for Pangolin CLI (WiX v4). Requires the `wix` CLI on PATH. +REM 1) Build: make go-build-release-windows-amd64 VERSION=X.Y.Z +REM 2) Run from repo root: scripts\build-msi.bat X.Y.Z +REM Or: scripts\build-msi.bat X.Y.Z C:\path\to\folder-with-exe +REM The MSI ProductVersion must be numeric, so any -suffix (e.g. -rc.1) is dropped. setlocal cd /d "%~dp0.." if "%~1"=="" ( + echo Usage: %~nx0 ^ [builddir] 1>&2 + exit /b 1 +) + +for /f "tokens=1 delims=-" %%v in ("%~1") do set "MSIVERSION=%%v" + +if "%~2"=="" ( set "BUILDDIR=bin" ) else ( - set "BUILDDIR=%~1" + set "BUILDDIR=%~2" ) if not exist "%BUILDDIR%\pangolin-cli_windows_amd64.exe" ( @@ -24,6 +31,6 @@ if not exist "third_party\wintun\amd64\wintun.dll" ( exit /b 1 ) -wix build -arch x64 -define "BuildDir=%BUILDDIR%" -o "%BUILDDIR%\pangolin-cli_windows_installer.msi" "pangolin-cli.wxs" +wix build -arch x64 -define "Version=%MSIVERSION%" -define "BuildDir=%BUILDDIR%" -o "%BUILDDIR%\pangolin-cli_windows_installer.msi" "pangolin-cli.wxs" if errorlevel 1 exit /b %errorlevel% -echo Created "%BUILDDIR%\pangolin-cli_windows_installer.msi" +echo Created "%BUILDDIR%\pangolin-cli_windows_installer.msi" (version %MSIVERSION%)