Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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`. 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

`armis-cli` ships tab-completion for commands, subcommands, and flag values (e.g.
Expand Down Expand Up @@ -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) |

---

Expand Down
4 changes: 2 additions & 2 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/auth/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/auth/idpconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions internal/cmd/mcp_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -27,6 +28,7 @@ var (
mcpDoctorVerbose bool
mcpDoctorDeep bool
mcpDoctorWatch bool
mcpDoctorNetwork bool
mcpDoctorStuckAfter time.Duration
)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -109,6 +125,7 @@ func init() {
mcpDoctorCmd.Flags().StringVar(&mcpDoctorBundlePath, "bundle-path", "", "Where to write the support bundle (implies --bundle; default: ./armis-mcp-doctor-<timestamp>.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")
}
Expand All @@ -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)
}
Expand All @@ -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()

Expand Down Expand Up @@ -484,3 +507,32 @@ 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 _, 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 f.set {
return fmt.Errorf("--network runs on its own; remove %s", f.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
}
51 changes: 51 additions & 0 deletions internal/cmd/mcp_doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
})
}
24 changes: 24 additions & 0 deletions internal/httpclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -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)
}
}
93 changes: 93 additions & 0 deletions internal/httpclient/explain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package httpclient

import (
"fmt"
"net/url"
"runtime"
"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)
}

// 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.
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")
untrusted := strings.Contains(msg, "x509:")
clientCert := strings.Contains(msg, "tls: certificate required") || strings.Contains(msg, "tls: bad certificate")
if !handshake && !proxyConnect && !untrusted && !clientCert {
return err
}

host := target.Hostname()
var proxy *url.URL
var source string
if v, ok := proxyChoices.Load(target.Host); ok {
choice := v.(proxyChoice)
proxy, source = choice.url, choice.source
}

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 {
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), 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), 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())
default:
return err
}
if proxy != nil && !untrusted && !clientCert {
hint += ". " + BypassProxyAdvice(host)
}
hint += ". Run 'armis-cli mcp doctor --network' for a step-by-step report"
return fmt.Errorf("%w (%s)", err, hint)
}
Loading
Loading