-
Notifications
You must be signed in to change notification settings - Fork 0
Add rots proxy probe command for live endpoint verification #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,13 +14,15 @@ | |||||||||||||||||
|
|
||||||||||||||||||
| import contextlib | ||||||||||||||||||
| import copy | ||||||||||||||||||
| import dataclasses | ||||||||||||||||||
| import json | ||||||||||||||||||
| import socket | ||||||||||||||||||
| import subprocess | ||||||||||||||||||
| import tempfile | ||||||||||||||||||
| import threading | ||||||||||||||||||
| import time | ||||||||||||||||||
| import urllib.parse | ||||||||||||||||||
| from datetime import UTC | ||||||||||||||||||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||
| from typing import TYPE_CHECKING | ||||||||||||||||||
|
|
@@ -37,6 +39,27 @@ class ProxyError(Exception): | |||||||||||||||||
| """Error during proxy configuration.""" | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| @dataclasses.dataclass | ||||||||||||||||||
| class ProbeResult: | ||||||||||||||||||
| """Result of probing a URL with curl.""" | ||||||||||||||||||
|
|
||||||||||||||||||
| url: str | ||||||||||||||||||
| http_code: int | ||||||||||||||||||
| ssl_verify_result: int # 0 = valid chain | ||||||||||||||||||
| ssl_verify_ok: bool | ||||||||||||||||||
| cert_issuer: str | ||||||||||||||||||
| cert_subject: str | ||||||||||||||||||
| cert_expiry: str | ||||||||||||||||||
| http_version: str | ||||||||||||||||||
| time_namelookup: float # seconds | ||||||||||||||||||
| time_connect: float | ||||||||||||||||||
| time_appconnect: float # TLS handshake complete | ||||||||||||||||||
| time_starttransfer: float # TTFB | ||||||||||||||||||
| time_total: float | ||||||||||||||||||
| response_headers: dict[str, str] | ||||||||||||||||||
| curl_json: dict # raw write-out for --json passthrough | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def parse_trace_url(url: str) -> urllib.parse.ParseResult: | ||||||||||||||||||
| """Normalise and validate a URL for ``proxy trace``. | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -493,3 +516,273 @@ def _read_stderr() -> str: | |||||||||||||||||
| except subprocess.TimeoutExpired: | ||||||||||||||||||
| proc.kill() | ||||||||||||||||||
| proc.wait(timeout=3) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||||||
| # Probe helpers | ||||||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||||||
|
|
||||||||||||||||||
| _CURL_SENTINEL = "%%CURL_JSON%%" | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def build_curl_args( | ||||||||||||||||||
| url: str, | ||||||||||||||||||
| *, | ||||||||||||||||||
| resolve: str | None = None, | ||||||||||||||||||
| connect_to: str | None = None, | ||||||||||||||||||
| cacert: Path | None = None, | ||||||||||||||||||
| cert_status: bool = False, | ||||||||||||||||||
| extra_headers: tuple[str, ...] = (), | ||||||||||||||||||
| timeout: int = 30, | ||||||||||||||||||
| method: str | None = None, | ||||||||||||||||||
| insecure: bool = False, | ||||||||||||||||||
| follow_redirects: bool = False, | ||||||||||||||||||
| ) -> list[str]: | ||||||||||||||||||
| """Build the curl command list for probing *url*. | ||||||||||||||||||
|
|
||||||||||||||||||
| Returns the full argv list without executing anything — purely | ||||||||||||||||||
| testable by asserting on the returned list. | ||||||||||||||||||
| """ | ||||||||||||||||||
| cmd = [ | ||||||||||||||||||
| "curl", | ||||||||||||||||||
| "-s", | ||||||||||||||||||
| "-o", | ||||||||||||||||||
| "/dev/null", | ||||||||||||||||||
| "-D", | ||||||||||||||||||
| "-", | ||||||||||||||||||
| "-w", | ||||||||||||||||||
| f"\n{_CURL_SENTINEL}\n%{{json}}", | ||||||||||||||||||
| "--max-time", | ||||||||||||||||||
| str(timeout), | ||||||||||||||||||
|
Comment on lines
+546
to
+556
|
||||||||||||||||||
| ] | ||||||||||||||||||
|
|
||||||||||||||||||
| if resolve is not None: | ||||||||||||||||||
| cmd.extend(["--resolve", resolve]) | ||||||||||||||||||
| if connect_to is not None: | ||||||||||||||||||
| cmd.extend(["--connect-to", connect_to]) | ||||||||||||||||||
| if cacert is not None: | ||||||||||||||||||
| cmd.extend(["--cacert", str(cacert)]) | ||||||||||||||||||
| if cert_status: | ||||||||||||||||||
| cmd.append("--cert-status") | ||||||||||||||||||
| for h in extra_headers: | ||||||||||||||||||
| cmd.extend(["-H", h]) | ||||||||||||||||||
| if method is not None: | ||||||||||||||||||
| cmd.extend(["-X", method]) | ||||||||||||||||||
| if insecure: | ||||||||||||||||||
| cmd.append("-k") | ||||||||||||||||||
| if follow_redirects: | ||||||||||||||||||
| cmd.append("-L") | ||||||||||||||||||
|
|
||||||||||||||||||
| cmd.append(url) | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To remediate this, use the
Suggested change
|
||||||||||||||||||
| return cmd | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def parse_curl_output(stdout: str) -> ProbeResult: | ||||||||||||||||||
| """Parse combined curl output (``-D -`` headers + ``-w '%{json}'``). | ||||||||||||||||||
|
|
||||||||||||||||||
| The output is split on the ``%%CURL_JSON%%`` sentinel. The first | ||||||||||||||||||
| part contains HTTP response headers; the second is the JSON blob | ||||||||||||||||||
| from curl's ``--write-out '%{json}'``. | ||||||||||||||||||
|
|
||||||||||||||||||
| Raises: | ||||||||||||||||||
| ProxyError: When the sentinel is missing or JSON is malformed. | ||||||||||||||||||
| """ | ||||||||||||||||||
| if _CURL_SENTINEL not in stdout: | ||||||||||||||||||
| raise ProxyError("curl output missing sentinel — unexpected format") | ||||||||||||||||||
|
|
||||||||||||||||||
| header_section, json_section = stdout.split(_CURL_SENTINEL, 1) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Parse response headers (skip status line) | ||||||||||||||||||
| response_headers: dict[str, str] = {} | ||||||||||||||||||
| for line in header_section.strip().splitlines(): | ||||||||||||||||||
| if ":" in line and not line.startswith("HTTP/"): | ||||||||||||||||||
| key, _, value = line.partition(":") | ||||||||||||||||||
| response_headers[key.strip()] = value.strip() | ||||||||||||||||||
delano marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
delano marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| try: | ||||||||||||||||||
| curl_json = json.loads(json_section.strip()) | ||||||||||||||||||
| except (json.JSONDecodeError, ValueError) as e: | ||||||||||||||||||
| raise ProxyError(f"curl JSON output malformed: {e}") from e | ||||||||||||||||||
|
|
||||||||||||||||||
| # Extract cert details from the certs string | ||||||||||||||||||
| certs_str = curl_json.get("certs", "") | ||||||||||||||||||
| cert_issuer = "" | ||||||||||||||||||
| cert_subject = "" | ||||||||||||||||||
| cert_expiry = "" | ||||||||||||||||||
| for cert_line in certs_str.splitlines(): | ||||||||||||||||||
| stripped = cert_line.strip() | ||||||||||||||||||
| if stripped.startswith("Issuer:") and not cert_issuer: | ||||||||||||||||||
| cert_issuer = stripped[len("Issuer:") :].strip() | ||||||||||||||||||
| elif stripped.startswith("Subject:") and not cert_subject: | ||||||||||||||||||
| cert_subject = stripped[len("Subject:") :].strip() | ||||||||||||||||||
| elif stripped.startswith("Expire date:") and not cert_expiry: | ||||||||||||||||||
| cert_expiry = stripped[len("Expire date:") :].strip() | ||||||||||||||||||
|
|
||||||||||||||||||
| ssl_verify = curl_json.get("ssl_verify_result", -1) | ||||||||||||||||||
| return ProbeResult( | ||||||||||||||||||
| url=curl_json.get("url_effective", curl_json.get("url", "")), | ||||||||||||||||||
| http_code=curl_json.get("http_code", 0), | ||||||||||||||||||
| ssl_verify_result=ssl_verify, | ||||||||||||||||||
| ssl_verify_ok=ssl_verify == 0, | ||||||||||||||||||
| cert_issuer=cert_issuer, | ||||||||||||||||||
| cert_subject=cert_subject, | ||||||||||||||||||
| cert_expiry=cert_expiry, | ||||||||||||||||||
| http_version=curl_json.get("http_version", ""), | ||||||||||||||||||
| time_namelookup=curl_json.get("time_namelookup", 0.0), | ||||||||||||||||||
| time_connect=curl_json.get("time_connect", 0.0), | ||||||||||||||||||
| time_appconnect=curl_json.get("time_appconnect", 0.0), | ||||||||||||||||||
| time_starttransfer=curl_json.get("time_starttransfer", 0.0), | ||||||||||||||||||
| time_total=curl_json.get("time_total", 0.0), | ||||||||||||||||||
| response_headers=response_headers, | ||||||||||||||||||
| curl_json=curl_json, | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def _parse_cert_expiry_days(cert_expiry: str) -> int | None: | ||||||||||||||||||
| """Parse cert expiry string and return days remaining. | ||||||||||||||||||
|
|
||||||||||||||||||
| The format comes from curl's ``%{json}`` output, e.g., | ||||||||||||||||||
| ``"Aug 17 23:59:59 2026 GMT"``. | ||||||||||||||||||
|
|
||||||||||||||||||
| Returns None on empty string or parse failure. | ||||||||||||||||||
| """ | ||||||||||||||||||
| if not cert_expiry: | ||||||||||||||||||
| return None | ||||||||||||||||||
| try: | ||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||
|
|
||||||||||||||||||
delano marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||
| expiry = datetime.strptime(cert_expiry, "%b %d %H:%M:%S %Y %Z") | ||||||||||||||||||
|
||||||||||||||||||
| expiry = datetime.strptime(cert_expiry, "%b %d %H:%M:%S %Y %Z") | |
| # Normalize and strip the fixed " GMT" suffix instead of relying on | |
| # platform-dependent %Z parsing. We then explicitly attach UTC. | |
| normalized = cert_expiry.strip() | |
| if normalized.endswith(" GMT"): | |
| normalized = normalized[: -len(" GMT")] | |
| expiry = datetime.strptime(normalized, "%b %d %H:%M:%S %Y") |
Uh oh!
There was an error while loading. Please reload this page.