diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c5f1403 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @oschwartz10612 @miloschwartz diff --git a/cmd/apply/blueprint/blueprint.go b/cmd/apply/blueprint/blueprint.go index b4944d0..209b978 100644 --- a/cmd/apply/blueprint/blueprint.go +++ b/cmd/apply/blueprint/blueprint.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "regexp" "strings" "github.com/fosrl/cli/internal/api" @@ -86,6 +87,8 @@ func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { return err } + blueprintContents = interpolateBlueprint(blueprintContents) + _, err = api.ApplyBlueprint(account.OrgID, opts.Name, string(blueprintContents)) if err != nil { logger.Error("Error: failed to apply blueprint: %v", err) @@ -96,3 +99,25 @@ func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { return nil } + +// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and +// replaces recognised schemes with their resolved values. Currently supported: +// +// - env. – replaced with the value of the named environment variable +// +// Any token that does not match a supported scheme is left as-is so that +// future schemes are preserved rather than silently dropped. +func interpolateBlueprint(data []byte) []byte { + re := regexp.MustCompile(`\{\{([^}]+)\}\}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + inner := strings.TrimSpace(string(match[2 : len(match)-2])) + + if strings.HasPrefix(inner, "env.") { + varName := strings.TrimPrefix(inner, "env.") + return []byte(os.Getenv(varName)) + } + + // unrecognised scheme – leave the token untouched + return match + }) +} diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go index cd96465..25992f6 100644 --- a/cmd/authdaemon/authdaemon.go +++ b/cmd/authdaemon/authdaemon.go @@ -40,6 +40,9 @@ func AuthDaemonCmd() *cobra.Command { Short: "Start the auth daemon", Long: "Start the auth daemon for remote SSH authentication", PreRunE: func(c *cobra.Command, args []string) error { + if opts.PreSharedKey == "" { + opts.PreSharedKey = os.Getenv("AUTH_DAEMON_PRE_SHARED_KEY") + } if opts.PreSharedKey == "" { return errPresharedKeyRequired } @@ -53,8 +56,7 @@ func AuthDaemonCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&opts.PreSharedKey, "pre-shared-key", "", "Preshared key required for all requests to the auth daemon (required)") - cmd.MarkFlagRequired("pre-shared-key") + cmd.Flags().StringVar(&opts.PreSharedKey, "pre-shared-key", "", "Preshared key required for all requests to the auth daemon (required). Can also be set via AUTH_DAEMON_PRE_SHARED_KEY env var") cmd.Flags().IntVar(&opts.Port, "port", defaultPort, "TCP listen port for the HTTPS server") cmd.Flags().StringVar(&opts.PrincipalsFile, "principals-file", defaultPrincipalsPath, "Path to the principals file") cmd.Flags().StringVar(&opts.CACertPath, "ca-cert-path", defaultCACertPath, "Path to the CA certificate file") diff --git a/cmd/list/aliases.go b/cmd/list/aliases.go new file mode 100644 index 0000000..95ad121 --- /dev/null +++ b/cmd/list/aliases.go @@ -0,0 +1,46 @@ +package list + +import ( + "fmt" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/utils" + "github.com/spf13/cobra" +) + +const aliasesPageSize = 1000 + +func aliasesCmd() *cobra.Command { + return &cobra.Command{ + Use: "aliases", + Short: "Print every private host alias you can reach in the current organization", + Long: `Lists all private site aliases you have access to in your selected organization—one name per line.`, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + + orgID, err := utils.ResolveOrgID(accountStore, "") + if err != nil { + return err + } + + for page := 1; ; page++ { + data, err := apiClient.ListUserResourceAliases(orgID, page, aliasesPageSize) + if err != nil { + return fmt.Errorf("list user resource aliases: %w", err) + } + for _, a := range data.Aliases { + fmt.Println(a) + } + if len(data.Aliases) == 0 { + break + } + if len(data.Aliases) < aliasesPageSize { + break + } + } + return nil + }, + } +} diff --git a/cmd/list/list.go b/cmd/list/list.go new file mode 100644 index 0000000..9078d81 --- /dev/null +++ b/cmd/list/list.go @@ -0,0 +1,13 @@ +package list + +import "github.com/spf13/cobra" + +// ListCmd is the parent `list` command for listing server-side items. +func ListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List resources and other items from the server", + } + cmd.AddCommand(aliasesCmd()) + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index 46b1002..94ef259 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/fosrl/cli/cmd/auth/logout" "github.com/fosrl/cli/cmd/authdaemon" "github.com/fosrl/cli/cmd/down" + "github.com/fosrl/cli/cmd/list" "github.com/fosrl/cli/cmd/logs" selectcmd "github.com/fosrl/cli/cmd/select" "github.com/fosrl/cli/cmd/ssh" @@ -49,7 +50,8 @@ func RootCommand(initResources bool) (*cobra.Command, error) { } cmd.AddCommand(apply.ApplyCommand()) cmd.AddCommand(selectcmd.SelectCmd()) - + cmd.AddCommand(list.ListCmd()) + // Platform-specific commands - nil on unsupported platforms if upCmd := up.UpCmd(); upCmd != nil { cmd.AddCommand(upCmd) @@ -63,7 +65,7 @@ func RootCommand(initResources bool) (*cobra.Command, error) { if statusCmd := status.StatusCmd(); statusCmd != nil { cmd.AddCommand(statusCmd) } - + cmd.AddCommand(ssh.SSHCmd()) cmd.AddCommand(update.UpdateCmd()) cmd.AddCommand(version.VersionCmd()) diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 7dbcd50..5a8ba86 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -5,7 +5,6 @@ import ( "time" "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/sshkeys" ) @@ -29,8 +28,14 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr if err != nil { return "", "", "", nil, fmt.Errorf("SSH error: %w", err) } - messageID := initResp.MessageID - if messageID == 0 { + + // Collect all message IDs to poll (support both single and multiple). + var messageIDs []int64 + if len(initResp.MessageIDs) > 0 { + messageIDs = initResp.MessageIDs + } else if initResp.MessageID != 0 { + messageIDs = []int64{initResp.MessageID} + } else { return "", "", "", nil, fmt.Errorf("SSH error: API did not return a message ID") } @@ -38,35 +43,23 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr interval := pollStartInterval for i := 0; i <= pollBackoffSteps; i++ { - msg, pollErr := client.GetRoundTripMessage(messageID) - if pollErr != nil { - return "", "", "", nil, fmt.Errorf("SSH error: poll: %w", pollErr) - } - if msg.Complete { - if msg.Error != nil && *msg.Error != "" { - return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error) + for _, messageID := range messageIDs { + msg, pollErr := client.GetRoundTripMessage(messageID) + if pollErr != nil { + return "", "", "", nil, fmt.Errorf("SSH error: poll: %w", pollErr) + } + if msg.Complete { + if msg.Error != nil && *msg.Error != "" { + return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error) + } + return privPEM, pubKey, initResp.Certificate, initResp, nil } - return privPEM, pubKey, initResp.Certificate, initResp, nil } if i < pollBackoffSteps { time.Sleep(interval) interval *= 2 } } - return "", "", "", nil, fmt.Errorf("SSH error: timed out waiting for round-trip message") -} -// ResolveOrgID returns orgID from the flag or the active account. Returns empty string and nil error if both are empty. -func ResolveOrgID(accountStore *config.AccountStore, flagOrgID string) (string, error) { - if flagOrgID != "" { - return flagOrgID, nil - } - active, err := accountStore.ActiveAccount() - if err != nil || active == nil { - return "", errOrgRequired - } - if active.OrgID == "" { - return "", errOrgRequired - } - return active.OrgID, nil + return "", "", "", nil, fmt.Errorf("SSH error: timed out waiting for round-trip message") } diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go index 3e9a14c..2c4de92 100644 --- a/cmd/ssh/sign.go +++ b/cmd/ssh/sign.go @@ -42,7 +42,7 @@ func SignCmd() *cobra.Command { apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) - orgID, err := ResolveOrgID(accountStore, "") + orgID, err := utils.ResolveOrgID(accountStore, "") if err != nil { logger.Error("%v", err) os.Exit(1) diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 4ea7759..add6113 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -4,18 +4,19 @@ import ( "errors" "os" "runtime" + "time" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" "github.com/fosrl/cli/internal/olm" + "github.com/fosrl/cli/internal/utils" "github.com/spf13/cobra" ) var ( errHostnameRequired = errors.New("API did not return a hostname for the connection") errResourceIDRequired = errors.New("Resource (alias or identifier) is required; example: pangolin ssh my-server.internal") - errOrgRequired = errors.New("Organization is required") errNoClientRunning = errors.New("No client is currently running. Start the client first with `pangolin up`") errNoClientRunningWindows = errors.New("No client is currently running. Start the client first in the system tray") ) @@ -39,27 +40,22 @@ func SSHCmd() *cobra.Command { return nil }, Run: func(c *cobra.Command, args []string) { - if runtime.GOOS != "windows" { - client := olm.NewClient("") - if !client.IsRunning() { - logger.Error("%v", errNoClientRunning) - os.Exit(1) - } - } else { - // check if the named pipe exists by trying to open it. If it doesn't exist, the client is not running. - pipePath := `\\.\pipe\pangolin-olm` - pipeFile, err := os.Open(pipePath) - if err != nil { - logger.Error("%v", errNoClientRunningWindows) - os.Exit(1) - } - pipeFile.Close() + client := olm.NewClient("") + if !client.IsRunning() { + logger.Error("%v", errNoClientRunning) + os.Exit(1) } apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) - orgID, err := ResolveOrgID(accountStore, "") + // init a jit connection to the site if we need to because we might not be connected + _, err := client.JITConnectByResourceID(opts.ResourceID) + if err != nil { + logger.Warning("%v", err) // we pass through this warning for backward compatibility with older olm api servers + } + + orgID, err := utils.ResolveOrgID(accountStore, "") if err != nil { logger.Error("%v", err) os.Exit(1) @@ -74,6 +70,40 @@ func SSHCmd() *cobra.Command { logger.Error("%v", errHostnameRequired) os.Exit(1) } + + // logger.Info("signData: %+v", signData) + + siteIDs := []int{} + if signData.SiteID != 0 { + siteIDs = append(siteIDs, signData.SiteID) + } + for _, id := range signData.SiteIDs { + if id != 0 { + siteIDs = append(siteIDs, id) + } + } + + if len(siteIDs) > 0 { // older versions of the server did not send back the site id so we need to check for backward compatibility + for _, siteID := range siteIDs { + deadline := time.Now().Add(15 * time.Second) + connected := false + for time.Now().Before(deadline) { + status, err := client.GetStatus() + if err == nil { + if peer, ok := status.PeerStatuses[siteID]; ok && peer.Connected { + connected = true + // logger.Info("site is connected") + break + } + } + time.Sleep(500 * time.Millisecond) + } + if !connected { + logger.Error("site %d is not connected; timed out waiting for connection", siteID) + os.Exit(1) + } + } + } passThrough := args[1:] runOpts := RunOpts{ diff --git a/cmd/status/status_windows.go b/cmd/status/status_windows.go index 4e84e0c..64d9f6b 100644 --- a/cmd/status/status_windows.go +++ b/cmd/status/status_windows.go @@ -3,10 +3,23 @@ package status import ( + "github.com/fosrl/cli/cmd/status/client" "github.com/spf13/cobra" ) -// StatusCmd returns nil on Windows as this command is not supported. func StatusCmd() *cobra.Command { - return nil + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := client.ClientStatusCmd() + + cmd.Use = "status" + cmd.Short = "Status commands" + cmd.Long = `View status information. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(client.ClientStatusCmd()) + + return cmd } \ No newline at end of file diff --git a/cmd/update/update_unix.go b/cmd/update/update_unix.go index 5e61ac4..23f3d36 100644 --- a/cmd/update/update_unix.go +++ b/cmd/update/update_unix.go @@ -28,8 +28,17 @@ func UpdateCmd() *cobra.Command { func updateMain() error { logger.Info("Updating Pangolin CLI...") - // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash - updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash") + // Get the path of the current running binary + execPath, err := os.Executable() + if err != nil { + logger.Error("Failed to get current executable path: %v", err) + return err + } + + logger.Debug("Current executable path: %s", execPath) + + // Execute: curl -fsSL https://static.pangolin.net/get-cli.sh | bash -s -- --path /current/binary/path + updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash -s -- --path "+execPath) updateCmd.Stdin = os.Stdin updateCmd.Stdout = os.Stdout updateCmd.Stderr = os.Stderr @@ -42,4 +51,4 @@ func updateMain() error { logger.Success("Pangolin CLI updated successfully!") return nil -} \ No newline at end of file +} diff --git a/get-cli.sh b/get-cli.sh index cc8f62e..9e1bdde 100644 --- a/get-cli.sh +++ b/get-cli.sh @@ -104,6 +104,26 @@ get_install_dir() { esac } +# Parse --path argument from args +# Returns the value after --path, or empty string if not provided +parse_path_arg() { + while [ $# -gt 0 ]; do + case "$1" in + --path) + if [ -n "$2" ]; then + printf '%s' "$2" + return + fi + ;; + --path=*) + printf '%s' "${1#--path=}" + return + ;; + esac + shift + done +} + # Check if we need sudo for installation needs_sudo() { install_dir="$1" @@ -135,6 +155,7 @@ install_pangolin() { platform="$1" install_dir="$2" sudo_cmd="$3" + custom_path="$4" asset_name="pangolin-cli_${platform}" final_name="pangolin" @@ -147,7 +168,14 @@ install_pangolin() { download_url="${BASE_URL}/${asset_name}" temp_file="/tmp/${final_name}" - final_path="${install_dir}/${final_name}" + + # If a custom path is provided, use it directly; otherwise use install_dir/final_name + if [ -n "$custom_path" ]; then + final_path="$custom_path" + install_dir=$(dirname "$final_path") + else + final_path="${install_dir}/${final_name}" + fi print_status "Downloading Pangolin from ${download_url}" @@ -206,7 +234,14 @@ verify_installation() { # Main function main() { - print_status "Installing latest version of Pangolin..." + # Check for --path argument + CUSTOM_PATH=$(parse_path_arg "$@") + + if [ -n "$CUSTOM_PATH" ]; then + print_status "Installing latest version of Pangolin to ${CUSTOM_PATH}..." + else + print_status "Installing latest version of Pangolin..." + fi print_status "Fetching latest version..." VERSION=$(get_latest_version) @@ -217,7 +252,11 @@ main() { PLATFORM=$(detect_platform) print_status "Detected platform: ${PLATFORM}" - INSTALL_DIR=$(get_install_dir) + if [ -n "$CUSTOM_PATH" ]; then + INSTALL_DIR=$(dirname "$CUSTOM_PATH") + else + INSTALL_DIR=$(get_install_dir) + fi print_status "Install directory: ${INSTALL_DIR}" # Check if we need sudo @@ -226,9 +265,18 @@ main() { print_status "Root privileges required for installation to ${INSTALL_DIR}" fi - install_pangolin "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" + install_pangolin "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH" - if verify_installation "$INSTALL_DIR"; then + if [ -n "$CUSTOM_PATH" ]; then + if [ -x "$CUSTOM_PATH" ]; then + print_status "Installation successful!" + print_status "pangolin version: $("$CUSTOM_PATH" version 2>/dev/null || printf 'unknown')" + print_status "Pangolin is ready to use!" + else + print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}." + exit 1 + fi + elif verify_installation "$INSTALL_DIR"; then print_status "Pangolin is ready to use!" print_status "Run 'pangolin --help' to get started." else diff --git a/go.mod b/go.mod index 53b444b..9a1bf09 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/Masterminds/semver/v3 v3.4.0 + github.com/Microsoft/go-winio v0.6.2 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 @@ -20,7 +21,6 @@ require ( ) require ( - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect diff --git a/internal/api/client.go b/internal/api/client.go index a0f6c6e..0fef840 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -360,6 +361,22 @@ func (c *Client) CheckOrgUserAccess(orgID, userID string) (*CheckOrgUserAccessRe return &response, nil } +// ListUserResourceAliases returns one page of host-mode private site resource aliases for the user in the org. +func (c *Client) ListUserResourceAliases(orgID string, page, pageSize int) (*ListUserResourceAliasesData, error) { + path := fmt.Sprintf("/org/%s/user-resource-aliases", url.PathEscape(orgID)) + var data ListUserResourceAliasesData + opts := RequestOptions{ + Query: map[string]string{ + "page": strconv.Itoa(page), + "pageSize": strconv.Itoa(pageSize), + }, + } + if err := c.Get(path, &data, opts); err != nil { + return nil, err + } + return &data, nil +} + // SignSSHKey signs an SSH public key for the given org and resource. func (c *Client) SignSSHKey(orgID string, req SignSSHKeyRequest) (*SignSSHKeyData, error) { path := fmt.Sprintf("/org/%s/ssh/sign-key", orgID) diff --git a/internal/api/types.go b/internal/api/types.go index 4eed2b6..252cb92 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -280,6 +280,7 @@ type SignSSHKeyRequest struct { } type SignSSHKeyData struct { + MessageIDs []int64 `json:"messageIds"` MessageID int64 `json:"messageId"` Certificate string `json:"certificate"` KeyID string `json:"keyId"` @@ -289,14 +290,17 @@ type SignSSHKeyData struct { ExpiresInSeconds int `json:"expiresIn"` Hostname string `json:"sshHost"` // hostname for SSH connection (returned by API) User string `json:"sshUsername"` // user for SSH connection (returned by API) + ResourceID int `json:"resourceId"` // resource ID for SSH connection (returned by API) + SiteIDs []int `json:"siteIds"` // site ID for SSH connection (returned by API) + SiteID int `json:"siteId"` // site ID for SSH connection (returned by API) } type RoundTripMessage struct { - MessageID int64 `json:"messageId"` - Complete bool `json:"complete"` - SentAt int64 `json:"sentAt"` // epoch seconds - ReceivedAt int64 `json:"receivedAt"` // epoch seconds - Error *string `json:"error,omitempty"` + MessageID int64 `json:"messageId"` + Complete bool `json:"complete"` + SentAt int64 `json:"sentAt"` // epoch seconds + ReceivedAt int64 `json:"receivedAt"` // epoch seconds + Error *string `json:"error,omitempty"` } type SignSSHKeyResponse struct { @@ -304,3 +308,16 @@ type SignSSHKeyResponse struct { Error *string `json:"error,omitempty"` Data SignSSHKeyData `json:"data"` } + +// ListUserResourceAliasesData is the inner `data` of GET /org/:orgId/user-resource-aliases. +type ListUserResourceAliasesData struct { + Aliases []string `json:"aliases"` + Pagination AliasesPagination `json:"pagination"` +} + +// AliasesPagination matches the paginated API envelope for user-resource-aliases. +type AliasesPagination struct { + Total int `json:"total"` + PageSize int `json:"pageSize"` + Page int `json:"page"` +} diff --git a/internal/olm/client.go b/internal/olm/client.go index fddcd6a..4263ca9 100644 --- a/internal/olm/client.go +++ b/internal/olm/client.go @@ -2,19 +2,15 @@ package olm import ( "bytes" - "context" "encoding/json" "fmt" "io" - "net" "net/http" - "os" "time" ) const ( - defaultSocketPath = "/var/run/olm.sock" - AgentName = "Pangolin CLI" + AgentName = "Pangolin CLI" ) // Client handles communication with the OLM process via Unix socket @@ -69,6 +65,18 @@ type SwitchOrgResponse struct { Status string `json:"status"` } +// JITConnectionRequest represents a Just-In-Time connection request. +// Exactly one of SiteID or ResourceID must be set. +type JITConnectionRequest struct { + Site string `json:"site,omitempty"` + Resource string `json:"resource,omitempty"` +} + +// JITConnectionResponse represents the response from a JIT connection request +type JITConnectionResponse struct { + Status string `json:"status"` +} + // NewClient creates a new OLM socket client func NewClient(socketPath string) *Client { if socketPath == "" { @@ -78,28 +86,19 @@ func NewClient(socketPath string) *Client { return &Client{ socketPath: socketPath, httpClient: &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, + Timeout: 5 * time.Second, + Transport: newHTTPTransport(socketPath), }, } } -// getDefaultSocketPath returns the default socket path -func getDefaultSocketPath() string { - return defaultSocketPath -} - -// GetDefaultSocketPath returns the default socket path (exported for use in other packages) -func GetDefaultSocketPath() string { - return getDefaultSocketPath() -} - // doRequest performs an HTTP request and handles common error cases func (c *Client) doRequest(method, path string, body io.Reader, headers map[string]string) (*http.Response, error) { + return c.doRequestExpecting(method, path, body, headers, http.StatusOK) +} + +// doRequestExpecting performs an HTTP request and treats the given status code as success +func (c *Client) doRequestExpecting(method, path string, body io.Reader, headers map[string]string, expectedStatus int) (*http.Response, error) { req, err := http.NewRequest(method, "http://localhost"+path, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -111,14 +110,13 @@ func (c *Client) doRequest(method, path string, body io.Reader, headers map[stri resp, err := c.httpClient.Do(req) if err != nil { - // Check if socket file exists - if _, statErr := os.Stat(c.socketPath); os.IsNotExist(statErr) { + if !socketExists(c.socketPath) { return nil, fmt.Errorf("socket does not exist: %s (is the client running?)", c.socketPath) } return nil, fmt.Errorf("failed to connect to socket: %w", err) } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode != expectedStatus { body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) @@ -183,12 +181,51 @@ func (c *Client) SwitchOrg(orgID string) (*SwitchOrgResponse, error) { return &switchOrgResp, nil } +// jitConnect is the shared implementation for JIT connection requests +func (c *Client) jitConnect(req JITConnectionRequest) (*JITConnectionResponse, error) { + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := c.doRequestExpecting("POST", "/jit-connect", bytes.NewBuffer(jsonData), map[string]string{ + "Content-Type": "application/json", + }, http.StatusAccepted) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jitResp JITConnectionResponse + if err := json.NewDecoder(resp.Body).Decode(&jitResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &jitResp, nil +} + +// JITConnectBySiteID initiates a dynamic Just-In-Time connection to the given site +func (c *Client) JITConnectBySiteID(siteID string) (*JITConnectionResponse, error) { + if siteID == "" { + return nil, fmt.Errorf("siteID must not be empty") + } + return c.jitConnect(JITConnectionRequest{Site: siteID}) +} + +// JITConnectByResourceID initiates a dynamic Just-In-Time connection to the site +// that serves the given resource +func (c *Client) JITConnectByResourceID(resourceID string) (*JITConnectionResponse, error) { + if resourceID == "" { + return nil, fmt.Errorf("resourceID must not be empty") + } + return c.jitConnect(JITConnectionRequest{Resource: resourceID}) +} + // IsRunning checks if the OLM process is running by checking if the socket exists // and making a health check request to verify the service is responding func (c *Client) IsRunning() bool { // First check if socket exists - _, err := os.Stat(c.socketPath) - if err != nil { + if !socketExists(c.socketPath) { return false } diff --git a/internal/olm/client_unix.go b/internal/olm/client_unix.go new file mode 100644 index 0000000..24b6403 --- /dev/null +++ b/internal/olm/client_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package olm + +import ( + "context" + "net" + "net/http" + "os" +) + +const defaultSocketPath = "/var/run/olm.sock" + +func getDefaultSocketPath() string { + return defaultSocketPath +} + +// GetDefaultSocketPath returns the default socket path (exported for use in other packages) +func GetDefaultSocketPath() string { + return getDefaultSocketPath() +} + +func newHTTPTransport(socketPath string) *http.Transport { + return &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + } +} + +func socketExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} \ No newline at end of file diff --git a/internal/olm/client_windows.go b/internal/olm/client_windows.go new file mode 100644 index 0000000..be2cb9d --- /dev/null +++ b/internal/olm/client_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package olm + +import ( + "context" + "net" + "net/http" + "time" + + "github.com/Microsoft/go-winio" +) + +const defaultSocketPath = `\\.\pipe\pangolin-olm` + +func getDefaultSocketPath() string { + return defaultSocketPath +} + +// GetDefaultSocketPath returns the default socket path (exported for use in other packages) +func GetDefaultSocketPath() string { + return getDefaultSocketPath() +} + +func newHTTPTransport(socketPath string) *http.Transport { + return &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + timeout := 2 * time.Second + return winio.DialPipe(socketPath, &timeout) + }, + } +} + +func socketExists(path string) bool { + timeout := 1 * time.Second + conn, err := winio.DialPipe(path, &timeout) + if err != nil { + return false + } + conn.Close() + return true +} \ No newline at end of file diff --git a/internal/utils/org.go b/internal/utils/org.go index 03a49a8..e313e9f 100644 --- a/internal/utils/org.go +++ b/internal/utils/org.go @@ -1,14 +1,34 @@ package utils import ( + "errors" "fmt" "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/olm" ) +// ErrOrgRequired is returned when an org ID is needed but the active account has none set. +var ErrOrgRequired = errors.New("Organization is required") + +// ResolveOrgID returns orgID from flagOrgID when non-empty; otherwise the active account's org ID. +func ResolveOrgID(accountStore *config.AccountStore, flagOrgID string) (string, error) { + if flagOrgID != "" { + return flagOrgID, nil + } + active, err := accountStore.ActiveAccount() + if err != nil || active == nil { + return "", ErrOrgRequired + } + if active.OrgID == "" { + return "", ErrOrgRequired + } + return active.OrgID, nil +} + // SelectOrgForm lists organizations for a user and prompts them to select one. // It returns the selected org ID and any error. // If the user has only one organization, it's automatically selected.