opskit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
opskit/net/tcp.py ADDED
@@ -0,0 +1,164 @@
1
+ """Reusable TCP resolve/connect primitive (the seam the future net category builds on).
2
+
3
+ Pure stdlib sockets: :func:`resolve` orders candidates via ``getaddrinfo`` (dual-stack order is
4
+ the platform's), :func:`connect` walks them with timeout/retries and normalizes raw ``OSError``
5
+ into the :mod:`opskit.net.errors` hierarchy. Nothing here prints or exits (Art. VII).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+ import socket
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from opskit.net.errors import ConnectRefused, ConnectTimeout, ResolutionError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AddressCandidate:
21
+ """One resolved address a connection may be attempted against."""
22
+
23
+ address: str
24
+ family: str # "ipv4" | "ipv6"
25
+ sockaddr: tuple[Any, ...]
26
+ af: int # socket.AF_* constant
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ """Return a JSON-serializable mapping of this candidate."""
30
+ return {"address": self.address, "family": self.family}
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TcpConnection:
35
+ """Facts about an established TCP connection (returned alongside the socket)."""
36
+
37
+ address: str
38
+ family: str # "ipv4" | "ipv6"
39
+ port: int
40
+ connect_ms: float
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ """Return a JSON-serializable mapping of this connection."""
44
+ return {
45
+ "address": self.address,
46
+ "family": self.family,
47
+ "port": self.port,
48
+ "connect_ms": round(self.connect_ms, 3),
49
+ }
50
+
51
+
52
+ def _family_name(af: int) -> str:
53
+ return "ipv6" if af == socket.AF_INET6 else "ipv4"
54
+
55
+
56
+ def resolve(host: str, port: int, *, timeout: float = 5.0) -> list[AddressCandidate]:
57
+ """Resolve ``host`` to ordered address candidates (platform dual-stack order).
58
+
59
+ Args:
60
+ host: Hostname or IP literal.
61
+ port: Target port (carried into the socket addresses).
62
+ timeout: Wall-clock budget for the lookup. ``socket.getaddrinfo`` has no per-call
63
+ timeout, so it runs off-thread and is abandoned after ``timeout`` seconds.
64
+
65
+ Returns:
66
+ Candidates in the order the platform recommends trying them.
67
+
68
+ Raises:
69
+ ResolutionError: If the name does not resolve or resolution exceeds ``timeout``.
70
+ """
71
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
72
+ future = executor.submit(socket.getaddrinfo, host, port, type=socket.SOCK_STREAM)
73
+ try:
74
+ infos = future.result(timeout=timeout)
75
+ except concurrent.futures.TimeoutError as exc:
76
+ raise ResolutionError(
77
+ f"resolving {host} timed out after {timeout}s",
78
+ hint="the resolver may be slow or unreachable; diagnose with: opskit dns lookup "
79
+ + host,
80
+ ) from exc
81
+ except OSError as exc:
82
+ raise ResolutionError(
83
+ f"cannot resolve {host}: {exc}",
84
+ hint="check the name for typos, or diagnose with: opskit dns lookup "
85
+ + host,
86
+ ) from exc
87
+ finally:
88
+ # A lookup that timed out keeps running in the worker; don't block on it (the OS
89
+ # resolver abandons it per resolv.conf), just release the executor.
90
+ executor.shutdown(wait=False)
91
+ candidates: list[AddressCandidate] = []
92
+ for af, _, _, _, sockaddr in infos:
93
+ candidates.append(
94
+ AddressCandidate(
95
+ address=str(sockaddr[0]),
96
+ family=_family_name(af),
97
+ sockaddr=tuple(sockaddr),
98
+ af=af,
99
+ )
100
+ )
101
+ if not candidates:
102
+ raise ResolutionError(f"cannot resolve {host}: no addresses returned")
103
+ return candidates
104
+
105
+
106
+ def connect(
107
+ host: str,
108
+ port: int,
109
+ *,
110
+ timeout: float = 5.0,
111
+ retries: int = 2,
112
+ ) -> tuple[socket.socket, TcpConnection]:
113
+ """Open a TCP connection to ``host:port``; the caller owns (and must close) the socket.
114
+
115
+ Candidates are tried in :func:`resolve` order; timeouts are retried up to ``retries``
116
+ times across the candidate list, while a refusal on every candidate fails immediately.
117
+
118
+ Returns:
119
+ The connected socket (timeout still set) and a :class:`TcpConnection` report.
120
+
121
+ Raises:
122
+ ResolutionError: If the name does not resolve.
123
+ ConnectRefused: If every candidate refused / was unreachable.
124
+ ConnectTimeout: If no candidate answered before the timeout (after retries).
125
+ """
126
+ candidates = resolve(host, port, timeout=timeout)
127
+ start = time.perf_counter()
128
+ saw_timeout = False
129
+ refused_exc: BaseException | None = None
130
+ timeout_exc: BaseException | None = None
131
+ for _ in range(retries + 1):
132
+ for candidate in candidates:
133
+ sock = socket.socket(candidate.af, socket.SOCK_STREAM)
134
+ sock.settimeout(timeout)
135
+ try:
136
+ sock.connect(candidate.sockaddr)
137
+ except socket.timeout as exc:
138
+ sock.close()
139
+ saw_timeout = True
140
+ timeout_exc = exc
141
+ except OSError as exc:
142
+ sock.close()
143
+ refused_exc = exc
144
+ else:
145
+ connect_ms = (time.perf_counter() - start) * 1000.0
146
+ return sock, TcpConnection(
147
+ address=candidate.address,
148
+ family=candidate.family,
149
+ port=port,
150
+ connect_ms=connect_ms,
151
+ )
152
+ # A refusal is definitive (host reachable, port closed) — don't retry or wait for
153
+ # a slow sibling address; only pure-timeout runs are worth retrying.
154
+ if refused_exc is not None or not saw_timeout:
155
+ break
156
+ if refused_exc is not None:
157
+ raise ConnectRefused(
158
+ f"cannot connect to {host}:{port}: {refused_exc}",
159
+ hint="check that the service is listening on this port",
160
+ ) from refused_exc
161
+ raise ConnectTimeout(
162
+ f"no response from {host}:{port} within {timeout}s",
163
+ hint="the port may be filtered; verify reachability and firewall rules",
164
+ ) from timeout_exc
opskit/py.typed ADDED
File without changes
opskit/tls/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # `opskit tls` — TLS verification
2
+
3
+ Read-only TLS/certificate verification that behaves **identically on Windows, macOS, and
4
+ Linux** — a single replacement for `openssl s_client` incantations. Available both as a CLI
5
+ command and as an importable Python API.
6
+
7
+ > Part of [**opskit**](../../../README.md). See the root README for install and project-wide docs.
8
+
9
+ ---
10
+
11
+ ## Contents
12
+
13
+ - [Quick start](#quick-start)
14
+ - [`opskit tls check`](#opskit-tls-check)
15
+ - [Layered outcomes & exit codes](#layered-outcomes--exit-codes)
16
+ - [Trust & name validation](#trust--name-validation)
17
+ - [Expiry warnings & watch](#expiry-warnings--watch)
18
+ - [Bulk checks](#bulk-checks)
19
+ - [Output](#output)
20
+ - [Use as a Python library](#use-as-a-python-library)
21
+
22
+ ---
23
+
24
+ ## Quick start
25
+
26
+ ```bash
27
+ opskit tls check example.com # full verdict, port 443
28
+ opskit tls check example.com:8443 # non-standard port (or -p 8443)
29
+ opskit tls check 192.0.2.10 --sni internal.corp # IP endpoint, verify a vhost identity
30
+ opskit tls check ldap.corp.example:636 --ca-file corp-root.pem # private PKI
31
+ opskit tls check example.com --json # machine-readable envelope
32
+ ```
33
+
34
+ The report always shows the verdict, the leaf certificate (subject, issuer, SANs, validity,
35
+ days to expiry, serial, signature algorithm, key), the presented chain, the negotiated
36
+ protocol/cipher, and every failed condition with an actionable hint — **certificate details
37
+ are shown even when validation fails**, so you can inspect the bad certificate instead of
38
+ just being told it's bad.
39
+
40
+ ## `opskit tls check`
41
+
42
+ ```bash
43
+ opskit tls check [TARGET] [OPTIONS]
44
+ ```
45
+
46
+ `TARGET` accepts `host`, `host:port`, IPv4/IPv6 literals, or `[ipv6]:port`.
47
+
48
+ | Option | Description | Default |
49
+ |---|---|---|
50
+ | `-p, --port` | Port to check (must agree with a `host:port` shorthand) | `443` |
51
+ | `--sni` | Server name to send & verify (see below) | target hostname |
52
+ | `--ca-file` | PEM bundle replacing the platform trust store (private PKI) | platform store |
53
+ | `--warn-days` | Expiring-soon threshold in days (`0` disables) | `30` |
54
+ | `--timeout` | Per-attempt timeout (connect and handshake each), seconds | `5.0` |
55
+ | `--retries` | Retries on timeout | `2` |
56
+ | `-i, --input-file` | File of targets, one `host[:port]` per line (`#` comments allowed) | — |
57
+ | `--watch` | Re-run every interval (e.g. `30s`, `2m`) until Ctrl-C | off |
58
+ | `--json` / `--jsonl` | Versioned JSON envelope / NDJSON per target | off |
59
+ | `--no-color` | Disable colored output (`NO_COLOR` honored too) | off |
60
+
61
+ ## Layered outcomes & exit codes
62
+
63
+ The check walks resolve → connect → handshake → validate and reports **which layer broke**:
64
+
65
+ | Code | Meaning | Layer |
66
+ |---|---|---|
67
+ | `0` | TLS healthy | |
68
+ | `1` | generic error | |
69
+ | `2` | usage error (bad target/controls; before any network) | pre-flight |
70
+ | `3` | name resolution failure | resolve |
71
+ | `6` | timeout (connect or handshake, after retries) | connect/handshake |
72
+ | `7` | PARTIAL (batch with mixed outcomes) | aggregate |
73
+ | `8` | connection failed (refused / unreachable) | connect |
74
+ | `9` | handshake failed (incl. non-TLS service; STARTTLS is out of scope) | handshake |
75
+ | `10` | certificate invalid (expired, not-yet-valid, name mismatch, self-signed, untrusted/incomplete chain) | validate |
76
+ | `11` | certificate valid but expiring soon (within `--warn-days`) | validate |
77
+
78
+ Distinct certificate conditions are reported separately: **expired** vs **not yet valid**,
79
+ **self-signed** vs **untrusted chain** vs **incomplete chain** (server forgot the
80
+ intermediate), **name mismatch** (shows requested vs covered names), and **no SANs** (legacy
81
+ CN-only certificates).
82
+
83
+ **Protocol floor**: opskit is secure-by-default and requires **TLS 1.2 or newer**. A server
84
+ that offers only SSLv3 / TLS 1.0 / 1.1 fails the handshake (exit 9) with a hint, rather than
85
+ opskit negotiating down to a weak protocol.
86
+
87
+ ## Trust & name validation
88
+
89
+ - Chain validation uses the **platform trust store** by default (Windows certificate stores,
90
+ macOS/Linux OpenSSL paths); `--ca-file` replaces it entirely for private PKI.
91
+ - Name matching follows RFC 6125: exact DNS-SAN match, a wildcard covers exactly one
92
+ left-most label (`*.example.com` matches `a.example.com`, never `example.com` or
93
+ `a.b.example.com`), IP targets match IP SANs, and there is no CN fallback.
94
+ - **SNI**: the target hostname is sent by default and omitted for IP targets. With `--sni`,
95
+ the given name is both sent *and* used for name validation — so
96
+ `opskit tls check 192.0.2.10 --sni internal.corp` verifies the `internal.corp` identity on
97
+ that IP (split-horizon / pre-DNS setups).
98
+ - Revocation (OCSP/CRL) is not checked: opskit connects **only** to the endpoint you name.
99
+
100
+ ## Expiry warnings & watch
101
+
102
+ ```bash
103
+ opskit tls check example.com --warn-days 14 # exit 11 if < 14 days remain
104
+ opskit tls check example.com --watch 30s # flag outcome/certificate changes
105
+ ```
106
+
107
+ `--watch` flags a change when the outcome class, the leaf fingerprint (rotation!), the
108
+ expiry date, or the negotiated protocol changes — timing jitter is ignored.
109
+
110
+ ## Bulk checks
111
+
112
+ ```bash
113
+ opskit tls check -i endpoints.txt --jsonl | jq .
114
+ ```
115
+
116
+ Every target is processed (one failure never aborts the batch); failed targets appear in the
117
+ JSON output with their error; the exit code is `0` only if all pass, the class code if all
118
+ failures share one class, else `7`.
119
+
120
+ ## Output
121
+
122
+ `--json` emits the versioned envelope (`schema_version`, `command: "tls.check"`, `query`,
123
+ `result`, `error`, `elapsed_ms`); `--jsonl` emits one envelope per line. The `result` object
124
+ carries the outcome, connection (address/family/timing), `tls_version`, `cipher`, `leaf`,
125
+ `chain`, and `findings`.
126
+
127
+ ## Use as a Python library
128
+
129
+ ```python
130
+ from opskit.tls import check, CertificateInvalid
131
+
132
+ result = check("example.com:443", warn_days=14)
133
+ print(result.outcome.value, result.tls_version, result.cipher)
134
+ print(result.leaf.subject, result.leaf.days_until_expiry)
135
+ for cert in result.chain:
136
+ print(" ", cert.subject, "->", cert.issuer)
137
+ for finding in result.findings:
138
+ print("!", finding.code.value, finding.message)
139
+
140
+ try:
141
+ check("expired.badssl.com", raise_on_invalid=True)
142
+ except CertificateInvalid as exc:
143
+ print(exc.message, "—", exc.hint)
144
+ ```
145
+
146
+ Completed handshakes **return** a result (even for bad certificates — details stay
147
+ inspectable); failures that preclude a report (resolve/connect/handshake) **raise** typed
148
+ errors from `opskit.net` / `opskit.tls`. `raise_on_invalid=True` opts into exceptions for
149
+ certificate conditions too. The TCP primitive is importable from `opskit.net`
150
+ (`resolve`/`connect`) for building your own network tooling.
opskit/tls/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """TLS verification diagnostics — importable API and CLI sub-app.
2
+
3
+ Public surface (SemVer-governed): :func:`check`, the typed models, and the TLS exception
4
+ hierarchy. Failures raise; nothing here prints or exits the process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from opskit.tls.api import check
10
+ from opskit.tls.errors import (
11
+ CertificateExpiring,
12
+ CertificateInvalid,
13
+ HandshakeError,
14
+ TlsError,
15
+ )
16
+ from opskit.tls.models import (
17
+ CertificateInfo,
18
+ FindingCode,
19
+ TlsCheckResult,
20
+ TlsOutcome,
21
+ TlsTarget,
22
+ ValidationFinding,
23
+ parse_target,
24
+ )
25
+
26
+ __all__ = [
27
+ "CertificateExpiring",
28
+ "CertificateInfo",
29
+ "CertificateInvalid",
30
+ "FindingCode",
31
+ "HandshakeError",
32
+ "TlsCheckResult",
33
+ "TlsError",
34
+ "TlsOutcome",
35
+ "TlsTarget",
36
+ "ValidationFinding",
37
+ "check",
38
+ "parse_target",
39
+ ]
opskit/tls/api.py ADDED
@@ -0,0 +1,151 @@
1
+ """Public TLS verification API — the CLI is a thin client over this module.
2
+
3
+ :func:`check` orchestrates the layers (resolve → connect → handshake → validate). Failures
4
+ that preclude a report **raise** (usage, resolve, connect, handshake); completed handshakes
5
+ **return** a :class:`TlsCheckResult` whose findings carry certificate conditions, so a bad
6
+ certificate's details stay inspectable (FR-006). Nothing here prints or calls ``sys.exit``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from opskit.core.errors import UsageError
15
+ from opskit.net import connect as net_connect
16
+ from opskit.tls.errors import CertificateExpiring, CertificateInvalid
17
+ from opskit.tls.handshake import perform_handshake
18
+ from opskit.tls.inspect import build_findings, match_hostname, parse_certificate
19
+ from opskit.tls.models import (
20
+ FindingCode,
21
+ TlsCheckResult,
22
+ TlsOutcome,
23
+ parse_target,
24
+ )
25
+
26
+ _INVALID_FINDINGS = frozenset(
27
+ {
28
+ FindingCode.EXPIRED,
29
+ FindingCode.NOT_YET_VALID,
30
+ FindingCode.NAME_MISMATCH,
31
+ FindingCode.SELF_SIGNED,
32
+ FindingCode.UNTRUSTED_CHAIN,
33
+ FindingCode.INCOMPLETE_CHAIN,
34
+ FindingCode.NO_SANS,
35
+ }
36
+ )
37
+
38
+
39
+ def _validate_controls(timeout: float, retries: int, warn_days: int) -> None:
40
+ if timeout <= 0:
41
+ raise UsageError("timeout must be positive")
42
+ if retries < 0:
43
+ raise UsageError("retries must be >= 0")
44
+ if warn_days < 0:
45
+ raise UsageError("warn-days must be >= 0")
46
+
47
+
48
+ def check(
49
+ target: str,
50
+ *,
51
+ port: int | None = None,
52
+ server_name: str | None = None,
53
+ ca_file: str | Path | None = None,
54
+ warn_days: int = 30,
55
+ timeout: float = 5.0,
56
+ retries: int = 2,
57
+ raise_on_invalid: bool = False,
58
+ ) -> TlsCheckResult:
59
+ """Verify the TLS health of ``target`` (``host``, ``host:port``, IP, ``[v6]:port``).
60
+
61
+ Args:
62
+ target: Endpoint to check; the port defaults to 443.
63
+ port: Explicit port (must agree with any ``host:port`` shorthand).
64
+ server_name: SNI override; defaults to the hostname, omitted for IP targets.
65
+ ca_file: PEM bundle replacing the platform trust store (private PKI).
66
+ warn_days: Expiring-soon threshold in days (0 disables the warning class).
67
+ timeout: Per-attempt timeout for connect and handshake, seconds.
68
+ retries: Retries on timeout.
69
+ raise_on_invalid: Raise :class:`CertificateInvalid`/:class:`CertificateExpiring`
70
+ instead of returning a result whose outcome carries those conditions.
71
+
72
+ Returns:
73
+ A :class:`TlsCheckResult` with the layered outcome, negotiated protocol/cipher,
74
+ parsed leaf + chain, and validation findings.
75
+
76
+ Raises:
77
+ UsageError: Invalid target or controls (before any network I/O).
78
+ ResolutionError: The host does not resolve.
79
+ ConnectRefused: The port refused / was unreachable.
80
+ ConnectTimeout: Connect or handshake timed out (after retries).
81
+ HandshakeError: The TLS handshake failed (e.g. non-TLS service).
82
+ CertificateInvalid: Only with ``raise_on_invalid=True``.
83
+ CertificateExpiring: Only with ``raise_on_invalid=True``.
84
+ """
85
+ parsed = parse_target(target, port=port, server_name=server_name)
86
+ _validate_controls(timeout, retries, warn_days)
87
+
88
+ start = time.perf_counter()
89
+ sock, connection = net_connect(
90
+ parsed.host, parsed.port, timeout=timeout, retries=retries
91
+ )
92
+ try:
93
+ outcome = perform_handshake(
94
+ sock,
95
+ server_name=parsed.server_name,
96
+ timeout=timeout,
97
+ ca_file=ca_file,
98
+ )
99
+ finally:
100
+ sock.close()
101
+
102
+ chain = tuple(parse_certificate(cert) for cert in outcome.chain)
103
+ leaf_raw = outcome.chain[0] if outcome.chain else None
104
+ leaf = chain[0] if chain else None
105
+ if leaf is None or leaf_raw is None:
106
+ # A handshake without a peer certificate is not a usable TLS endpoint.
107
+ raise CertificateInvalid(
108
+ "server presented no certificate",
109
+ hint="the endpoint may require a protocol opskit does not speak",
110
+ )
111
+
112
+ findings = build_findings(
113
+ parsed,
114
+ leaf,
115
+ name_matched=match_hostname(parsed, leaf_raw),
116
+ verify_errors=outcome.verify_errors,
117
+ chain_length=len(chain),
118
+ tls_version=outcome.tls_version,
119
+ warn_days=warn_days,
120
+ )
121
+
122
+ codes = {finding.code for finding in findings}
123
+ if codes & _INVALID_FINDINGS:
124
+ verdict = TlsOutcome.CERT_INVALID
125
+ elif FindingCode.EXPIRING_SOON in codes:
126
+ verdict = TlsOutcome.EXPIRING_SOON
127
+ else:
128
+ verdict = TlsOutcome.OK
129
+
130
+ result = TlsCheckResult(
131
+ target=parsed,
132
+ outcome=verdict,
133
+ connection=connection,
134
+ tls_version=outcome.tls_version,
135
+ cipher=outcome.cipher,
136
+ leaf=leaf,
137
+ chain=chain,
138
+ findings=findings,
139
+ elapsed_ms=(time.perf_counter() - start) * 1000.0,
140
+ )
141
+
142
+ if raise_on_invalid and verdict is TlsOutcome.CERT_INVALID:
143
+ first = next(f for f in findings if f.code in _INVALID_FINDINGS)
144
+ raise CertificateInvalid(first.message, hint=first.hint, findings=findings)
145
+ if raise_on_invalid and verdict is TlsOutcome.EXPIRING_SOON:
146
+ raise CertificateExpiring(
147
+ f"certificate expires in {leaf.days_until_expiry} day(s)",
148
+ hint="schedule renewal",
149
+ days_remaining=leaf.days_until_expiry,
150
+ )
151
+ return result