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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fixed TLS validation failure behind corporate TLS-intercepting proxies and firewalls: `install/validation.py` now uses `requests` (honouring `REQUESTS_CA_BUNDLE`) instead of stdlib `urllib`, and surfaces a single CA-trust hint at default verbosity instead of a misleading auth error. (#911)

## [0.9.3] - 2026-04-26

### Added
Expand Down
6 changes: 6 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export default defineConfig({
{ label: 'Agent Workflows (Experimental)', slug: 'guides/agent-workflows' },
],
},
{
label: 'Troubleshooting',
items: [
{ label: 'SSL / TLS issues', slug: 'troubleshooting/ssl-issues' },
],
},
{
label: 'Enterprise',
items: [
Expand Down
40 changes: 40 additions & 0 deletions docs/src/content/docs/troubleshooting/ssl-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: "SSL / TLS issues"
description: "Fix SSL/TLS verification errors when running APM."
sidebar:
order: 1
---

If `apm install` fails with a TLS error like:

```text
[!] TLS verification failed -- if you're behind a corporate proxy or firewall, set the REQUESTS_CA_BUNDLE environment variable to the path of your organisation's CA bundle (a PEM file) and retry.
```

The most common cause is a corporate TLS-intercepting proxy or firewall (Zscaler, Netskope, Palo Alto, etc.) re-signing HTTPS traffic with an internal CA that APM doesn't trust.

## Fix

Point APM at the PEM file containing your organisation's CA. Ask your IT team for the path if you don't know it.

**Linux / macOS:**

```bash
export REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem
```

To persist across sessions, add the same line to your shell profile (`~/.bashrc`, `~/.zshrc`, `~/.profile`, etc.).

**Windows (PowerShell):**

```powershell
# Current session only
$env:REQUESTS_CA_BUNDLE = "C:\path\to\corporate-ca.pem"

# Persist for future sessions (user-level)
[Environment]::SetEnvironmentVariable("REQUESTS_CA_BUNDLE", "C:\path\to\corporate-ca.pem", "User")
```

## Not behind a proxy or firewall?

The root cause is likely somewhere else. Re-run with `--verbose` for the underlying exception.
109 changes: 80 additions & 29 deletions src/apm_cli/install/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,53 @@

from pathlib import Path

import requests

from ..utils.console import _rich_echo, _rich_info
from ..utils.github_host import default_host

# ---------------------------------------------------------------------------
# TLS failure helpers
# ---------------------------------------------------------------------------

# Marker prefix used on RuntimeError messages raised when the underlying
# network probe fails TLS verification. Lets the caller distinguish trust
# failures from auth / 404 / network errors so the user is not pushed down
# the PAT troubleshooting path for a CA-trust problem.
_TLS_ERROR_PREFIX = "TLS verification failed"


def _is_tls_failure(exc: BaseException) -> bool:
"""Return True if exc (or any cause in its chain) is a TLS verification failure."""
cur: BaseException | None = exc
seen = 0
while cur is not None and seen < 8:
msg = str(cur)
if _TLS_ERROR_PREFIX in msg or "CERTIFICATE_VERIFY_FAILED" in msg:
return True
if isinstance(cur, requests.exceptions.SSLError):
return True
cur = cur.__cause__ or cur.__context__
seen += 1
return False


def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger) -> None:
"""Surface a TLS verification failure with an actionable CA-trust hint.

Default verbosity: a single one-liner via ``logger.warning`` so users behind
a corporate proxy see the right next step without re-running with --verbose.
Verbose: also include the host name and the underlying exception text.
"""
logger.warning(
"TLS verification failed -- if you're behind a corporate proxy or "
"firewall, set the REQUESTS_CA_BUNDLE environment variable to the "
"path of your organisation's CA bundle (a PEM file) and retry. "
"See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/"
)
if verbose_log:
verbose_log(f"underlying error from {host_display}: {exc}")


# ---------------------------------------------------------------------------
# Validation helpers
Expand Down Expand Up @@ -306,9 +350,6 @@ def _validate_package_exists(package, verbose=False, auth_resolver=None, logger=

def _check_repo(token, git_env):
"""Check repo accessibility via GitHub API (or git ls-remote for non-GitHub)."""
import urllib.request
import urllib.error

api_base = host_info.api_base
api_url = f"{api_base}/repos/{dep_ref.repo_url}"
headers = {
Expand All @@ -318,24 +359,26 @@ def _check_repo(token, git_env):
if token:
headers["Authorization"] = f"Bearer {token}"

req = urllib.request.Request(api_url, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=15)
if verbose_log:
verbose_log(f"API {api_url} -> {resp.status}")
return True
except urllib.error.HTTPError as e:
if verbose_log:
verbose_log(f"API {api_url} -> {e.code} {e.reason}")
if e.code == 404 and token:
# 404 with token could mean no access -- raise to trigger fallback
raise RuntimeError(f"API returned {e.code}")
raise RuntimeError(f"API returned {e.code}: {e.reason}")
except Exception as e:
resp = requests.get(api_url, headers=headers, timeout=15)
except requests.exceptions.SSLError as e:
raise RuntimeError(
f"TLS verification failed for {host_info.display_name}"
) from e
except requests.exceptions.RequestException as e:
if verbose_log:
verbose_log(f"API request failed: {e}")
raise

if verbose_log:
verbose_log(f"API {api_url} -> {resp.status_code}")
if resp.ok:
return True
if resp.status_code == 404 and token:
# 404 with token could mean no access -- raise to trigger fallback
raise RuntimeError(f"API returned {resp.status_code}")
raise RuntimeError(f"API returned {resp.status_code}: {resp.reason}")

try:
return auth_resolver.try_with_fallback(
host, _check_repo,
Expand All @@ -344,7 +387,10 @@ def _check_repo(token, git_env):
unauth_first=True,
verbose_callback=verbose_log,
)
except Exception:
except Exception as exc:
if _is_tls_failure(exc):
_log_tls_failure(host_info.display_name, exc, verbose_log, logger)
return False
if verbose_log:
try:
ctx = auth_resolver.build_error_context(
Expand All @@ -364,9 +410,6 @@ def _check_repo(token, git_env):
repo_path = package # owner/repo format

def _check_repo_fallback(token, git_env):
import urllib.request
import urllib.error

host_info = auth_resolver.classify_host(host)
api_url = f"{host_info.api_base}/repos/{repo_path}"
headers = {
Expand All @@ -376,27 +419,35 @@ def _check_repo_fallback(token, git_env):
if token:
headers["Authorization"] = f"Bearer {token}"

req = urllib.request.Request(api_url, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=15)
return True
except urllib.error.HTTPError as e:
if verbose_log:
verbose_log(f"API fallback -> {e.code} {e.reason}")
raise RuntimeError(f"API returned {e.code}")
except Exception as e:
resp = requests.get(api_url, headers=headers, timeout=15)
except requests.exceptions.SSLError as e:
raise RuntimeError(
f"TLS verification failed for {host_info.display_name}"
) from e
except requests.exceptions.RequestException as e:
if verbose_log:
verbose_log(f"API fallback failed: {e}")
raise

if resp.ok:
return True
if verbose_log:
verbose_log(f"API fallback -> {resp.status_code} {resp.reason}")
raise RuntimeError(f"API returned {resp.status_code}")

try:
return auth_resolver.try_with_fallback(
host, _check_repo_fallback,
org=org,
unauth_first=True,
verbose_callback=verbose_log,
)
except Exception:
except Exception as exc:
if _is_tls_failure(exc):
# See note above: logged once here, skip auth context render.
_log_tls_failure(host, exc, verbose_log, logger)
return False
if verbose_log:
try:
ctx = auth_resolver.build_error_context(host, f"accessing {package}", org=org, dep_url=package)
Expand Down
Loading
Loading