From 60cc3f153b0ab99e4dead05991e1d183266840ac Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Nov 2026 12:40:00 +0200 Subject: [PATCH 1/5] feat(netcheck): diagnose proxy and TLS handshake failures - Add `armis-cli mcp doctor --network`, which reports the proxy in use and where it comes from, DNS, TCP connect, TLS handshake and certificate issuer. When a proxy is in use it also tries a direct connection, so users can tell whether the proxy is the cause. No credentials are sent. - Run the same check as a "CLI network" row in the regular `mcp doctor`. The CLI follows the Windows system proxy and PAC file but the server's Python runtime does not, so one can work while the other fails. - Explain "TLS handshake timeout" and "proxyconnect" errors in auth, device and IdP calls. The message names the proxy, its source, and the exact override to try. - Add `ARMIS_NO_SYSTEM_PROXY` to ignore OS proxy settings and `ARMIS_TLS_HANDSHAKE_TIMEOUT` to raise the 10s handshake limit for slow inspecting proxies. - Document both in the README. --- README.md | 24 +++ internal/auth/client.go | 4 +- internal/auth/device.go | 2 +- internal/auth/idpconfig.go | 2 +- internal/cmd/mcp_doctor.go | 47 +++++ internal/cmd/mcp_doctor_test.go | 51 +++++ internal/httpclient/client.go | 2 +- internal/httpclient/explain.go | 58 +++++ internal/httpclient/explain_test.go | 134 ++++++++++++ internal/httpclient/transport.go | 134 +++++++++++- internal/install/doctor.go | 30 +++ internal/install/doctor_diagnose_test.go | 50 +++++ internal/netcheck/netcheck.go | 256 +++++++++++++++++++++++ internal/netcheck/netcheck_test.go | 188 +++++++++++++++++ 14 files changed, 971 insertions(+), 11 deletions(-) create mode 100644 internal/httpclient/explain.go create mode 100644 internal/httpclient/explain_test.go create mode 100644 internal/netcheck/netcheck.go create mode 100644 internal/netcheck/netcheck_test.go diff --git a/README.md b/README.md index 903ebdf..6cede0d 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,28 @@ If you see "command not found" after installation: & "$env:LOCALAPPDATA\armis-cli\armis-cli.exe" --help ``` +### Troubleshooting: timeouts, "EOF" or TLS errors behind a proxy + +If a login or scan fails with `TLS handshake timeout`, `EOF`, or `proxyconnect`, run: + +```bash +armis-cli mcp doctor --network +``` + +This works without any MCP install. It connects to the Armis API the way the CLI does and prints each step with timings: the proxy in use and where that setting comes from, DNS, the TCP connection, the TLS handshake, and who issued the server certificate. When a proxy is in use it also tries a direct connection, so you can see whether the proxy is the problem. No credentials are sent. Plain `armis-cli mcp doctor` runs the same check as its "CLI network" row. Include the output when you ask for help. + +How the CLI chooses a proxy: + +- If `HTTPS_PROXY` or `HTTP_PROXY` is set, the CLI uses it, and `NO_PROXY` exempts hosts. +- Otherwise on Windows it uses the system proxy settings, including a PAC file. If a PAC file lists several proxies, only the first one is used. On macOS and Linux it uses only the environment variables. + +Options: + +| Variable | Effect | +|---|---| +| `ARMIS_NO_SYSTEM_PROXY=1` | Ignore the operating system's proxy settings and use only `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. Use it only where a direct connection is allowed. | +| `ARMIS_TLS_HANDSHAKE_TIMEOUT=30s` | Wait longer than the default 10 seconds for the TLS handshake (a Go duration, or a number of seconds). Useful when a proxy inspects traffic slowly. | + ### Shell Completion `armis-cli` ships tab-completion for commands, subcommands, and flag values (e.g. @@ -1180,6 +1202,8 @@ Before users can sign in with SSO, an IT admin registers the tenant's identity p | `ARMIS_PAGE_LIMIT` | Results pagination size (default: 500) | | `ARMIS_THEME` | Terminal background theme: auto, dark, light (default: auto) | | `ARMIS_NO_UPDATE_CHECK` | Disable automatic update checking | +| `ARMIS_NO_SYSTEM_PROXY` | Ignore the operating system's proxy settings (see [timeouts behind a proxy](#troubleshooting-timeouts-eof-or-tls-errors-behind-a-proxy)) | +| `ARMIS_TLS_HANDSHAKE_TIMEOUT` | TLS handshake limit, default 10s (Go duration or seconds) | --- diff --git a/internal/auth/client.go b/internal/auth/client.go index a3d2c10..8d0bf06 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -191,9 +191,9 @@ func (c *AuthClient) Authenticate(ctx context.Context, clientID, clientSecret st // so log it here when debugging. The URL is non-sensitive; the request // body (which carries credentials) is intentionally never logged. if c.debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Auth transport error to %s: %v\n", authEndpoint, err) + fmt.Fprintf(os.Stderr, "[DEBUG] Auth transport error to %s (%s): %v\n", authEndpoint, httpclient.ProxyUsedFor(req.URL.Host), err) } - return nil, fmt.Errorf("authentication request failed: %w", annotateTransportError(err)) + return nil, fmt.Errorf("authentication request failed: %w", annotateTransportError(httpclient.ExplainTransportError(err, req.URL))) } defer resp.Body.Close() //nolint:errcheck // response body read-only diff --git a/internal/auth/device.go b/internal/auth/device.go index 164682e..a7d5836 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -289,7 +289,7 @@ func (c *DeviceClient) postForm(ctx context.Context, path string, form url.Value resp, err := c.httpClient.Do(req) //nolint:gosec // endpoint built from validated config, not user input if err != nil { - return nil, 0, fmt.Errorf("request failed: %w", annotateTransportError(err)) + return nil, 0, fmt.Errorf("request failed: %w", annotateTransportError(httpclient.ExplainTransportError(err, req.URL))) } defer resp.Body.Close() //nolint:errcheck // response body read-only diff --git a/internal/auth/idpconfig.go b/internal/auth/idpconfig.go index d658b16..e3aebd1 100644 --- a/internal/auth/idpconfig.go +++ b/internal/auth/idpconfig.go @@ -197,7 +197,7 @@ func (c *IdpConfigClient) send(ctx context.Context, method, path string, body an resp, err := c.httpClient.Do(httpReq) //nolint:gosec // endpoint built from validated config, not user input if err != nil { - return nil, fmt.Errorf("request failed: %w", annotateTransportError(err)) + return nil, fmt.Errorf("request failed: %w", annotateTransportError(httpclient.ExplainTransportError(err, httpReq.URL))) } defer resp.Body.Close() //nolint:errcheck // response body read-only diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index a34c533..4efa9fb 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -13,6 +13,7 @@ import ( "github.com/ArmisSecurity/armis-cli/internal/cli" "github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil" "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/ArmisSecurity/armis-cli/internal/netcheck" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -27,6 +28,7 @@ var ( mcpDoctorVerbose bool mcpDoctorDeep bool mcpDoctorWatch bool + mcpDoctorNetwork bool mcpDoctorStuckAfter time.Duration ) @@ -44,6 +46,9 @@ Unless --no-handshake is set, it also: - checks that the client credentials are accepted by the Armis API - checks that the server's own Python runtime can reach the Armis API, which catches proxy and TLS-inspection problems the CLI itself doesn't hit + - checks that the CLI can reach the Armis API. The CLI follows the Windows + system proxy and PAC file; the server's Python runtime does not, so one + can work while the other fails For VS Code (GitHub Copilot) it additionally checks VS Code Insiders and VSCodium, per-profile and workspace configs, duplicate entries, settings and @@ -66,6 +71,14 @@ the Armis API. When a call stops answering, the doctor records which child process the server was waiting on and, for Python servers on macOS and Linux, a stack dump. +--network runs only the CLI's connection check, with no MCP install needed. It +connects to the Armis API the way the CLI does and reports each step with +timings: which proxy is used and where that setting comes from, DNS, the TCP +connection, the TLS handshake, and who issued the server certificate. When a +proxy is in use it also tries a direct connection, so you can see whether the +proxy is the problem. No credentials are sent. Use it when logins or scans fail +with timeouts, "EOF", or TLS errors. + --watch does not run the checks. It follows the scanner's server.log and prints each tool call as it happens, so you can reproduce the problem in your editor. A call that stays open longer than --watch-stuck-after is reported along with @@ -90,6 +103,9 @@ Exits non-zero if any check fails.`, # Also run a real scan call to find stalls on the scan path armis-cli mcp doctor --deep + # Only check how the CLI reaches the Armis API (proxy, TLS, certificates) + armis-cli mcp doctor --network + # Follow the server log while you reproduce the problem in your editor armis-cli mcp doctor --watch @@ -109,6 +125,7 @@ func init() { mcpDoctorCmd.Flags().StringVar(&mcpDoctorBundlePath, "bundle-path", "", "Where to write the support bundle (implies --bundle; default: ./armis-mcp-doctor-.zip)") mcpDoctorCmd.Flags().BoolVar(&mcpDoctorDeep, "deep", false, "Also call scan_file on a throwaway file to test the real scan path (sends a two-line snippet to the Armis API)") mcpDoctorCmd.Flags().BoolVar(&mcpDoctorWatch, "watch", false, "Follow the scanner's server.log and flag tool calls that stay open, instead of running the checks") + mcpDoctorCmd.Flags().BoolVar(&mcpDoctorNetwork, "network", false, "Only check how the CLI reaches the Armis API (proxy, TLS handshake, certificates); no MCP install needed") mcpDoctorCmd.Flags().DurationVar(&mcpDoctorStuckAfter, "watch-stuck-after", install.DefaultWatchStuckAfter, "With --watch: how long a tool call may stay open before it is reported as stuck") mcpDoctorCmd.Flags().BoolVarP(&mcpDoctorVerbose, "verbose", "v", false, "List every check, including passing ones and informational notes") } @@ -120,6 +137,9 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error { return fmt.Errorf("invalid --format value %q: must be plain or json", mcpDoctorFormat) } + if mcpDoctorNetwork { + return runMCPDoctorNetwork(cmd) + } if mcpDoctorWatch { return runMCPDoctorWatch(cmd) } @@ -135,6 +155,9 @@ func runMCPDoctor(cmd *cobra.Command, _ []string) error { Deep: mcpDoctorDeep, Timeout: mcpDoctorTimeout, AuthCheck: doctorAuthCheck, + NetCheck: func(ctx context.Context, url string, w io.Writer) bool { + return netcheck.Run(ctx, netcheck.Options{BaseURL: url, Out: w}) + }, } stderr := cmd.ErrOrStderr() @@ -484,3 +507,27 @@ func statusSymbol(s install.CheckStatus, accessible bool) string { return lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗") } } + +// runMCPDoctorNetwork runs only the CLI connection check. It does not need an +// MCP install, because the failures it finds (proxy, TLS inspection, firewall) +// break logins and scans as well. +func runMCPDoctorNetwork(cmd *cobra.Command) error { + for name, set := range map[string]bool{ + "--watch": mcpDoctorWatch, + "--fix": mcpDoctorFix, + "--bundle": mcpDoctorBundle || mcpDoctorBundlePath != "", + "--deep": mcpDoctorDeep, + "--no-handshake": mcpDoctorNoHandshake, + } { + if set { + return fmt.Errorf("--network runs on its own; remove %s", name) + } + } + if mcpDoctorFormat != agentFormatPlain { + return fmt.Errorf("--network prints a plain-text report; remove --format") + } + if !netcheck.Run(cmd.Context(), netcheck.Options{BaseURL: getAPIBaseURL(), Out: cmd.ErrOrStderr()}) { + return fmt.Errorf("the Armis API could not be reached with the current network settings") + } + return nil +} diff --git a/internal/cmd/mcp_doctor_test.go b/internal/cmd/mcp_doctor_test.go index dee9f1c..be9bdb9 100644 --- a/internal/cmd/mcp_doctor_test.go +++ b/internal/cmd/mcp_doctor_test.go @@ -284,3 +284,54 @@ func TestMCPDoctorRejectsIgnoredWatchFlags(t *testing.T) { t.Errorf("--watch with --verbose: err = %v", err) } } + +func TestMCPDoctorNetworkFlag(t *testing.T) { + if f := mcpDoctorCmd.Flags().Lookup("network"); f == nil || f.DefValue != "false" { + t.Fatalf("mcp doctor needs a --network flag defaulting to false, got %v", f) + } + + set := func(t *testing.T, name, value string) { + t.Helper() + if err := mcpDoctorCmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + f := mcpDoctorCmd.Flags().Lookup(name) + _ = f.Value.Set(f.DefValue) + f.Changed = false + }) + } + + t.Run("cannot be combined with other modes", func(t *testing.T) { + set(t, "network", "true") + set(t, "fix", "true") + err := runMCPDoctor(mcpDoctorCmd, nil) + if err == nil || !strings.Contains(err.Error(), "remove --fix") { + t.Errorf("err = %v", err) + } + }) + + t.Run("prints a plain report only", func(t *testing.T) { + set(t, "network", "true") + set(t, "format", "json") + err := runMCPDoctor(mcpDoctorCmd, nil) + if err == nil || !strings.Contains(err.Error(), "plain-text report") { + t.Errorf("err = %v", err) + } + }) + + t.Run("reports an unreachable API without needing an install", func(t *testing.T) { + t.Setenv("ARMIS_API_URL", "https://127.0.0.1:1") + set(t, "network", "true") + var out strings.Builder + mcpDoctorCmd.SetErr(&out) + t.Cleanup(func() { mcpDoctorCmd.SetErr(nil) }) + err := runMCPDoctor(mcpDoctorCmd, nil) + if err == nil || !strings.Contains(err.Error(), "could not be reached") { + t.Errorf("err = %v", err) + } + if !strings.Contains(out.String(), "Armis connectivity check") || !strings.Contains(out.String(), "The API is not reachable") { + t.Errorf("report = %q", out.String()) + } + }) +} diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index 2bb59cf..a955b82 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -118,7 +118,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { err = backoff.Retry(operation, backoff.WithContext(b, ctx)) } if err != nil { - return nil, err + return nil, ExplainTransportError(err, req.URL) } // Defensive check: resp should never be nil at this point since operation diff --git a/internal/httpclient/explain.go b/internal/httpclient/explain.go new file mode 100644 index 0000000..a5d6f55 --- /dev/null +++ b/internal/httpclient/explain.go @@ -0,0 +1,58 @@ +package httpclient + +import ( + "fmt" + "net/url" + "strings" +) + +// ProxyUsedFor describes the proxy chosen for the most recent request to host +// (as it appears in the request URL), or "" when no request to it was made. +func ProxyUsedFor(host string) string { + v, ok := proxyChoices.Load(host) + if !ok { + return "" + } + return DescribeProxy(v.(proxyChoice).url) +} + +// ExplainTransportError adds guidance to the two failures that proxies and +// firewalls cause most often: a TLS handshake that never finishes, and a proxy +// that cannot be reached. Other errors are returned unchanged. +func ExplainTransportError(err error, target *url.URL) error { + if err == nil || target == nil { + return err + } + msg := err.Error() + handshake := strings.Contains(msg, "TLS handshake timeout") + proxyConnect := strings.Contains(msg, "proxyconnect") + if !handshake && !proxyConnect { + return err + } + + host := target.Hostname() + var proxy *url.URL + if v, ok := proxyChoices.Load(target.Host); ok { + proxy = v.(proxyChoice).url + } + + var hint string + switch { + case proxy != nil && proxyConnect: + hint = fmt.Sprintf("the CLI tried to reach %s through the proxy %s, taken from %s, and could not connect to that proxy. "+ + "Check the proxy address. If your PAC file lists several proxies, only the first one is used", host, DescribeProxy(proxy), ProxySource()) + case proxy != nil: + hint = fmt.Sprintf("the connection to %s went through the proxy %s, taken from %s, and the TLS handshake did not finish within %s. "+ + "Ask your network team to allow %s through the proxy or exclude it from TLS inspection", host, DescribeProxy(proxy), ProxySource(), TLSHandshakeTimeout(), host) + case handshake: + hint = fmt.Sprintf("the CLI connected to %s directly (no proxy is configured for it) and the TLS handshake did not finish within %s. "+ + "A firewall, VPN or security agent may be blocking or delaying the connection. If your network requires a proxy, set HTTPS_PROXY", host, TLSHandshakeTimeout()) + default: + return err + } + if proxy != nil { + hint += fmt.Sprintf(". To try without the system proxy, set %s=1 (or set HTTPS_PROXY to a working proxy and NO_PROXY=%s)", EnvNoSystemProxy, host) + } + hint += ". Run 'armis-cli mcp doctor --network' for a step-by-step report" + return fmt.Errorf("%w (%s)", err, hint) +} diff --git a/internal/httpclient/explain_test.go b/internal/httpclient/explain_test.go new file mode 100644 index 0000000..badf263 --- /dev/null +++ b/internal/httpclient/explain_test.go @@ -0,0 +1,134 @@ +package httpclient + +import ( + "errors" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestTLSHandshakeTimeout(t *testing.T) { + tests := []struct { + name string + env string + want time.Duration + }{ + {"unset uses the default", "", 10 * time.Second}, + {"go duration", "30s", 30 * time.Second}, + {"plain seconds", "45", 45 * time.Second}, + {"garbage falls back", "soon", 10 * time.Second}, + {"zero falls back", "0", 10 * time.Second}, + {"negative falls back", "-5s", 10 * time.Second}, + {"over the cap falls back", "10m", 10 * time.Second}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(EnvTLSHandshakeTimeout, tt.env) + if got := TLSHandshakeTimeout(); got != tt.want { + t.Errorf("TLSHandshakeTimeout() = %v, want %v", got, tt.want) + } + if got := ProxyAwareTransport().TLSHandshakeTimeout; got != tt.want { + t.Errorf("transport TLSHandshakeTimeout = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNoSystemProxyUsesEnvironmentOnly(t *testing.T) { + want := &url.URL{Scheme: "http", Host: "proxy.example.com:8080"} + old := envProxy + envProxy = func(*http.Request) (*url.URL, error) { return want, nil } + t.Cleanup(func() { envProxy = old }) + t.Setenv(EnvNoSystemProxy, "1") + + target, _ := url.Parse("https://api.example.com/") + got, source, err := ProxyFor(target) + if err != nil { + t.Fatalf("ProxyFor: %v", err) + } + if got != want { + t.Errorf("ProxyFor = %v, want %v", got, want) + } + if !strings.Contains(source, EnvNoSystemProxy) { + t.Errorf("source %q should mention %s", source, EnvNoSystemProxy) + } + + tr := ProxyAwareTransport() + req := &http.Request{URL: target} + if u, _ := tr.Proxy(req); u != want { + t.Errorf("transport proxy = %v, want %v", u, want) + } + if desc := ProxyUsedFor(target.Host); desc != "http://proxy.example.com:8080" { + t.Errorf("ProxyUsedFor = %q", desc) + } +} + +func TestDescribeProxy(t *testing.T) { + tests := []struct { + name string + in *url.URL + want string + }{ + {"nil is direct", nil, "direct connection (no proxy)"}, + {"bare host from a PAC result", &url.URL{Host: "10.0.0.1:8080"}, "http://10.0.0.1:8080"}, + {"credentials are hidden", &url.URL{Scheme: "http", User: url.UserPassword("u", "secret"), Host: "p:80"}, "http://u:xxxxx@p:80"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DescribeProxy(tt.in); got != tt.want { + t.Errorf("DescribeProxy = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExplainTransportError(t *testing.T) { + target, _ := url.Parse("https://api.example.com/path") + handshake := errors.New(`Post "https://api.example.com/path": net/http: TLS handshake timeout`) + proxyConnect := errors.New(`Post "https://api.example.com/path": proxyconnect tcp: dial tcp 10.0.0.1:8080: connectex: refused`) + + t.Run("unrelated errors are untouched", func(t *testing.T) { + other := errors.New("boom") + if got := ExplainTransportError(other, target); got != other { + t.Errorf("got %v, want the original error", got) + } + if got := ExplainTransportError(nil, target); got != nil { + t.Errorf("nil error became %v", got) + } + }) + + t.Run("handshake timeout through a proxy names the proxy", func(t *testing.T) { + proxyChoices.Store(target.Host, proxyChoice{url: &url.URL{Scheme: "http", User: url.UserPassword("u", "secret"), Host: "proxy.corp:8080"}}) + got := ExplainTransportError(handshake, target) + msg := got.Error() + for _, want := range []string{"proxy.corp:8080", EnvNoSystemProxy, "mcp doctor --network", "TLS inspection", "NO_PROXY=api.example.com"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q should contain %q", msg, want) + } + } + if strings.Contains(msg, "secret") { + t.Errorf("proxy password leaked into %q", msg) + } + if !errors.Is(got, handshake) { + t.Error("the original error must stay in the chain") + } + }) + + t.Run("proxy connect failure says the proxy was unreachable", func(t *testing.T) { + proxyChoices.Store(target.Host, proxyChoice{url: &url.URL{Scheme: "http", Host: "10.0.0.1:8080"}}) + msg := ExplainTransportError(proxyConnect, target).Error() + if !strings.Contains(msg, "could not connect to that proxy") || !strings.Contains(msg, "only the first one is used") { + t.Errorf("unexpected message %q", msg) + } + }) + + t.Run("handshake timeout without a proxy says direct", func(t *testing.T) { + proxyChoices.Store(target.Host, proxyChoice{url: nil}) + msg := ExplainTransportError(handshake, target).Error() + if !strings.Contains(msg, "directly") || strings.Contains(msg, EnvNoSystemProxy) { + t.Errorf("unexpected message %q", msg) + } + }) +} diff --git a/internal/httpclient/transport.go b/internal/httpclient/transport.go index 076d697..9eae7ea 100644 --- a/internal/httpclient/transport.go +++ b/internal/httpclient/transport.go @@ -3,21 +3,50 @@ package httpclient import ( "net/http" "net/url" + "os" + "runtime" + "strconv" + "strings" "sync" + "time" "github.com/mattn/go-ieproxy" ) -// proxyFunc resolves the proxy for a request from the operating system's -// proxy configuration. It is computed once and reused: on Windows, -// ieproxy.GetProxyFunc() reads the WinINET settings (including a PAC file -// referenced by AutoConfigURL) on first use, and we avoid repeating that work -// on every request. +const ( + // EnvNoSystemProxy makes the CLI ignore the operating system's proxy + // settings (WinINET and PAC on Windows) and use only the HTTP_PROXY, + // HTTPS_PROXY and NO_PROXY environment variables. + EnvNoSystemProxy = "ARMIS_NO_SYSTEM_PROXY" + + // EnvTLSHandshakeTimeout overrides how long the CLI waits for a TLS + // handshake to finish. It accepts a Go duration ("30s") or a number of + // seconds ("30"). + EnvTLSHandshakeTimeout = "ARMIS_TLS_HANDSHAKE_TIMEOUT" + + defaultTLSHandshakeTimeout = 10 * time.Second + maxTLSHandshakeTimeout = 5 * time.Minute +) + +// proxyFuncOnce and proxyFunc cache the operating system's proxy resolver. On +// Windows, ieproxy.GetProxyFunc() reads the WinINET settings (including a PAC +// file referenced by AutoConfigURL) on first use, and we avoid repeating that +// work on every request. var ( proxyFuncOnce sync.Once proxyFunc func(*http.Request) (*url.URL, error) ) +// envProxy is a variable so tests can bypass the process-wide environment +// snapshot that http.ProxyFromEnvironment takes on first use. +var envProxy = http.ProxyFromEnvironment + +// proxyChoices remembers, per request host, the proxy chosen for the most +// recent request, so a failure can name the proxy it went through. +var proxyChoices sync.Map // host -> proxyChoice + +type proxyChoice struct{ url *url.URL } + // ProxyAwareTransport returns an *http.Transport that resolves proxies from the // host operating system's configuration rather than from environment variables // alone. @@ -36,6 +65,9 @@ var ( // CGO_ENABLED=0 release builds, where the cgo system-preferences path is // compiled out — it falls back to the standard environment-variable behavior, // so existing setups are unaffected. +// +// Setting ARMIS_NO_SYSTEM_PROXY skips the operating system lookup, and +// ARMIS_TLS_HANDSHAKE_TIMEOUT changes the 10 second handshake limit. func ProxyAwareTransport() *http.Transport { // http.DefaultTransport is documented as *http.Transport; guard the assertion // so a future stdlib change degrades to a fresh transport instead of panicking. @@ -44,10 +76,28 @@ func ProxyAwareTransport() *http.Transport { base = &http.Transport{} } transport := base.Clone() - transport.Proxy = systemProxyFunc() + transport.Proxy = proxyForRequest + transport.TLSHandshakeTimeout = TLSHandshakeTimeout() return transport } +// proxyForRequest is a package-level function (not a closure) so every +// transport shares the same resolver. +func proxyForRequest(req *http.Request) (*url.URL, error) { + u, err := resolveProxy(req) + if err == nil && req.URL != nil { + proxyChoices.Store(req.URL.Host, proxyChoice{url: u}) + } + return u, err +} + +func resolveProxy(req *http.Request) (*url.URL, error) { + if envTrue(EnvNoSystemProxy) { + return envProxy(req) + } + return systemProxyFunc()(req) +} + // systemProxyFunc returns the cached OS proxy resolver, initializing it once. func systemProxyFunc() func(*http.Request) (*url.URL, error) { proxyFuncOnce.Do(func() { @@ -55,3 +105,75 @@ func systemProxyFunc() func(*http.Request) (*url.URL, error) { }) return proxyFunc } + +// ProxyFor reports the proxy the CLI would use for target (nil means a direct +// connection) and a description of where that setting comes from. +func ProxyFor(target *url.URL) (*url.URL, string, error) { + u, err := resolveProxy(&http.Request{URL: target}) + return u, ProxySource(), err +} + +// ProxySource describes which configuration decides the proxy. +func ProxySource() string { + switch { + case envTrue(EnvNoSystemProxy): + return "environment variables only (" + EnvNoSystemProxy + " is set)" + case proxyEnvSet(): + return "the HTTPS_PROXY/HTTP_PROXY environment variable" + case runtime.GOOS == "windows": + return "the Windows system proxy settings (PAC/WinINET)" + default: + return "the environment (no proxy variables are set)" + } +} + +// DescribeProxy formats a proxy for display, hiding any credentials in it. +func DescribeProxy(u *url.URL) string { + if u == nil { + return "direct connection (no proxy)" + } + if u.Scheme == "" { + // PAC and WinINET results are bare host:port values. + withScheme := *u + withScheme.Scheme = "http" + return withScheme.Redacted() + } + return u.Redacted() +} + +// TLSHandshakeTimeout returns the handshake limit in effect. +func TLSHandshakeTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv(EnvTLSHandshakeTimeout)) + if raw == "" { + return defaultTLSHandshakeTimeout + } + d, err := time.ParseDuration(raw) + if err != nil { + secs, convErr := strconv.Atoi(raw) + if convErr != nil { + return defaultTLSHandshakeTimeout + } + d = time.Duration(secs) * time.Second + } + if d <= 0 || d > maxTLSHandshakeTimeout { + return defaultTLSHandshakeTimeout + } + return d +} + +func envTrue(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +func proxyEnvSet() bool { + for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} { + if os.Getenv(name) != "" { + return true + } + } + return false +} diff --git a/internal/install/doctor.go b/internal/install/doctor.go index 24d5d2b..2add8d0 100644 --- a/internal/install/doctor.go +++ b/internal/install/doctor.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -267,6 +268,11 @@ type DoctorOptions struct { // credentials against the Armis API. Injected by the caller so this // package stays independent of the auth client. AuthCheck func(ctx context.Context, clientID, clientSecret string) error + // NetCheck, when set and Handshake is true, checks the connection to the + // Armis API with the CLI's own HTTP stack and writes the step-by-step + // report to w. It returns whether the API was reachable. Injected by the + // caller so this package stays independent of the transport. + NetCheck func(ctx context.Context, url string, w io.Writer) bool // Deep, when true (and Handshake is true), also calls the scanner's // scan_file on a small throwaway file. Unlike debug_config, scan_file runs // git and calls the Armis API, so it finds stalls on the real scan path. @@ -511,10 +517,32 @@ func checkScannerPlugin(d *doctorRun, ei *EditorInstaller) { // loading .env itself, which it does from its own directory. d.probe(component, "", serverLaunch{Command: pythonPath, Args: []string{serverPy}, EnvFile: ei.EnvFilePath(), Env: env}) checkServerNetwork(d, component, pythonPath, env, ei.EnvFilePath(), serverAPIURL(env), false) + checkCLINetwork(d, component, serverAPIURL(env)) checkAuth(d, component, env) } } +// checkCLINetwork runs the same connection check as 'armis-cli mcp doctor --network'. The +// server's Python runtime and the CLI resolve proxies differently (the CLI +// follows the Windows system proxy and PAC file, Python does not), so one can +// reach the API while the other cannot. +func checkCLINetwork(d *doctorRun, component, url string) { + if d.opts.NetCheck == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*networkProbeTimeout+5*time.Second) + defer cancel() + var report bytes.Buffer + ok := d.opts.NetCheck(ctx, url, &report) + d.report.artifact(sanitizeArtifactName(component)+"/cli-net-check.txt", report.String()) + if !ok { + d.report.add(component, "CLI network", StatusFail, url+" is not reachable with the CLI's network settings"). + hint("Run 'armis-cli mcp doctor --network' for the step-by-step report: which proxy the CLI uses, where the TLS handshake stops, and whether a direct connection works. The same report is in the support bundle.") + return + } + d.report.add(component, "CLI network", StatusOK, url+" reachable from the CLI").short("CLI reaches API") +} + // checkVenvBase verifies the interpreter a venv was created from still // exists. A venv's python.exe on Windows is a thin launcher that execs the // base interpreter recorded in pyvenv.cfg; uninstalling or upgrading that @@ -726,6 +754,8 @@ func checkAuth(d *doctorRun, component string, env map[string]string) { c.hint("The client ID/secret were rejected. They may be revoked, expired, or for another tenant. " + credentialsHint) case strings.Contains(lower, "x509") || strings.Contains(lower, "certificate"): c.hint("TLS verification failed. Your network may be intercepting HTTPS; ask IT for the corporate root CA to be installed in the Windows certificate store.") + case strings.Contains(lower, "tls handshake timeout") || strings.Contains(lower, "proxyconnect"): + c.hint("The CLI's connection to the API stalled at a proxy or firewall. Run 'armis-cli mcp doctor --network' to see which proxy the CLI uses and whether a direct connection works.") default: c.hint("Check network access to the Armis API from this machine (proxy, firewall, VPN).") } diff --git a/internal/install/doctor_diagnose_test.go b/internal/install/doctor_diagnose_test.go index 741dbc9..4b20fe1 100644 --- a/internal/install/doctor_diagnose_test.go +++ b/internal/install/doctor_diagnose_test.go @@ -3,6 +3,7 @@ package install import ( "context" "errors" + "io" "strings" "testing" "time" @@ -238,3 +239,52 @@ func TestDedupeRemediations(t *testing.T) { t.Error("short hints should stay as they are") } } + +func TestCheckCLINetwork(t *testing.T) { + t.Run("skipped without a NetCheck hook", func(t *testing.T) { + d := newDoctorRun(DoctorOptions{Handshake: true}) + checkCLINetwork(d, "scanner", "https://moose.armis.com/api/v1") + if len(d.report.Checks) != 0 || len(d.report.Artifacts) != 0 { + t.Errorf("expected no output, got %+v", d.report) + } + }) + + t.Run("reachable", func(t *testing.T) { + d := newDoctorRun(DoctorOptions{Handshake: true, NetCheck: func(_ context.Context, _ string, w io.Writer) bool { + _, _ = io.WriteString(w, "report text") + return true + }}) + checkCLINetwork(d, "scanner", "https://moose.armis.com/api/v1") + wantStatus(t, checkMap(d.report), "scanner/CLI network", StatusOK) + if got := d.report.Artifacts["scanner/cli-net-check.txt"]; got != "report text" { + t.Errorf("artifact = %q", got) + } + }) + + t.Run("unreachable points at the network flag and keeps the report", func(t *testing.T) { + d := newDoctorRun(DoctorOptions{Handshake: true, NetCheck: func(_ context.Context, _ string, w io.Writer) bool { + _, _ = io.WriteString(w, "TLS handshake timeout") + return false + }}) + checkCLINetwork(d, "scanner", "https://moose.armis.com/api/v1") + c := wantStatus(t, checkMap(d.report), "scanner/CLI network", StatusFail) + if !strings.Contains(c.Remediation, "armis-cli mcp doctor --network") { + t.Errorf("remediation = %q", c.Remediation) + } + if d.report.Artifacts["scanner/cli-net-check.txt"] != "TLS handshake timeout" { + t.Errorf("artifacts = %v", d.report.Artifacts) + } + }) +} + +func TestCheckAuthHintsAtNetworkCheckOnHandshakeTimeout(t *testing.T) { + t.Setenv("ARMIS_DEFAULT_AUTH_METHOD", "") + d := newDoctorRun(DoctorOptions{Handshake: true, AuthCheck: func(context.Context, string, string) error { + return errors.New(`Post "https://moose.armis.com/api/v1/auth/token": net/http: TLS handshake timeout`) + }}) + checkAuth(d, "scanner", map[string]string{"ARMIS_CLIENT_ID": "id", "ARMIS_CLIENT_SECRET": "s"}) + c := wantStatus(t, checkMap(d.report), "scanner/authentication", StatusFail) + if !strings.Contains(c.Remediation, "armis-cli mcp doctor --network") { + t.Errorf("remediation = %q", c.Remediation) + } +} diff --git a/internal/netcheck/netcheck.go b/internal/netcheck/netcheck.go new file mode 100644 index 0000000..2aa77ea --- /dev/null +++ b/internal/netcheck/netcheck.go @@ -0,0 +1,256 @@ +// Package netcheck reports, step by step, how the CLI reaches the Armis API: +// which proxy it uses, where a connection stops, and who signs the TLS +// certificate. It sends no credentials. +package netcheck + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + "net/http/httptrace" + "net/url" + "os" + "runtime" + "strings" + "time" + + "github.com/ArmisSecurity/armis-cli/internal/httpclient" +) + +const attemptTimeout = 30 * time.Second + +// Options configures Run. +type Options struct { + BaseURL string + Out io.Writer + + // Test hooks. Production code leaves them nil. + proxyFor func(*url.URL) (*url.URL, string, error) + transport func(direct bool) *http.Transport +} + +type attempt struct { + events []string + err error + status int + took time.Duration + tls *tls.ConnectionState +} + +// Run checks the connection and writes a report to opts.Out. It returns true +// when the path the CLI normally uses reaches the API. +func Run(ctx context.Context, opts Options) bool { + if ctx == nil { + ctx = context.Background() + } + out := opts.Out + target, err := url.Parse(opts.BaseURL) + if err != nil || target.Host == "" { + _, _ = fmt.Fprintf(out, "Cannot check %q: not a valid URL\n", opts.BaseURL) + return false + } + proxyFor := opts.proxyFor + if proxyFor == nil { + proxyFor = httpclient.ProxyFor + } + newTransport := opts.transport + if newTransport == nil { + newTransport = defaultTransport + } + + proxy, source, proxyErr := proxyFor(target) + + _, _ = fmt.Fprintf(out, "Armis connectivity check\n") + _, _ = fmt.Fprintf(out, " Target: %s\n", target.Redacted()) + _, _ = fmt.Fprintf(out, " Platform: %s/%s, %s\n", runtime.GOOS, runtime.GOARCH, runtime.Version()) + if proxyErr != nil { + _, _ = fmt.Fprintf(out, " Proxy: could not be determined: %v\n", proxyErr) + } else { + _, _ = fmt.Fprintf(out, " Proxy: %s\n", httpclient.DescribeProxy(proxy)) + _, _ = fmt.Fprintf(out, " Taken from: %s\n", source) + } + _, _ = fmt.Fprintf(out, " TLS handshake limit: %s\n", httpclient.TLSHandshakeTimeout()) + for _, name := range []string{"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", httpclient.EnvNoSystemProxy, httpclient.EnvTLSHandshakeTimeout, "SSL_CERT_FILE", "GODEBUG"} { + _, _ = fmt.Fprintf(out, " %-28s %s\n", name, envValue(name)) + } + + total := 1 + if proxy != nil { + total = 2 + } + + _, _ = fmt.Fprintf(out, "\n[1/%d] Connection the CLI uses\n", total) + used := probe(ctx, newTransport(false), target) + report(out, used) + + if proxy == nil { + verdictDirectOnly(out, used) + return used.err == nil + } + + _, _ = fmt.Fprintf(out, "\n[2/2] Direct connection, without the proxy (diagnostic only: the CLI does not use it)\n") + direct := probe(ctx, newTransport(true), target) + report(out, direct) + + verdictProxied(out, used, direct, target.Hostname()) + return used.err == nil +} + +func defaultTransport(direct bool) *http.Transport { + tr := httpclient.ProxyAwareTransport() + if direct { + tr.Proxy = nil + } + return tr +} + +func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { + var a attempt + start := time.Now() + mark := func(format string, args ...any) { + a.events = append(a.events, fmt.Sprintf("%7s %s", fmtDur(time.Since(start)), fmt.Sprintf(format, args...))) + } + + trace := &httptrace.ClientTrace{ + DNSStart: func(i httptrace.DNSStartInfo) { mark("looking up %s", i.Host) }, + DNSDone: func(i httptrace.DNSDoneInfo) { + if i.Err != nil { + mark("DNS failed: %v", i.Err) + return + } + addrs := make([]string, 0, len(i.Addrs)) + for _, ad := range i.Addrs { + addrs = append(addrs, ad.String()) + } + mark("DNS answered: %s", strings.Join(addrs, ", ")) + }, + ConnectStart: func(network, addr string) { mark("connecting to %s", addr) }, + ConnectDone: func(network, addr string, err error) { + if err != nil { + mark("connect to %s failed: %v", addr, err) + return + } + mark("connected to %s", addr) + }, + TLSHandshakeStart: func() { mark("TLS handshake started (limit %s)", tr.TLSHandshakeTimeout) }, + TLSHandshakeDone: func(cs tls.ConnectionState, err error) { + if err != nil { + mark("TLS handshake failed: %v", err) + return + } + mark("TLS handshake finished (%s, protocol %q)", tlsVersion(cs.Version), cs.NegotiatedProtocol) + }, + WroteRequest: func(httptrace.WroteRequestInfo) { mark("request sent") }, + GotFirstResponseByte: func() { mark("first response byte received") }, + } + + ctx, cancel := context.WithTimeout(ctx, attemptTimeout) + defer cancel() + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, target.String(), nil) + if err != nil { + a.err = err + return a + } + client := &http.Client{ + Transport: tr, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + resp, err := client.Do(req) //nolint:gosec // G704: URL is the configured Armis API base URL + a.took = time.Since(start) + if err != nil { + a.err = err + return a + } + defer resp.Body.Close() //nolint:errcheck // body is not read + a.status = resp.StatusCode + a.tls = resp.TLS + return a +} + +func report(out io.Writer, a attempt) { + for _, e := range a.events { + _, _ = fmt.Fprintf(out, " %s\n", e) + } + if a.err != nil { + _, _ = fmt.Fprintf(out, " FAILED after %s: %v\n", fmtDur(a.took), a.err) + return + } + _, _ = fmt.Fprintf(out, " OK: HTTP %d in %s\n", a.status, fmtDur(a.took)) + if a.tls != nil { + for i, c := range a.tls.PeerCertificates { + _, _ = fmt.Fprintf(out, " certificate %d: subject %q, issued by %q\n", i, c.Subject.String(), c.Issuer.String()) + } + } +} + +func verdictDirectOnly(out io.Writer, a attempt) { + _, _ = fmt.Fprintf(out, "\nResult\n") + if a.err == nil { + _, _ = fmt.Fprintf(out, " The API is reachable. HTTP %d without credentials is the expected answer.\n", a.status) + certNote(out, a) + return + } + _, _ = fmt.Fprintf(out, " The API is not reachable. Look at the last step above that did not finish.\n") + _, _ = fmt.Fprintf(out, " If your network requires a proxy, set HTTPS_PROXY. A firewall, VPN or security agent can also block the connection.\n") +} + +func verdictProxied(out io.Writer, used, direct attempt, host string) { + _, _ = fmt.Fprintf(out, "\nResult\n") + switch { + case used.err == nil: + _, _ = fmt.Fprintf(out, " The API is reachable through the proxy. HTTP %d without credentials is the expected answer.\n", used.status) + certNote(out, used) + case direct.err == nil: + _, _ = fmt.Fprintf(out, " The direct connection works and the proxy path does not.\n") + if strings.Contains(used.err.Error(), "proxyconnect") { + _, _ = fmt.Fprintf(out, " The CLI could not connect to the proxy itself. Check that the proxy address is right and the proxy is running.\n") + _, _ = fmt.Fprintf(out, " If a PAC file lists several proxies, the CLI uses only the first one.\n") + } else { + _, _ = fmt.Fprintf(out, " Ask your network team to allow %s through the proxy or exclude it from TLS inspection.\n", host) + } + _, _ = fmt.Fprintf(out, " If policy allows a direct connection, set %s=1 to ignore the system proxy settings,\n", httpclient.EnvNoSystemProxy) + _, _ = fmt.Fprintf(out, " or set HTTPS_PROXY to a working proxy and NO_PROXY=%s.\n", host) + default: + _, _ = fmt.Fprintf(out, " Neither the proxy path nor the direct connection reaches the API.\n") + _, _ = fmt.Fprintf(out, " Look at the last step in each section that did not finish. A VPN, firewall or security agent may be involved.\n") + } +} + +func certNote(out io.Writer, a attempt) { + if a.tls == nil || len(a.tls.PeerCertificates) == 0 { + return + } + _, _ = fmt.Fprintf(out, " The server certificate was issued by %q.\n", a.tls.PeerCertificates[0].Issuer.String()) + _, _ = fmt.Fprintf(out, " If that is your organization's CA, your network is inspecting TLS traffic.\n") +} + +func envValue(name string) string { + v := os.Getenv(name) + if v == "" { + return "(not set)" + } + if u, err := url.Parse(v); err == nil && u.User != nil { + return u.Redacted() + } + return v +} + +func fmtDur(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +func tlsVersion(v uint16) string { + switch v { + case tls.VersionTLS13: + return "TLS 1.3" + case tls.VersionTLS12: + return "TLS 1.2" + default: + return fmt.Sprintf("0x%04x", v) + } +} diff --git a/internal/netcheck/netcheck_test.go b/internal/netcheck/netcheck_test.go new file mode 100644 index 0000000..3c35551 --- /dev/null +++ b/internal/netcheck/netcheck_test.go @@ -0,0 +1,188 @@ +package netcheck + +import ( + "bytes" + "context" + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func serverTransport(srv *httptest.Server) *http.Transport { + tr := srv.Client().Transport.(*http.Transport).Clone() + tr.Proxy = nil + tr.TLSClientConfig = &tls.Config{RootCAs: srv.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs, MinVersion: tls.VersionTLS12} + return tr +} + +// stallingProxy accepts a CONNECT, answers 200 and then forwards nothing, so +// the TLS handshake through it never finishes. +func stallingProxy(t *testing.T) *url.URL { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + buf := make([]byte, 4096) + _, _ = c.Read(buf) + _, _ = c.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) + time.Sleep(5 * time.Second) + _ = c.Close() + }() + } + }() + return &url.URL{Scheme: "http", Host: ln.Addr().String()} +} + +func TestRunDirectSuccess(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: srv.URL, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return nil, "test", nil }, + transport: func(bool) *http.Transport { return serverTransport(srv) }, + }) + if !ok { + t.Fatalf("expected success, report:\n%s", out.String()) + } + report := out.String() + for _, want := range []string{"direct connection (no proxy)", "TLS handshake finished", "OK: HTTP 401", "The API is reachable", "issued by"} { + if !strings.Contains(report, want) { + t.Errorf("report should contain %q:\n%s", want, report) + } + } + if strings.Contains(report, "[2/2]") { + t.Error("no direct comparison is needed when no proxy is used") + } +} + +func TestRunProxyStallsButDirectWorks(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + proxy := stallingProxy(t) + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: srv.URL, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return proxy, "the test", nil }, + transport: func(direct bool) *http.Transport { + tr := serverTransport(srv) + if !direct { + tr.Proxy = http.ProxyURL(proxy) + tr.TLSHandshakeTimeout = 300 * time.Millisecond + } + return tr + }, + }) + if ok { + t.Fatalf("the proxied path must be reported as failed:\n%s", out.String()) + } + report := out.String() + for _, want := range []string{ + "TLS handshake started", + "TLS handshake timeout", + "[2/2] Direct connection", + "The direct connection works and the proxy path does not", + "ARMIS_NO_SYSTEM_PROXY", + } { + if !strings.Contains(report, want) { + t.Errorf("report should contain %q:\n%s", want, report) + } + } +} + +func TestRunBothFail(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + _ = ln.Close() + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: "https://" + addr, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return nil, "test", nil }, + transport: func(bool) *http.Transport { return &http.Transport{} }, + }) + if ok { + t.Fatal("expected failure for a closed port") + } + if !strings.Contains(out.String(), "The API is not reachable") { + t.Errorf("unexpected report:\n%s", out.String()) + } +} + +func TestRunRejectsBadURL(t *testing.T) { + var out bytes.Buffer + if Run(context.Background(), Options{BaseURL: "::", Out: &out}) { + t.Fatal("expected failure") + } + if !strings.Contains(out.String(), "not a valid URL") { + t.Errorf("unexpected output %q", out.String()) + } +} + +func TestEnvValueHidesCredentials(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://user:secret@proxy.corp:8080") + got := envValue("HTTPS_PROXY") + if strings.Contains(got, "secret") { + t.Errorf("credentials leaked: %q", got) + } +} + +func TestRunDeadProxyBlamesTheProxy(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + dead := &url.URL{Scheme: "http", Host: ln.Addr().String()} + _ = ln.Close() + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: srv.URL, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return dead, "the test", nil }, + transport: func(direct bool) *http.Transport { + tr := serverTransport(srv) + if !direct { + tr.Proxy = http.ProxyURL(dead) + } + return tr + }, + }) + if ok { + t.Fatal("expected the dead proxy to fail") + } + report := out.String() + if !strings.Contains(report, "could not connect to the proxy itself") || strings.Contains(report, "TLS inspection") { + t.Errorf("unexpected verdict:\n%s", report) + } +} From ff3cb5d9ab0dbf0fa14c7782048768a8a0dc2f0f Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Nov 2026 13:36:46 +0200 Subject: [PATCH 2/5] feat(netcheck): explain untrusted TLS certificates and how to trust the - Detect x509 verification failures in ExplainTransportError and tell the user the machine does not trust the certificate, with OS-specific guidance for installing the organization's root CA. - Skip the "bypass the system proxy" hint for trust errors, since skipping the proxy does not fix them. - In 'mcp doctor --network', re-handshake without verification to read the presented chain and name the issuing authority in the report. --- internal/httpclient/explain.go | 28 +++++++++++- internal/httpclient/explain_test.go | 18 ++++++++ internal/netcheck/netcheck.go | 67 +++++++++++++++++++++++++++++ internal/netcheck/netcheck_test.go | 32 ++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/internal/httpclient/explain.go b/internal/httpclient/explain.go index a5d6f55..f814080 100644 --- a/internal/httpclient/explain.go +++ b/internal/httpclient/explain.go @@ -3,6 +3,7 @@ package httpclient import ( "fmt" "net/url" + "runtime" "strings" ) @@ -16,6 +17,22 @@ func ProxyUsedFor(host string) string { return DescribeProxy(v.(proxyChoice).url) } +// TrustStoreAdvice says how to make the CLI trust a certificate authority on +// this operating system. A network that inspects TLS re-signs traffic with its +// own authority, and the CLI fails until that authority is trusted. +func TrustStoreAdvice() string { + switch runtime.GOOS { + case "windows": + return "If your network inspects TLS, install your organization's root CA in the Windows certificate store " + + "(Trusted Root Certification Authorities). The CLI uses that store and does not read SSL_CERT_FILE on Windows." + case "darwin": + return "If your network inspects TLS, add your organization's root CA to the macOS Keychain and trust it for SSL." + default: + return "If your network inspects TLS, add your organization's root CA to the system CA bundle, " + + "or set SSL_CERT_FILE to a PEM file that contains it." + } +} + // ExplainTransportError adds guidance to the two failures that proxies and // firewalls cause most often: a TLS handshake that never finishes, and a proxy // that cannot be reached. Other errors are returned unchanged. @@ -26,7 +43,8 @@ func ExplainTransportError(err error, target *url.URL) error { msg := err.Error() handshake := strings.Contains(msg, "TLS handshake timeout") proxyConnect := strings.Contains(msg, "proxyconnect") - if !handshake && !proxyConnect { + untrusted := strings.Contains(msg, "x509:") + if !handshake && !proxyConnect && !untrusted { return err } @@ -38,6 +56,12 @@ func ExplainTransportError(err error, target *url.URL) error { var hint string switch { + case untrusted: + hint = fmt.Sprintf("this machine does not trust the certificate presented for %s", host) + if proxy != nil { + hint += fmt.Sprintf(" (the connection went through the proxy %s)", DescribeProxy(proxy)) + } + hint += ". " + TrustStoreAdvice() case proxy != nil && proxyConnect: hint = fmt.Sprintf("the CLI tried to reach %s through the proxy %s, taken from %s, and could not connect to that proxy. "+ "Check the proxy address. If your PAC file lists several proxies, only the first one is used", host, DescribeProxy(proxy), ProxySource()) @@ -50,7 +74,7 @@ func ExplainTransportError(err error, target *url.URL) error { default: return err } - if proxy != nil { + if proxy != nil && !untrusted { hint += fmt.Sprintf(". To try without the system proxy, set %s=1 (or set HTTPS_PROXY to a working proxy and NO_PROXY=%s)", EnvNoSystemProxy, host) } hint += ". Run 'armis-cli mcp doctor --network' for a step-by-step report" diff --git a/internal/httpclient/explain_test.go b/internal/httpclient/explain_test.go index badf263..b4ce32b 100644 --- a/internal/httpclient/explain_test.go +++ b/internal/httpclient/explain_test.go @@ -131,4 +131,22 @@ func TestExplainTransportError(t *testing.T) { t.Errorf("unexpected message %q", msg) } }) + + t.Run("untrusted certificate says how to trust the issuing authority", func(t *testing.T) { + proxyChoices.Store(target.Host, proxyChoice{url: &url.URL{Scheme: "http", Host: "proxy.corp:8080"}}) + untrusted := errors.New(`Post "https://api.example.com/path": tls: failed to verify certificate: x509: certificate signed by unknown authority`) + got := ExplainTransportError(untrusted, target) + msg := got.Error() + for _, want := range []string{"does not trust the certificate", "proxy.corp:8080", "root CA", "mcp doctor --network"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q should contain %q", msg, want) + } + } + if strings.Contains(msg, EnvNoSystemProxy) { + t.Errorf("bypassing the proxy does not fix a trust problem, message %q", msg) + } + if !errors.Is(got, untrusted) { + t.Error("the original error must stay in the chain") + } + }) } diff --git a/internal/netcheck/netcheck.go b/internal/netcheck/netcheck.go index 2aa77ea..95f4dd0 100644 --- a/internal/netcheck/netcheck.go +++ b/internal/netcheck/netcheck.go @@ -6,6 +6,7 @@ package netcheck import ( "context" "crypto/tls" + "crypto/x509" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "os" "runtime" "strings" + "sync" "time" "github.com/ArmisSecurity/armis-cli/internal/httpclient" @@ -37,6 +39,9 @@ type attempt struct { status int took time.Duration tls *tls.ConnectionState + // untrusted holds the certificate chain presented on a connection whose + // certificate this machine does not trust. + untrusted []*x509.Certificate } // Run checks the connection and writes a report to opts.Out. It returns true @@ -161,6 +166,9 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { a.took = time.Since(start) if err != nil { a.err = err + if strings.Contains(err.Error(), "x509:") { + a.untrusted = presentedChain(ctx, tr, target) + } return a } defer resp.Body.Close() //nolint:errcheck // body is not read @@ -169,12 +177,50 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { return a } +// presentedChain reads the certificates a server presents without trusting +// them, so the report can name the authority that signed them. The handshake +// is abandoned as soon as the chain is read and no request is sent. +func presentedChain(ctx context.Context, tr *http.Transport, target *url.URL) []*x509.Certificate { + probeTr := tr.Clone() + // armis:ignore cwe:295 reason:diagnostic only; the connection is cancelled right after the handshake and no request or credentials are sent + probeTr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} //nolint:gosec // G402: read the presented chain only + ctx, cancel := context.WithTimeout(ctx, attemptTimeout) + defer cancel() + + var mu sync.Mutex + var certs []*x509.Certificate + trace := &httptrace.ClientTrace{ + TLSHandshakeDone: func(cs tls.ConnectionState, err error) { + if err == nil { + mu.Lock() + certs = cs.PeerCertificates + mu.Unlock() + } + cancel() + }, + } + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, target.String(), nil) + if err != nil { + return nil + } + resp, err := (&http.Client{Transport: probeTr}).Do(req) //nolint:gosec // G704: URL is the configured Armis API base URL + if err == nil { + _ = resp.Body.Close() + } + mu.Lock() + defer mu.Unlock() + return certs +} + func report(out io.Writer, a attempt) { for _, e := range a.events { _, _ = fmt.Fprintf(out, " %s\n", e) } if a.err != nil { _, _ = fmt.Fprintf(out, " FAILED after %s: %v\n", fmtDur(a.took), a.err) + for i, c := range a.untrusted { + _, _ = fmt.Fprintf(out, " certificate %d (not trusted by this machine): subject %q, issued by %q\n", i, c.Subject.String(), c.Issuer.String()) + } return } _, _ = fmt.Fprintf(out, " OK: HTTP %d in %s\n", a.status, fmtDur(a.took)) @@ -187,6 +233,9 @@ func report(out io.Writer, a attempt) { func verdictDirectOnly(out io.Writer, a attempt) { _, _ = fmt.Fprintf(out, "\nResult\n") + if untrustedNote(out, a) { + return + } if a.err == nil { _, _ = fmt.Fprintf(out, " The API is reachable. HTTP %d without credentials is the expected answer.\n", a.status) certNote(out, a) @@ -198,6 +247,9 @@ func verdictDirectOnly(out io.Writer, a attempt) { func verdictProxied(out io.Writer, used, direct attempt, host string) { _, _ = fmt.Fprintf(out, "\nResult\n") + if untrustedNote(out, used) { + return + } switch { case used.err == nil: _, _ = fmt.Fprintf(out, " The API is reachable through the proxy. HTTP %d without credentials is the expected answer.\n", used.status) @@ -218,6 +270,21 @@ func verdictProxied(out io.Writer, used, direct attempt, host string) { } } +// untrustedNote explains a connection that failed because its certificate is +// not trusted, and reports whether it did. +func untrustedNote(out io.Writer, a attempt) bool { + if a.err == nil || !strings.Contains(a.err.Error(), "x509:") { + return false + } + if len(a.untrusted) > 0 { + _, _ = fmt.Fprintf(out, " This machine does not trust the certificate presented on this path. It was issued by %q.\n", a.untrusted[0].Issuer.String()) + } else { + _, _ = fmt.Fprintf(out, " This machine does not trust the certificate presented on this path.\n") + } + _, _ = fmt.Fprintf(out, " %s\n", httpclient.TrustStoreAdvice()) + return true +} + func certNote(out io.Writer, a attempt) { if a.tls == nil || len(a.tls.PeerCertificates) == 0 { return diff --git a/internal/netcheck/netcheck_test.go b/internal/netcheck/netcheck_test.go index 3c35551..f316c4d 100644 --- a/internal/netcheck/netcheck_test.go +++ b/internal/netcheck/netcheck_test.go @@ -186,3 +186,35 @@ func TestRunDeadProxyBlamesTheProxy(t *testing.T) { t.Errorf("unexpected verdict:\n%s", report) } } + +func TestRunUntrustedCertificateNamesTheIssuer(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: srv.URL, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return nil, "test", nil }, + transport: func(bool) *http.Transport { return &http.Transport{} }, + }) + if ok { + t.Fatal("a certificate from an unknown authority must fail") + } + report := out.String() + for _, want := range []string{ + "x509:", + "not trusted by this machine", + "does not trust the certificate presented on this path", + "Acme Co", + } { + if !strings.Contains(report, want) { + t.Errorf("report should contain %q:\n%s", want, report) + } + } + if strings.Contains(report, "The direct connection works") || strings.Contains(report, "TLS inspection.") { + t.Errorf("a trust problem must not be reported as a proxy stall:\n%s", report) + } +} From 74e700a1bf088854c523b4ecb6bda75d963563b3 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Nov 2026 13:43:11 +0200 Subject: [PATCH 3/5] Harden proxy/TLS diagnostics: fix source reporting, timeout parsing, red - Record the proxy source at selection time so errors name the real origin (env, Windows, macOS) instead of guessing afterward. - Add BypassProxyAdvice: ARMIS_NO_SYSTEM_PROXY does not affect a proxy from HTTPS_PROXY/HTTP_PROXY, so point to NO_PROXY or unsetting instead. - Clamp ARMIS_TLS_HANDSHAKE_TIMEOUT to 5m, avoid int overflow on huge values, and warn once on unusable values rather than silently ignoring them. - Stop wrapping Client.Do errors after retries, so a 5xx response body mentioning "TLS handshake timeout" is not misreported as a proxy failure. - netcheck: guard nil Out, make httptrace event capture goroutine-safe, close idle connections, and redact credentials in scheme-less proxy URLs like user:pass@host:8080. - mcp doctor --network: also reject --timeout and --verbose, and check flags in a stable order. - README: document the macOS cgo proxy behavior and the new timeout rules. --- README.md | 6 +-- internal/cmd/mcp_doctor.go | 21 +++++--- internal/httpclient/client.go | 2 +- internal/httpclient/client_test.go | 24 +++++++++ internal/httpclient/explain.go | 10 ++-- internal/httpclient/explain_test.go | 38 ++++++++++++++- internal/httpclient/transport.go | 75 ++++++++++++++++++++++++----- internal/netcheck/netcheck.go | 52 +++++++++++++++++--- internal/netcheck/netcheck_test.go | 22 +++++++-- 9 files changed, 211 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6cede0d..1276f4a 100644 --- a/README.md +++ b/README.md @@ -238,14 +238,14 @@ This works without any MCP install. It connects to the Armis API the way the CLI How the CLI chooses a proxy: - If `HTTPS_PROXY` or `HTTP_PROXY` is set, the CLI uses it, and `NO_PROXY` exempts hosts. -- Otherwise on Windows it uses the system proxy settings, including a PAC file. If a PAC file lists several proxies, only the first one is used. On macOS and Linux it uses only the environment variables. +- Otherwise on Windows it uses the system proxy settings, including a PAC file. If a PAC file lists several proxies, only the first one is used. On macOS and Linux it uses only the environment variables. A macOS build compiled with cgo also reads the macOS system proxy settings; the released binaries are built without cgo. Options: | Variable | Effect | |---|---| -| `ARMIS_NO_SYSTEM_PROXY=1` | Ignore the operating system's proxy settings and use only `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. Use it only where a direct connection is allowed. | -| `ARMIS_TLS_HANDSHAKE_TIMEOUT=30s` | Wait longer than the default 10 seconds for the TLS handshake (a Go duration, or a number of seconds). Useful when a proxy inspects traffic slowly. | +| `ARMIS_NO_SYSTEM_PROXY=1` | Ignore the operating system's proxy settings and use only `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. It does not bypass a proxy that comes from `HTTPS_PROXY` or `HTTP_PROXY`; to do that, unset them or add the host to `NO_PROXY`. Use it only where a direct connection is allowed. | +| `ARMIS_TLS_HANDSHAKE_TIMEOUT=30s` | Wait longer than the default 10 seconds for the TLS handshake (a Go duration, or a number of seconds, up to 5 minutes; larger values are capped and invalid values are reported and ignored). Useful when a proxy inspects traffic slowly. | ### Shell Completion diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go index 4efa9fb..9ffaaa1 100644 --- a/internal/cmd/mcp_doctor.go +++ b/internal/cmd/mcp_doctor.go @@ -512,15 +512,20 @@ func statusSymbol(s install.CheckStatus, accessible bool) string { // MCP install, because the failures it finds (proxy, TLS inspection, firewall) // break logins and scans as well. func runMCPDoctorNetwork(cmd *cobra.Command) error { - for name, set := range map[string]bool{ - "--watch": mcpDoctorWatch, - "--fix": mcpDoctorFix, - "--bundle": mcpDoctorBundle || mcpDoctorBundlePath != "", - "--deep": mcpDoctorDeep, - "--no-handshake": mcpDoctorNoHandshake, + for _, f := range []struct { + name string + set bool + }{ + {"--watch", mcpDoctorWatch}, + {"--fix", mcpDoctorFix}, + {"--bundle", mcpDoctorBundle || mcpDoctorBundlePath != ""}, + {"--deep", mcpDoctorDeep}, + {"--no-handshake", mcpDoctorNoHandshake}, + {"--timeout", cmd.Flags().Changed("timeout")}, + {"--verbose", cmd.Flags().Changed("verbose")}, } { - if set { - return fmt.Errorf("--network runs on its own; remove %s", name) + if f.set { + return fmt.Errorf("--network runs on its own; remove %s", f.name) } } if mcpDoctorFormat != agentFormatPlain { diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index a955b82..2bb59cf 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -118,7 +118,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { err = backoff.Retry(operation, backoff.WithContext(b, ctx)) } if err != nil { - return nil, ExplainTransportError(err, req.URL) + return nil, err } // Defensive check: resp should never be nil at this point since operation diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go index ab2e0b3..a56ce27 100644 --- a/internal/httpclient/client_test.go +++ b/internal/httpclient/client_test.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "time" ) @@ -312,3 +314,25 @@ func TestClientDo_RetryWithBody(t *testing.T) { } } } + +func TestClientDo_ServerErrorBodyIsNotExplainedAsProxyFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream connect error: TLS handshake timeout, proxyconnect")) + })) + defer server.Close() + + client := NewClient(Config{DisableRetry: true, Timeout: 5 * time.Second}) + req, err := http.NewRequest("GET", server.URL, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + proxyChoices.Store(req.URL.Host, proxyChoice{url: &url.URL{Scheme: "http", Host: "proxy.corp:8080"}}) + _, err = client.Do(req) + if err == nil || !strings.Contains(err.Error(), "server error: 502") { + t.Fatalf("want a server error, got %v", err) + } + if strings.Contains(err.Error(), "mcp doctor --network") { + t.Errorf("5xx body was explained as a transport failure: %v", err) + } +} diff --git a/internal/httpclient/explain.go b/internal/httpclient/explain.go index f814080..f85a113 100644 --- a/internal/httpclient/explain.go +++ b/internal/httpclient/explain.go @@ -50,8 +50,10 @@ func ExplainTransportError(err error, target *url.URL) error { host := target.Hostname() var proxy *url.URL + var source string if v, ok := proxyChoices.Load(target.Host); ok { - proxy = v.(proxyChoice).url + choice := v.(proxyChoice) + proxy, source = choice.url, choice.source } var hint string @@ -64,10 +66,10 @@ func ExplainTransportError(err error, target *url.URL) error { hint += ". " + TrustStoreAdvice() case proxy != nil && proxyConnect: hint = fmt.Sprintf("the CLI tried to reach %s through the proxy %s, taken from %s, and could not connect to that proxy. "+ - "Check the proxy address. If your PAC file lists several proxies, only the first one is used", host, DescribeProxy(proxy), ProxySource()) + "Check the proxy address. If your PAC file lists several proxies, only the first one is used", host, DescribeProxy(proxy), source) case proxy != nil: hint = fmt.Sprintf("the connection to %s went through the proxy %s, taken from %s, and the TLS handshake did not finish within %s. "+ - "Ask your network team to allow %s through the proxy or exclude it from TLS inspection", host, DescribeProxy(proxy), ProxySource(), TLSHandshakeTimeout(), host) + "Ask your network team to allow %s through the proxy or exclude it from TLS inspection", host, DescribeProxy(proxy), source, TLSHandshakeTimeout(), host) case handshake: hint = fmt.Sprintf("the CLI connected to %s directly (no proxy is configured for it) and the TLS handshake did not finish within %s. "+ "A firewall, VPN or security agent may be blocking or delaying the connection. If your network requires a proxy, set HTTPS_PROXY", host, TLSHandshakeTimeout()) @@ -75,7 +77,7 @@ func ExplainTransportError(err error, target *url.URL) error { return err } if proxy != nil && !untrusted { - hint += fmt.Sprintf(". To try without the system proxy, set %s=1 (or set HTTPS_PROXY to a working proxy and NO_PROXY=%s)", EnvNoSystemProxy, host) + hint += ". " + BypassProxyAdvice(host) } hint += ". Run 'armis-cli mcp doctor --network' for a step-by-step report" return fmt.Errorf("%w (%s)", err, hint) diff --git a/internal/httpclient/explain_test.go b/internal/httpclient/explain_test.go index b4ce32b..ac6d537 100644 --- a/internal/httpclient/explain_test.go +++ b/internal/httpclient/explain_test.go @@ -21,7 +21,10 @@ func TestTLSHandshakeTimeout(t *testing.T) { {"garbage falls back", "soon", 10 * time.Second}, {"zero falls back", "0", 10 * time.Second}, {"negative falls back", "-5s", 10 * time.Second}, - {"over the cap falls back", "10m", 10 * time.Second}, + {"over the cap is clamped", "10m", 5 * time.Minute}, + {"seconds over the cap are clamped", "600", 5 * time.Minute}, + {"huge seconds do not overflow", "10000000000", 5 * time.Minute}, + {"unit typo falls back", "30sec", 10 * time.Second}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -36,6 +39,39 @@ func TestTLSHandshakeTimeout(t *testing.T) { } } +func TestTLSHandshakeTimeoutWarnsOnceOnBadValue(t *testing.T) { + var buf strings.Builder + oldW := warnWriter + warnWriter = &buf + warnedValues.Delete("30sec-test") + t.Cleanup(func() { warnWriter = oldW }) + t.Setenv(EnvTLSHandshakeTimeout, "30sec-test") + + TLSHandshakeTimeout() + TLSHandshakeTimeout() + if n := strings.Count(buf.String(), "ignoring "+EnvTLSHandshakeTimeout); n != 1 { + t.Errorf("warned %d times, want 1: %q", n, buf.String()) + } +} + +func TestBypassProxyAdvice(t *testing.T) { + t.Run("proxy from the environment is not fixed by the system-proxy switch", func(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://bad:8080") + got := BypassProxyAdvice("api.example.com") + if strings.Contains(got, EnvNoSystemProxy) || !strings.Contains(got, "NO_PROXY=api.example.com") { + t.Errorf("unexpected advice %q", got) + } + }) + t.Run("system proxy points at the switch", func(t *testing.T) { + for _, n := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} { + t.Setenv(n, "") + } + if got := BypassProxyAdvice("api.example.com"); !strings.Contains(got, EnvNoSystemProxy) { + t.Errorf("unexpected advice %q", got) + } + }) +} + func TestNoSystemProxyUsesEnvironmentOnly(t *testing.T) { want := &url.URL{Scheme: "http", Host: "proxy.example.com:8080"} old := envProxy diff --git a/internal/httpclient/transport.go b/internal/httpclient/transport.go index 9eae7ea..bc0665d 100644 --- a/internal/httpclient/transport.go +++ b/internal/httpclient/transport.go @@ -1,6 +1,8 @@ package httpclient import ( + "fmt" + "io" "net/http" "net/url" "os" @@ -45,7 +47,10 @@ var envProxy = http.ProxyFromEnvironment // recent request, so a failure can name the proxy it went through. var proxyChoices sync.Map // host -> proxyChoice -type proxyChoice struct{ url *url.URL } +type proxyChoice struct { + url *url.URL + source string +} // ProxyAwareTransport returns an *http.Transport that resolves proxies from the // host operating system's configuration rather than from environment variables @@ -86,7 +91,7 @@ func ProxyAwareTransport() *http.Transport { func proxyForRequest(req *http.Request) (*url.URL, error) { u, err := resolveProxy(req) if err == nil && req.URL != nil { - proxyChoices.Store(req.URL.Host, proxyChoice{url: u}) + proxyChoices.Store(req.URL.Host, proxyChoice{url: u, source: proxySource(u)}) } return u, err } @@ -110,23 +115,39 @@ func systemProxyFunc() func(*http.Request) (*url.URL, error) { // connection) and a description of where that setting comes from. func ProxyFor(target *url.URL) (*url.URL, string, error) { u, err := resolveProxy(&http.Request{URL: target}) - return u, ProxySource(), err + return u, proxySource(u), err } -// ProxySource describes which configuration decides the proxy. -func ProxySource() string { +// proxySource names the configuration that produced u. It is derived from the +// proxy that was actually chosen, so it stays correct on platforms where the +// operating system supplies the setting. +func proxySource(u *url.URL) string { switch { + case u == nil: + return "no proxy applies to this host (none is configured, or it is excluded)" case envTrue(EnvNoSystemProxy): return "environment variables only (" + EnvNoSystemProxy + " is set)" case proxyEnvSet(): return "the HTTPS_PROXY/HTTP_PROXY environment variable" case runtime.GOOS == "windows": return "the Windows system proxy settings (PAC/WinINET)" + case runtime.GOOS == "darwin": + return "the macOS system proxy settings" default: - return "the environment (no proxy variables are set)" + return "the operating system's proxy settings" } } +// BypassProxyAdvice says how to try a host without the proxy now in use. The +// advice depends on where the proxy comes from: ARMIS_NO_SYSTEM_PROXY has no +// effect on a proxy that HTTPS_PROXY or HTTP_PROXY supplies. +func BypassProxyAdvice(host string) string { + if proxyEnvSet() { + return fmt.Sprintf("To try without the proxy, unset HTTPS_PROXY and HTTP_PROXY, or set NO_PROXY=%s", host) + } + return fmt.Sprintf("To try without the system proxy, set %s=1 (or set HTTPS_PROXY to a working proxy and NO_PROXY=%s)", EnvNoSystemProxy, host) +} + // DescribeProxy formats a proxy for display, hiding any credentials in it. func DescribeProxy(u *url.URL) string { if u == nil { @@ -141,24 +162,54 @@ func DescribeProxy(u *url.URL) string { return u.Redacted() } -// TLSHandshakeTimeout returns the handshake limit in effect. +// TLSHandshakeTimeout returns the handshake limit in effect. A value above the +// maximum is clamped to it; an unusable value falls back to the default and is +// reported once on stderr. func TLSHandshakeTimeout() time.Duration { raw := strings.TrimSpace(os.Getenv(EnvTLSHandshakeTimeout)) if raw == "" { return defaultTLSHandshakeTimeout } + d, ok := parseHandshakeTimeout(raw) + if !ok { + warnOnce(raw, "ignoring %s=%q: use a duration such as 30s or a number of seconds between 1 and %d; using %s", + EnvTLSHandshakeTimeout, raw, int(maxTLSHandshakeTimeout/time.Second), defaultTLSHandshakeTimeout) + return defaultTLSHandshakeTimeout + } + if d > maxTLSHandshakeTimeout { + warnOnce(raw, "%s=%q is above the maximum; using %s", EnvTLSHandshakeTimeout, raw, maxTLSHandshakeTimeout) + return maxTLSHandshakeTimeout + } + return d +} + +func parseHandshakeTimeout(raw string) (time.Duration, bool) { d, err := time.ParseDuration(raw) if err != nil { - secs, convErr := strconv.Atoi(raw) + secs, convErr := strconv.ParseInt(raw, 10, 64) if convErr != nil { - return defaultTLSHandshakeTimeout + return 0, false + } + if secs > int64(maxTLSHandshakeTimeout/time.Second) { + return maxTLSHandshakeTimeout + time.Second, true } d = time.Duration(secs) * time.Second } - if d <= 0 || d > maxTLSHandshakeTimeout { - return defaultTLSHandshakeTimeout + return d, d > 0 +} + +// warnedValues remembers which settings were already reported, because +// TLSHandshakeTimeout is called from several places per run. +var ( + warnedValues sync.Map + warnWriter io.Writer = os.Stderr +) + +func warnOnce(key, format string, args ...any) { + if _, seen := warnedValues.LoadOrStore(key, struct{}{}); seen { + return } - return d + _, _ = fmt.Fprintf(warnWriter, "Warning: "+format+"\n", args...) } func envTrue(name string) bool { diff --git a/internal/netcheck/netcheck.go b/internal/netcheck/netcheck.go index 95f4dd0..479237c 100644 --- a/internal/netcheck/netcheck.go +++ b/internal/netcheck/netcheck.go @@ -51,6 +51,9 @@ func Run(ctx context.Context, opts Options) bool { ctx = context.Background() } out := opts.Out + if out == nil { + out = io.Discard + } target, err := url.Parse(opts.BaseURL) if err != nil || target.Host == "" { _, _ = fmt.Fprintf(out, "Cannot check %q: not a valid URL\n", opts.BaseURL) @@ -112,10 +115,25 @@ func defaultTransport(direct bool) *http.Transport { } func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { + defer tr.CloseIdleConnections() + var a attempt start := time.Now() + + // httptrace hooks can run on several goroutines at once (parallel dials), + // and one can fire after Do returns. + var mu sync.Mutex + var events []string mark := func(format string, args ...any) { - a.events = append(a.events, fmt.Sprintf("%7s %s", fmtDur(time.Since(start)), fmt.Sprintf(format, args...))) + line := fmt.Sprintf("%7s %s", fmtDur(time.Since(start)), fmt.Sprintf(format, args...)) + mu.Lock() + events = append(events, line) + mu.Unlock() + } + snapshot := func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), events...) } trace := &httptrace.ClientTrace{ @@ -156,6 +174,7 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, target.String(), nil) if err != nil { a.err = err + a.events = snapshot() return a } client := &http.Client{ @@ -169,11 +188,13 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { if strings.Contains(err.Error(), "x509:") { a.untrusted = presentedChain(ctx, tr, target) } + a.events = snapshot() return a } defer resp.Body.Close() //nolint:errcheck // body is not read a.status = resp.StatusCode a.tls = resp.TLS + a.events = snapshot() return a } @@ -182,6 +203,7 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { // is abandoned as soon as the chain is read and no request is sent. func presentedChain(ctx context.Context, tr *http.Transport, target *url.URL) []*x509.Certificate { probeTr := tr.Clone() + defer probeTr.CloseIdleConnections() // armis:ignore cwe:295 reason:diagnostic only; the connection is cancelled right after the handshake and no request or credentials are sent probeTr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} //nolint:gosec // G402: read the presented chain only ctx, cancel := context.WithTimeout(ctx, attemptTimeout) @@ -262,8 +284,7 @@ func verdictProxied(out io.Writer, used, direct attempt, host string) { } else { _, _ = fmt.Fprintf(out, " Ask your network team to allow %s through the proxy or exclude it from TLS inspection.\n", host) } - _, _ = fmt.Fprintf(out, " If policy allows a direct connection, set %s=1 to ignore the system proxy settings,\n", httpclient.EnvNoSystemProxy) - _, _ = fmt.Fprintf(out, " or set HTTPS_PROXY to a working proxy and NO_PROXY=%s.\n", host) + _, _ = fmt.Fprintf(out, " If policy allows a direct connection: %s.\n", httpclient.BypassProxyAdvice(host)) default: _, _ = fmt.Fprintf(out, " Neither the proxy path nor the direct connection reaches the API.\n") _, _ = fmt.Fprintf(out, " Look at the last step in each section that did not finish. A VPN, firewall or security agent may be involved.\n") @@ -298,10 +319,29 @@ func envValue(name string) string { if v == "" { return "(not set)" } - if u, err := url.Parse(v); err == nil && u.User != nil { - return u.Redacted() + return redactCredentials(v) +} + +// redactCredentials hides the password in user:password@host, with or without a +// scheme. url.Parse reads "user:pass@host:8080" as a scheme and keeps no user. +func redactCredentials(v string) string { + scheme, rest := "", v + if i := strings.Index(v, "://"); i >= 0 { + scheme, rest = v[:i+3], v[i+3:] + } + authority, tail := rest, "" + if i := strings.IndexAny(rest, "/?#"); i >= 0 { + authority, tail = rest[:i], rest[i:] + } + at := strings.LastIndex(authority, "@") + if at < 0 { + return v + } + user := authority[:at] + if i := strings.Index(user, ":"); i >= 0 { + user = user[:i] + ":xxxxx" } - return v + return scheme + user + authority[at:] + tail } func fmtDur(d time.Duration) string { diff --git a/internal/netcheck/netcheck_test.go b/internal/netcheck/netcheck_test.go index f316c4d..93336ca 100644 --- a/internal/netcheck/netcheck_test.go +++ b/internal/netcheck/netcheck_test.go @@ -146,13 +146,27 @@ func TestRunRejectsBadURL(t *testing.T) { } func TestEnvValueHidesCredentials(t *testing.T) { - t.Setenv("HTTPS_PROXY", "http://user:secret@proxy.corp:8080") - got := envValue("HTTPS_PROXY") - if strings.Contains(got, "secret") { - t.Errorf("credentials leaked: %q", got) + for _, v := range []string{ + "http://user:secret@proxy.corp:8080", + "user:secret@proxy.corp:8080", + "http://user:secret@proxy.corp:8080/path", + } { + t.Setenv("HTTPS_PROXY", v) + got := envValue("HTTPS_PROXY") + if strings.Contains(got, "secret") || !strings.Contains(got, "proxy.corp:8080") { + t.Errorf("envValue(%q) = %q", v, got) + } + } + t.Setenv("SSL_CERT_FILE", "/home/a@b/ca.pem") + if got := envValue("SSL_CERT_FILE"); got != "/home/a@b/ca.pem" { + t.Errorf("path was altered: %q", got) } } +func TestRunNilOutDoesNotPanic(t *testing.T) { + Run(context.Background(), Options{BaseURL: "not a url"}) +} + func TestRunDeadProxyBlamesTheProxy(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) From 9d5895fcf221c9069e5870d3343b1fb378c9be05 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Nov 2026 13:43:22 +0200 Subject: [PATCH 4/5] Diagnose client-certificate requirements in proxy/TLS failures - Recognize "tls: certificate required" and "tls: bad certificate" errors and explain that the CLI cannot present a client certificate or use the OS certificate store, which is why browsers and Windows-stack tools may work. - Skip the proxy-bypass advice for this case, since avoiding the proxy is not the fix. - In netcheck, record when the server or an intercepting proxy requests a client certificate, including the issuers it accepts, and print a dedicated verdict telling the user to ask their network team for an exemption. --- internal/httpclient/explain.go | 13 ++++- internal/httpclient/explain_test.go | 18 +++++++ internal/netcheck/netcheck.go | 75 ++++++++++++++++++++++++++++- internal/netcheck/netcheck_test.go | 29 +++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/internal/httpclient/explain.go b/internal/httpclient/explain.go index f85a113..5a0571e 100644 --- a/internal/httpclient/explain.go +++ b/internal/httpclient/explain.go @@ -44,7 +44,8 @@ func ExplainTransportError(err error, target *url.URL) error { handshake := strings.Contains(msg, "TLS handshake timeout") proxyConnect := strings.Contains(msg, "proxyconnect") untrusted := strings.Contains(msg, "x509:") - if !handshake && !proxyConnect && !untrusted { + clientCert := strings.Contains(msg, "tls: certificate required") || strings.Contains(msg, "tls: bad certificate") + if !handshake && !proxyConnect && !untrusted && !clientCert { return err } @@ -58,6 +59,14 @@ func ExplainTransportError(err error, target *url.URL) error { var hint string switch { + case clientCert: + hint = fmt.Sprintf("the server or a proxy on the path to %s asked for a client certificate, or rejected the one offered. "+ + "The CLI cannot present a client certificate; it does not use certificates from the operating system's store. "+ + "Browsers and tools built on the Windows TLS stack (PowerShell, curl.exe) can, which is why they may work. "+ + "Ask your network team to exempt %s from the client-certificate requirement", host, host) + if proxy != nil { + hint += fmt.Sprintf(" (the connection went through the proxy %s)", DescribeProxy(proxy)) + } case untrusted: hint = fmt.Sprintf("this machine does not trust the certificate presented for %s", host) if proxy != nil { @@ -76,7 +85,7 @@ func ExplainTransportError(err error, target *url.URL) error { default: return err } - if proxy != nil && !untrusted { + if proxy != nil && !untrusted && !clientCert { hint += ". " + BypassProxyAdvice(host) } hint += ". Run 'armis-cli mcp doctor --network' for a step-by-step report" diff --git a/internal/httpclient/explain_test.go b/internal/httpclient/explain_test.go index ac6d537..e56a1c2 100644 --- a/internal/httpclient/explain_test.go +++ b/internal/httpclient/explain_test.go @@ -185,4 +185,22 @@ func TestExplainTransportError(t *testing.T) { t.Error("the original error must stay in the chain") } }) + + t.Run("required client certificate explains the CLI cannot present one", func(t *testing.T) { + proxyChoices.Store(target.Host, proxyChoice{url: &url.URL{Scheme: "http", Host: "proxy.corp:8080"}}) + required := errors.New(`Post "https://api.example.com/path": remote error: tls: certificate required`) + got := ExplainTransportError(required, target) + msg := got.Error() + for _, want := range []string{"asked for a client certificate", "cannot present", "Windows TLS stack", "proxy.corp:8080", "mcp doctor --network"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q should contain %q", msg, want) + } + } + if strings.Contains(msg, EnvNoSystemProxy) { + t.Errorf("skipping the proxy is not the fix here, message %q", msg) + } + if !errors.Is(got, required) { + t.Error("the original error must stay in the chain") + } + }) } diff --git a/internal/netcheck/netcheck.go b/internal/netcheck/netcheck.go index 479237c..1fbe2d7 100644 --- a/internal/netcheck/netcheck.go +++ b/internal/netcheck/netcheck.go @@ -7,6 +7,8 @@ import ( "context" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "fmt" "io" "net/http" @@ -42,6 +44,10 @@ type attempt struct { // untrusted holds the certificate chain presented on a connection whose // certificate this machine does not trust. untrusted []*x509.Certificate + // clientCertAsked is set when the server (or a proxy decrypting the + // connection) requested a client certificate, which the CLI cannot send. + clientCertAsked bool + clientCertIssuers []string } // Run checks the connection and writes a report to opts.Out. It returns true @@ -136,6 +142,8 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { return append([]string(nil), events...) } + asked := observeClientCertRequest(tr) + trace := &httptrace.ClientTrace{ DNSStart: func(i httptrace.DNSStartInfo) { mark("looking up %s", i.Host) }, DNSDone: func(i httptrace.DNSDoneInfo) { @@ -183,6 +191,7 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { } resp, err := client.Do(req) //nolint:gosec // G704: URL is the configured Armis API base URL a.took = time.Since(start) + a.clientCertAsked, a.clientCertIssuers = asked() if err != nil { a.err = err if strings.Contains(err.Error(), "x509:") { @@ -198,6 +207,49 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { return a } +// observeClientCertRequest records when the server asks for a client +// certificate. The transport still answers with none, which is what it would +// do without this hook. +func observeClientCertRequest(tr *http.Transport) func() (bool, []string) { + var mu sync.Mutex + asked := false + var issuers []string + + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if tr.TLSClientConfig != nil { + cfg = tr.TLSClientConfig.Clone() + } + prev := cfg.GetClientCertificate + cfg.GetClientCertificate = func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { + mu.Lock() + asked = true + for _, name := range info.AcceptableCAs { + issuers = append(issuers, subjectString(name)) + } + mu.Unlock() + if prev != nil { + return prev(info) + } + return &tls.Certificate{}, nil + } + tr.TLSClientConfig = cfg + return func() (bool, []string) { + mu.Lock() + defer mu.Unlock() + return asked, issuers + } +} + +func subjectString(rawDN []byte) string { + var rdn pkix.RDNSequence + if _, err := asn1.Unmarshal(rawDN, &rdn); err != nil { + return "(unreadable)" + } + var name pkix.Name + name.FillFromRDNSequence(&rdn) + return name.String() +} + // presentedChain reads the certificates a server presents without trusting // them, so the report can name the authority that signed them. The handshake // is abandoned as soon as the chain is read and no request is sent. @@ -240,6 +292,13 @@ func report(out io.Writer, a attempt) { } if a.err != nil { _, _ = fmt.Fprintf(out, " FAILED after %s: %v\n", fmtDur(a.took), a.err) + if a.clientCertAsked { + line := " the server (or a proxy decrypting the connection) asked for a client certificate; the CLI sent none" + if len(a.clientCertIssuers) > 0 { + line += "; it accepts certificates issued by: " + strings.Join(a.clientCertIssuers, "; ") + } + _, _ = fmt.Fprintln(out, line) + } for i, c := range a.untrusted { _, _ = fmt.Fprintf(out, " certificate %d (not trusted by this machine): subject %q, issued by %q\n", i, c.Subject.String(), c.Issuer.String()) } @@ -255,7 +314,7 @@ func report(out io.Writer, a attempt) { func verdictDirectOnly(out io.Writer, a attempt) { _, _ = fmt.Fprintf(out, "\nResult\n") - if untrustedNote(out, a) { + if untrustedNote(out, a) || clientCertNote(out, a) { return } if a.err == nil { @@ -269,7 +328,7 @@ func verdictDirectOnly(out io.Writer, a attempt) { func verdictProxied(out io.Writer, used, direct attempt, host string) { _, _ = fmt.Fprintf(out, "\nResult\n") - if untrustedNote(out, used) { + if untrustedNote(out, used) || clientCertNote(out, used) { return } switch { @@ -291,6 +350,18 @@ func verdictProxied(out io.Writer, used, direct attempt, host string) { } } +// clientCertNote explains a connection that failed because a client +// certificate was required, and reports whether it did. +func clientCertNote(out io.Writer, a attempt) bool { + if a.err == nil || !a.clientCertAsked { + return false + } + _, _ = fmt.Fprintf(out, " A client certificate is required on this path and the CLI cannot present one.\n") + _, _ = fmt.Fprintf(out, " The CLI does not use certificates from the operating system's store. Tools built on the Windows TLS stack (PowerShell, curl.exe) can, which is why they may work.\n") + _, _ = fmt.Fprintf(out, " Ask your network team to exempt this host from the client-certificate requirement.\n") + return true +} + // untrustedNote explains a connection that failed because its certificate is // not trusted, and reports whether it did. func untrustedNote(out io.Writer, a attempt) bool { diff --git a/internal/netcheck/netcheck_test.go b/internal/netcheck/netcheck_test.go index 93336ca..8a2b7c0 100644 --- a/internal/netcheck/netcheck_test.go +++ b/internal/netcheck/netcheck_test.go @@ -232,3 +232,32 @@ func TestRunUntrustedCertificateNamesTheIssuer(t *testing.T) { t.Errorf("a trust problem must not be reported as a proxy stall:\n%s", report) } } + +func TestRunClientCertificateRequired(t *testing.T) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + srv.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert, MinVersion: tls.VersionTLS12} + srv.StartTLS() + defer srv.Close() + + var out bytes.Buffer + ok := Run(context.Background(), Options{ + BaseURL: srv.URL, + Out: &out, + proxyFor: func(*url.URL) (*url.URL, string, error) { return nil, "test", nil }, + transport: func(bool) *http.Transport { return serverTransport(srv) }, + }) + if ok { + t.Fatal("a server that requires a client certificate must fail") + } + report := out.String() + for _, want := range []string{ + "asked for a client certificate; the CLI sent none", + "A client certificate is required on this path and the CLI cannot present one", + } { + if !strings.Contains(report, want) { + t.Errorf("report should contain %q:\n%s", want, report) + } + } +} From fb50d8dfafaa381a6b4ce23a884d09872e0e0a70 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Wed, 4 Nov 2026 13:58:00 +0200 Subject: [PATCH 5/5] Use the verified-chain error instead of re-probing for untrusted certifi - Read the unverified chain from tls.CertificateVerificationError rather than opening a second connection with InsecureSkipVerify. - Drops presentedChain and its extra handshake, and no longer matches on the "x509:" error string. --- internal/netcheck/netcheck.go | 42 ++++------------------------------- 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/internal/netcheck/netcheck.go b/internal/netcheck/netcheck.go index 1fbe2d7..5629484 100644 --- a/internal/netcheck/netcheck.go +++ b/internal/netcheck/netcheck.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "errors" "fmt" "io" "net/http" @@ -194,8 +195,9 @@ func probe(ctx context.Context, tr *http.Transport, target *url.URL) attempt { a.clientCertAsked, a.clientCertIssuers = asked() if err != nil { a.err = err - if strings.Contains(err.Error(), "x509:") { - a.untrusted = presentedChain(ctx, tr, target) + var verr *tls.CertificateVerificationError + if errors.As(err, &verr) { + a.untrusted = verr.UnverifiedCertificates } a.events = snapshot() return a @@ -250,42 +252,6 @@ func subjectString(rawDN []byte) string { return name.String() } -// presentedChain reads the certificates a server presents without trusting -// them, so the report can name the authority that signed them. The handshake -// is abandoned as soon as the chain is read and no request is sent. -func presentedChain(ctx context.Context, tr *http.Transport, target *url.URL) []*x509.Certificate { - probeTr := tr.Clone() - defer probeTr.CloseIdleConnections() - // armis:ignore cwe:295 reason:diagnostic only; the connection is cancelled right after the handshake and no request or credentials are sent - probeTr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} //nolint:gosec // G402: read the presented chain only - ctx, cancel := context.WithTimeout(ctx, attemptTimeout) - defer cancel() - - var mu sync.Mutex - var certs []*x509.Certificate - trace := &httptrace.ClientTrace{ - TLSHandshakeDone: func(cs tls.ConnectionState, err error) { - if err == nil { - mu.Lock() - certs = cs.PeerCertificates - mu.Unlock() - } - cancel() - }, - } - req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, target.String(), nil) - if err != nil { - return nil - } - resp, err := (&http.Client{Transport: probeTr}).Do(req) //nolint:gosec // G704: URL is the configured Armis API base URL - if err == nil { - _ = resp.Body.Close() - } - mu.Lock() - defer mu.Unlock() - return certs -} - func report(out io.Writer, a attempt) { for _, e := range a.events { _, _ = fmt.Fprintf(out, " %s\n", e)