fix(cli): add global --insecure for remote TLS verification
- commit
- 4fd0c2f58bcebc2fa772f438b34d74b90fdf4e27
- author
- brice · · 2026-02-06 19:38 UTC
- committer
- brice · · 2026-02-06 19:38 UTC
- parent
- 800ace0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +74 -9
M docs/cli/index.md +6 -0
| @@ -109,6 +109,7 @@ gordon remotes use prod | |||
| 109 | 109 | | `-c, --config` | Path to configuration file | | |
| 110 | 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | |
| 111 | 111 | | `--token` | Authentication token for remote | | |
| 112 | + | | `--insecure` | Skip TLS certificate verification for remote HTTPS endpoints | | |
| 112 | 113 | ||
| 113 | 114 | ### Remote Targeting | |
| 114 | 115 | ||
| @@ -118,10 +119,15 @@ when you want to bypass your saved configuration. | |||
| 118 | 119 | ||
| 119 | 120 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | |
| 120 | 121 | ||
| 122 | + | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |
| 123 | + | ||
| 121 | 124 | ```bash | |
| 122 | 125 | # Using flags (use the gordon_domain from remote Gordon config) | |
| 123 | 126 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |
| 124 | 127 | ||
| 128 | + | # Against self-signed/private CA endpoint | |
| 129 | + | gordon --remote https://gordon.mydomain.com --token $TOKEN --insecure status | |
| 130 | + | ||
| 125 | 131 | # Using environment variables | |
| 126 | 132 | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 127 | 133 | export GORDON_TOKEN=$TOKEN | |
| @@ -109,6 +109,7 @@ gordon remotes use prod | |||||
| 109 | | `-c, --config` | Path to configuration file | | 109 | | `-c, --config` | Path to configuration file | | ||
| 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | ||
| 111 | | `--token` | Authentication token for remote | | 111 | | `--token` | Authentication token for remote | | ||
| 112 | + | | `--insecure` | Skip TLS certificate verification for remote HTTPS endpoints | | |||
| 112 | 113 | ||||
| 113 | ### Remote Targeting | 114 | ### Remote Targeting | ||
| 114 | 115 | ||||
| @@ -118,10 +119,15 @@ when you want to bypass your saved configuration. | |||||
| 118 | 119 | ||||
| 119 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | 120 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | ||
| 120 | 121 | ||||
| 122 | + | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |||
| 123 | + | ||||
| 121 | ```bash | 124 | ```bash | ||
| 122 | # Using flags (use the gordon_domain from remote Gordon config) | 125 | # Using flags (use the gordon_domain from remote Gordon config) | ||
| 123 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | 126 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | ||
| 124 | 127 | ||||
| 128 | + | # Against self-signed/private CA endpoint | |||
| 129 | + | gordon --remote https://gordon.mydomain.com --token $TOKEN --insecure status | |||
| 130 | + | ||||
| 125 | # Using environment variables | 131 | # Using environment variables | ||
| 126 | export GORDON_REMOTE=https://gordon.mydomain.com | 132 | export GORDON_REMOTE=https://gordon.mydomain.com | ||
| 127 | export GORDON_TOKEN=$TOKEN | 133 | export GORDON_TOKEN=$TOKEN | ||
M internal/adapters/in/cli/auth.go +3 -3
| @@ -297,7 +297,7 @@ func runAuthLogin(remoteName, username, password string) error { | |||
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | // Create remote client | |
| 300 | - | client := remote.NewClient(remoteConfig.URL) | |
| 300 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions("")...) | |
| 301 | 301 | ||
| 302 | 302 | // Authenticate | |
| 303 | 303 | ctx := context.Background() | |
| @@ -332,7 +332,7 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 332 | 332 | return err | |
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | |
| 335 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token)...) | |
| 336 | 336 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 337 | 337 | defer cancel() | |
| 338 | 338 | verified := true | |
| @@ -758,7 +758,7 @@ func runAuthStatus(remoteNameArg string) error { | |||
| 758 | 758 | } | |
| 759 | 759 | ||
| 760 | 760 | // Create client | |
| 761 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token)) | |
| 761 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token)...) | |
| 762 | 762 | ||
| 763 | 763 | // Verify auth | |
| 764 | 764 | ctx := context.Background() | |
| @@ -297,7 +297,7 @@ func runAuthLogin(remoteName, username, password string) error { | |||||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | // Create remote client | 299 | // Create remote client | ||
| 300 | - | client := remote.NewClient(remoteConfig.URL) | 300 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions("")...) |
| 301 | 301 | ||||
| 302 | // Authenticate | 302 | // Authenticate | ||
| 303 | ctx := context.Background() | 303 | ctx := context.Background() | ||
| @@ -332,7 +332,7 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 332 | return err | 332 | return err | ||
| 333 | } | 333 | } | ||
| 334 | 334 | ||||
| 335 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | 335 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token)...) |
| 336 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 336 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| 337 | defer cancel() | 337 | defer cancel() | ||
| 338 | verified := true | 338 | verified := true | ||
| @@ -758,7 +758,7 @@ func runAuthStatus(remoteNameArg string) error { | |||||
| 758 | } | 758 | } | ||
| 759 | 759 | ||||
| 760 | // Create client | 760 | // Create client | ||
| 761 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token)) | 761 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token)...) |
| 762 | 762 | ||||
| 763 | // Verify auth | 763 | // Verify auth | ||
| 764 | ctx := context.Background() | 764 | ctx := context.Background() | ||
M internal/adapters/in/cli/remote/client.go +34 -3
| @@ -4,6 +4,7 @@ package remote | |||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | 6 | "context" | |
| 7 | + | "crypto/tls" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| @@ -19,9 +20,10 @@ import ( | |||
| 19 | 20 | ||
| 20 | 21 | // Client is an HTTP client for the Gordon admin API. | |
| 21 | 22 | type Client struct { | |
| 22 | - | baseURL string | |
| 23 | - | token string | |
| 24 | - | httpClient *http.Client | |
| 23 | + | baseURL string | |
| 24 | + | token string | |
| 25 | + | httpClient *http.Client | |
| 26 | + | insecureTLS bool | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | var ( | |
| @@ -48,6 +50,8 @@ func NewClient(baseURL string, opts ...ClientOption) *Client { | |||
| 48 | 50 | opt(c) | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 53 | + | c.applyTLSConfig() | |
| 54 | + | ||
| 51 | 55 | return c | |
| 52 | 56 | } | |
| 53 | 57 | ||
| @@ -72,6 +76,33 @@ func WithTimeout(timeout time.Duration) ClientOption { | |||
| 72 | 76 | } | |
| 73 | 77 | } | |
| 74 | 78 | ||
| 79 | + | // WithInsecureTLS disables TLS certificate verification for remote admin API requests. | |
| 80 | + | func WithInsecureTLS(insecure bool) ClientOption { | |
| 81 | + | return func(c *Client) { | |
| 82 | + | c.insecureTLS = insecure | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (c *Client) applyTLSConfig() { | |
| 87 | + | if !c.insecureTLS { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | var transport *http.Transport | |
| 92 | + | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | |
| 93 | + | transport = existing.Clone() | |
| 94 | + | } else { | |
| 95 | + | transport = http.DefaultTransport.(*http.Transport).Clone() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | if transport.TLSClientConfig == nil { | |
| 99 | + | transport.TLSClientConfig = &tls.Config{} | |
| 100 | + | } | |
| 101 | + | //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments. | |
| 102 | + | transport.TLSClientConfig.InsecureSkipVerify = true | |
| 103 | + | c.httpClient.Transport = transport | |
| 104 | + | } | |
| 105 | + | ||
| 75 | 106 | // request performs an HTTP request to the admin API. | |
| 76 | 107 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | |
| 77 | 108 | url := c.baseURL + "/admin" + path | |
| @@ -4,6 +4,7 @@ package remote | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "crypto/tls" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "fmt" | 10 | "fmt" | ||
| @@ -19,9 +20,10 @@ import ( | |||||
| 19 | 20 | ||||
| 20 | // Client is an HTTP client for the Gordon admin API. | 21 | // Client is an HTTP client for the Gordon admin API. | ||
| 21 | type Client struct { | 22 | type Client struct { | ||
| 22 | - | baseURL string | 23 | + | baseURL string |
| 23 | - | token string | 24 | + | token string |
| 24 | - | httpClient *http.Client | 25 | + | httpClient *http.Client |
| 26 | + | insecureTLS bool | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | var ( | 29 | var ( | ||
| @@ -48,6 +50,8 @@ func NewClient(baseURL string, opts ...ClientOption) *Client { | |||||
| 48 | opt(c) | 50 | opt(c) | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 53 | + | c.applyTLSConfig() | |||
| 54 | + | ||||
| 51 | return c | 55 | return c | ||
| 52 | } | 56 | } | ||
| 53 | 57 | ||||
| @@ -72,6 +76,33 @@ func WithTimeout(timeout time.Duration) ClientOption { | |||||
| 72 | } | 76 | } | ||
| 73 | } | 77 | } | ||
| 74 | 78 | ||||
| 79 | + | // WithInsecureTLS disables TLS certificate verification for remote admin API requests. | |||
| 80 | + | func WithInsecureTLS(insecure bool) ClientOption { | |||
| 81 | + | return func(c *Client) { | |||
| 82 | + | c.insecureTLS = insecure | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (c *Client) applyTLSConfig() { | |||
| 87 | + | if !c.insecureTLS { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | var transport *http.Transport | |||
| 92 | + | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | |||
| 93 | + | transport = existing.Clone() | |||
| 94 | + | } else { | |||
| 95 | + | transport = http.DefaultTransport.(*http.Transport).Clone() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | if transport.TLSClientConfig == nil { | |||
| 99 | + | transport.TLSClientConfig = &tls.Config{} | |||
| 100 | + | } | |||
| 101 | + | //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments. | |||
| 102 | + | transport.TLSClientConfig.InsecureSkipVerify = true | |||
| 103 | + | c.httpClient.Transport = transport | |||
| 104 | + | } | |||
| 105 | + | ||||
| 75 | // request performs an HTTP request to the admin API. | 106 | // request performs an HTTP request to the admin API. | ||
| 76 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | 107 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | ||
| 77 | url := c.baseURL + "/admin" + path | 108 | url := c.baseURL + "/admin" + path | ||
M internal/adapters/in/cli/remote/client_test.go +15 -0
| @@ -93,3 +93,18 @@ func TestClientDeployReturnsErrorAfterRetryExhaustion(t *testing.T) { | |||
| 93 | 93 | assert.Contains(t, err.Error(), "500 Internal Server Error: failed to deploy container") | |
| 94 | 94 | assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts)) | |
| 95 | 95 | } | |
| 96 | + | ||
| 97 | + | func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |
| 98 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 99 | + | require.Equal(t, "/admin/status", r.URL.Path) | |
| 100 | + | w.Header().Set("Content-Type", "application/json") | |
| 101 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |
| 102 | + | })) | |
| 103 | + | defer srv.Close() | |
| 104 | + | ||
| 105 | + | _, err := NewClient(srv.URL).GetStatus(context.Background()) | |
| 106 | + | require.Error(t, err) | |
| 107 | + | ||
| 108 | + | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | } | |
| @@ -93,3 +93,18 @@ func TestClientDeployReturnsErrorAfterRetryExhaustion(t *testing.T) { | |||||
| 93 | assert.Contains(t, err.Error(), "500 Internal Server Error: failed to deploy container") | 93 | assert.Contains(t, err.Error(), "500 Internal Server Error: failed to deploy container") | ||
| 94 | assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts)) | 94 | assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts)) | ||
| 95 | } | 95 | } | ||
| 96 | + | ||||
| 97 | + | func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 98 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 99 | + | require.Equal(t, "/admin/status", r.URL.Path) | |||
| 100 | + | w.Header().Set("Content-Type", "application/json") | |||
| 101 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |||
| 102 | + | })) | |||
| 103 | + | defer srv.Close() | |||
| 104 | + | ||||
| 105 | + | _, err := NewClient(srv.URL).GetStatus(context.Background()) | |||
| 106 | + | require.Error(t, err) | |||
| 107 | + | ||||
| 108 | + | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | } | |||
M internal/adapters/in/cli/root.go +16 -3
| @@ -22,8 +22,9 @@ var ( | |||
| 22 | 22 | BuildDate = "unknown" | |
| 23 | 23 | ||
| 24 | 24 | // Global flags for remote targeting | |
| 25 | - | remoteFlag string | |
| 26 | - | tokenFlag string | |
| 25 | + | remoteFlag string | |
| 26 | + | tokenFlag string | |
| 27 | + | insecureTLSFlag bool | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | 30 | // Command group IDs | |
| @@ -60,6 +61,7 @@ Commands are organized by where they run: | |||
| 60 | 61 | // Add persistent flags for remote targeting | |
| 61 | 62 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | |
| 62 | 63 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | |
| 64 | + | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | |
| 63 | 65 | ||
| 64 | 66 | // Server-only commands (must run on the Gordon host) | |
| 65 | 67 | serveCmd := newServeCmd() | |
| @@ -139,10 +141,21 @@ func GetRemoteClient() (*remote.Client, bool) { | |||
| 139 | 141 | return nil, false | |
| 140 | 142 | } | |
| 141 | 143 | ||
| 142 | - | client := remote.NewClient(url, remote.WithToken(token)) | |
| 144 | + | client := remote.NewClient(url, remoteClientOptions(token)...) | |
| 143 | 145 | return client, true | |
| 144 | 146 | } | |
| 145 | 147 | ||
| 148 | + | func remoteClientOptions(token string) []remote.ClientOption { | |
| 149 | + | opts := make([]remote.ClientOption, 0, 2) | |
| 150 | + | if token != "" { | |
| 151 | + | opts = append(opts, remote.WithToken(token)) | |
| 152 | + | } | |
| 153 | + | if insecureTLSFlag { | |
| 154 | + | opts = append(opts, remote.WithInsecureTLS(true)) | |
| 155 | + | } | |
| 156 | + | return opts | |
| 157 | + | } | |
| 158 | + | ||
| 146 | 159 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | |
| 147 | 160 | func IsRemoteMode() bool { | |
| 148 | 161 | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |
| @@ -22,8 +22,9 @@ var ( | |||||
| 22 | BuildDate = "unknown" | 22 | BuildDate = "unknown" | ||
| 23 | 23 | ||||
| 24 | // Global flags for remote targeting | 24 | // Global flags for remote targeting | ||
| 25 | - | remoteFlag string | 25 | + | remoteFlag string |
| 26 | - | tokenFlag string | 26 | + | tokenFlag string |
| 27 | + | insecureTLSFlag bool | |||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | // Command group IDs | 30 | // Command group IDs | ||
| @@ -60,6 +61,7 @@ Commands are organized by where they run: | |||||
| 60 | // Add persistent flags for remote targeting | 61 | // Add persistent flags for remote targeting | ||
| 61 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | 62 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | ||
| 62 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | 63 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | ||
| 64 | + | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | |||
| 63 | 65 | ||||
| 64 | // Server-only commands (must run on the Gordon host) | 66 | // Server-only commands (must run on the Gordon host) | ||
| 65 | serveCmd := newServeCmd() | 67 | serveCmd := newServeCmd() | ||
| @@ -139,10 +141,21 @@ func GetRemoteClient() (*remote.Client, bool) { | |||||
| 139 | return nil, false | 141 | return nil, false | ||
| 140 | } | 142 | } | ||
| 141 | 143 | ||||
| 142 | - | client := remote.NewClient(url, remote.WithToken(token)) | 144 | + | client := remote.NewClient(url, remoteClientOptions(token)...) |
| 143 | return client, true | 145 | return client, true | ||
| 144 | } | 146 | } | ||
| 145 | 147 | ||||
| 148 | + | func remoteClientOptions(token string) []remote.ClientOption { | |||
| 149 | + | opts := make([]remote.ClientOption, 0, 2) | |||
| 150 | + | if token != "" { | |||
| 151 | + | opts = append(opts, remote.WithToken(token)) | |||
| 152 | + | } | |||
| 153 | + | if insecureTLSFlag { | |||
| 154 | + | opts = append(opts, remote.WithInsecureTLS(true)) | |||
| 155 | + | } | |||
| 156 | + | return opts | |||
| 157 | + | } | |||
| 158 | + | ||||
| 146 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | 159 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | ||
| 147 | func IsRemoteMode() bool { | 160 | func IsRemoteMode() bool { | ||
| 148 | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | 161 | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | ||