whisper-id 0.1.0__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.9", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python-version }}
14
+ - run: pip install -e . pytest
15
+ - run: python -m pytest -q
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+ permissions:
6
+ contents: read
7
+ id-token: write # OIDC — PyPI Trusted Publishing (no API token/secret)
8
+ jobs:
9
+ pypi:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.x"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ # No password: pypa/gh-action-pypi-publish authenticates via OIDC against the
19
+ # Trusted Publisher you configure at pypi.org/manage/account/publishing
20
+ # (project: whisper-id, owner: whisper-sec, repo: whisper-py, workflow: publish.yml).
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 viaGraph B.V. (Whisper Security)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: whisper-id
3
+ Version: 0.1.0
4
+ Summary: Give any Python agent a real, routable Whisper IPv6 identity and safe egress.
5
+ Project-URL: Homepage, https://whisper.online
6
+ Project-URL: Source, https://github.com/whisper-sec/whisper-py
7
+ Project-URL: Issues, https://github.com/whisper-sec/whisper-py/issues
8
+ Author: Whisper Security
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,dns,egress,identity,ipv6,proxy,whisper
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: Name Service (DNS)
16
+ Classifier: Topic :: System :: Networking
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: socks
19
+ Requires-Dist: pysocks>=1.7; extra == 'socks'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # whisper-id
23
+
24
+ A real, routable **IPv6 identity** and **safe egress** for any Python agent — in two calls.
25
+
26
+ ```sh
27
+ pip install whisper-id # add [socks] for requests+SOCKS: pip install "whisper-id[socks]"
28
+ ```
29
+
30
+ ```python
31
+ from whisper_id import register, egress
32
+ import requests
33
+
34
+ agent = register("my-bot") # a routable Whisper /128 identity
35
+ with egress(): # route this block through your /128
36
+ requests.get("https://api64.ipify.org").text # ← leaves from your Whisper IPv6
37
+ ```
38
+
39
+ That's it. Inside the `with` block the standard proxy env vars point at your local Whisper
40
+ proxy, so `requests`, `httpx`, `urllib`, and most libraries "just work"; on exit they're restored.
41
+
42
+ ## API
43
+
44
+ | Call | Does |
45
+ |------|------|
46
+ | `register(name, *, new_key=False)` | Create a named agent — a routable `/128`. `new_key=True` mints a new agent **and** its own API key. → `Agent(address, id, name)` |
47
+ | `egress(agent=None, *, tier="socks5", set_env=True)` | Context manager: bring up egress bound to your `/128`. Yields `Egress` (`.port`, `.proxy_url`, `.socks_url`, `.proxies`). `tier="wireguard"` for a routed `/128`. |
48
+ | `verify(address)` | Keyless — is `address` a real Whisper agent? (DANE + DNSSEC + reverse-DNS + JWS) → `bool` |
49
+ | `ip()` | Your current egress IP, proving it's your `/128`. → `str` |
50
+
51
+ Pass the proxy explicitly instead of via env if you prefer:
52
+
53
+ ```python
54
+ with egress(set_env=False) as e:
55
+ requests.get(url, proxies=e.proxies)
56
+ ```
57
+
58
+ ## Requirements
59
+
60
+ The `whisper` CLI on your `PATH` (this package is a thin, dependency-free wrapper over it):
61
+
62
+ ```sh
63
+ curl get.whisper.online | sh
64
+ ```
65
+
66
+ For authenticated calls (`register`, `egress`, `ip`), set `WHISPER_API_KEY` in the environment
67
+ or run `whisper login`. `verify` is keyless. (`$WHISPER_BIN` overrides the CLI path.)
68
+
69
+ ## Links
70
+
71
+ - Site — https://whisper.online
72
+ - CLI — https://github.com/whisper-sec/whisper-cli
73
+
74
+ MIT licensed.
@@ -0,0 +1,53 @@
1
+ # whisper-id
2
+
3
+ A real, routable **IPv6 identity** and **safe egress** for any Python agent — in two calls.
4
+
5
+ ```sh
6
+ pip install whisper-id # add [socks] for requests+SOCKS: pip install "whisper-id[socks]"
7
+ ```
8
+
9
+ ```python
10
+ from whisper_id import register, egress
11
+ import requests
12
+
13
+ agent = register("my-bot") # a routable Whisper /128 identity
14
+ with egress(): # route this block through your /128
15
+ requests.get("https://api64.ipify.org").text # ← leaves from your Whisper IPv6
16
+ ```
17
+
18
+ That's it. Inside the `with` block the standard proxy env vars point at your local Whisper
19
+ proxy, so `requests`, `httpx`, `urllib`, and most libraries "just work"; on exit they're restored.
20
+
21
+ ## API
22
+
23
+ | Call | Does |
24
+ |------|------|
25
+ | `register(name, *, new_key=False)` | Create a named agent — a routable `/128`. `new_key=True` mints a new agent **and** its own API key. → `Agent(address, id, name)` |
26
+ | `egress(agent=None, *, tier="socks5", set_env=True)` | Context manager: bring up egress bound to your `/128`. Yields `Egress` (`.port`, `.proxy_url`, `.socks_url`, `.proxies`). `tier="wireguard"` for a routed `/128`. |
27
+ | `verify(address)` | Keyless — is `address` a real Whisper agent? (DANE + DNSSEC + reverse-DNS + JWS) → `bool` |
28
+ | `ip()` | Your current egress IP, proving it's your `/128`. → `str` |
29
+
30
+ Pass the proxy explicitly instead of via env if you prefer:
31
+
32
+ ```python
33
+ with egress(set_env=False) as e:
34
+ requests.get(url, proxies=e.proxies)
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ The `whisper` CLI on your `PATH` (this package is a thin, dependency-free wrapper over it):
40
+
41
+ ```sh
42
+ curl get.whisper.online | sh
43
+ ```
44
+
45
+ For authenticated calls (`register`, `egress`, `ip`), set `WHISPER_API_KEY` in the environment
46
+ or run `whisper login`. `verify` is keyless. (`$WHISPER_BIN` overrides the CLI path.)
47
+
48
+ ## Links
49
+
50
+ - Site — https://whisper.online
51
+ - CLI — https://github.com/whisper-sec/whisper-cli
52
+
53
+ MIT licensed.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whisper-id"
7
+ version = "0.1.0"
8
+ description = "Give any Python agent a real, routable Whisper IPv6 identity and safe egress."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Whisper Security" }]
13
+ keywords = ["whisper", "agent", "identity", "ipv6", "egress", "proxy", "dns"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Internet :: Name Service (DNS)",
18
+ "Topic :: System :: Networking",
19
+ "Intended Audience :: Developers",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ socks = ["PySocks>=1.7"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://whisper.online"
27
+ Source = "https://github.com/whisper-sec/whisper-py"
28
+ Issues = "https://github.com/whisper-sec/whisper-py/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["whisper_id"]
@@ -0,0 +1,129 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 viaGraph B.V. (Whisper Security)
3
+ """Unit tests for whisper-id — the CLI is mocked, so these run anywhere (no live box)."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import subprocess
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ import whisper_id
13
+ from whisper_id import Agent, WhisperError, egress, ip, register, verify
14
+
15
+
16
+ def _proc(stdout="", stderr="", code=0):
17
+ return subprocess.CompletedProcess(args=["whisper"], returncode=code, stdout=stdout, stderr=stderr)
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def _fake_cli(monkeypatch):
22
+ # Pretend the binary exists so cli_path() resolves without hitting the real PATH.
23
+ monkeypatch.setenv("WHISPER_BIN", "/usr/bin/whisper")
24
+ # Clean proxy env for deterministic save/restore assertions.
25
+ for k in whisper_id._PROXY_VARS:
26
+ monkeypatch.delenv(k, raising=False)
27
+
28
+
29
+ def _capture(monkeypatch, proc):
30
+ calls = []
31
+
32
+ def fake_run(cmd, **kw):
33
+ calls.append(cmd)
34
+ return proc
35
+
36
+ monkeypatch.setattr(subprocess, "run", fake_run)
37
+ return calls
38
+
39
+
40
+ def test_cli_path_missing(monkeypatch):
41
+ monkeypatch.delenv("WHISPER_BIN", raising=False)
42
+ monkeypatch.setattr(whisper_id.shutil, "which", lambda _: None)
43
+ with pytest.raises(WhisperError, match="not found on PATH"):
44
+ whisper_id.cli_path()
45
+
46
+
47
+ def test_register_parses_address(monkeypatch):
48
+ calls = _capture(monkeypatch, _proc(stdout='{"agent":"2a04:2a01:b69a:6717:dead:beef:1:2","id":"ag_123"}'))
49
+ a = register("my-bot")
50
+ assert isinstance(a, Agent)
51
+ assert a.address == "2a04:2a01:b69a:6717:dead:beef:1:2"
52
+ assert a.id == "ag_123"
53
+ assert a.name == "my-bot"
54
+ assert calls[0][1:] == ["--json", "create", "--name", "my-bot"]
55
+
56
+
57
+ def test_register_new_key_flag(monkeypatch):
58
+ calls = _capture(monkeypatch, _proc(stdout='{"address":"2a04:2a01:1::9"}'))
59
+ register("boot", new_key=True)
60
+ assert "--register" in calls[0]
61
+
62
+
63
+ def test_register_requires_name():
64
+ with pytest.raises(WhisperError, match="non-empty"):
65
+ register(" ")
66
+
67
+
68
+ def test_register_no_address_raises(monkeypatch):
69
+ _capture(monkeypatch, _proc(stdout='{"ok":true}'))
70
+ with pytest.raises(WhisperError, match="no /128"):
71
+ register("x")
72
+
73
+
74
+ def test_egress_sets_and_restores_env(monkeypatch):
75
+ os.environ["HTTP_PROXY"] = "http://old:1" # must be restored exactly
76
+ _capture(monkeypatch, _proc(stdout="whisper: connection up on 127.0.0.1:36123\n"))
77
+ with egress() as e:
78
+ assert e.port == 36123
79
+ assert e.proxy_url == "http://127.0.0.1:36123"
80
+ assert e.socks_url == "socks5h://127.0.0.1:36123"
81
+ assert os.environ["HTTP_PROXY"] == "http://127.0.0.1:36123"
82
+ assert os.environ["ALL_PROXY"] == "socks5h://127.0.0.1:36123"
83
+ assert os.environ["HTTP_PROXY"] == "http://old:1" # restored
84
+ assert "ALL_PROXY" not in os.environ # was unset before → unset after
85
+ del os.environ["HTTP_PROXY"]
86
+
87
+
88
+ def test_egress_set_env_false_leaves_environ(monkeypatch):
89
+ _capture(monkeypatch, _proc(stdout="connection up on 127.0.0.1:40000"))
90
+ with egress(set_env=False) as e:
91
+ assert e.port == 40000
92
+ assert "HTTP_PROXY" not in os.environ
93
+
94
+
95
+ def test_egress_passes_agent_and_tier(monkeypatch):
96
+ calls = _capture(monkeypatch, _proc(stdout="up on 127.0.0.1:5555"))
97
+ with egress(agent="2a04:2a01:1::1", tier="wireguard"):
98
+ pass
99
+ cmd = calls[0]
100
+ assert "--agent" in cmd and "2a04:2a01:1::1" in cmd
101
+ assert "wireguard" in cmd
102
+
103
+
104
+ def test_egress_no_port_raises(monkeypatch):
105
+ _capture(monkeypatch, _proc(stdout="something went sideways"))
106
+ with pytest.raises(WhisperError, match="could not determine"):
107
+ with egress():
108
+ pass
109
+
110
+
111
+ def test_verify_truthy_on_zero_exit(monkeypatch):
112
+ _capture(monkeypatch, _proc(code=0))
113
+ assert verify("2a04:2a01:1::1") is True
114
+
115
+
116
+ def test_verify_false_on_nonzero(monkeypatch):
117
+ _capture(monkeypatch, _proc(code=3, stderr="not a whisper agent"))
118
+ assert verify("2001:db8::1") is False
119
+
120
+
121
+ def test_ip_returns_address(monkeypatch):
122
+ _capture(monkeypatch, _proc(stdout='{"agent":"2a04:2a01:1::a","ip":"2a04:2a01:1::a","verified":true}'))
123
+ assert ip() == "2a04:2a01:1::a"
124
+
125
+
126
+ def test_run_check_raises_with_stderr(monkeypatch):
127
+ _capture(monkeypatch, _proc(code=1, stderr="boom"))
128
+ with pytest.raises(WhisperError, match="boom"):
129
+ ip()
@@ -0,0 +1,219 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 viaGraph B.V. (Whisper Security)
3
+ """whisper-id — a real, routable IPv6 identity and safe egress for any Python agent.
4
+
5
+ A thin, dependency-free wrapper over the ``whisper`` CLI (https://whisper.online).
6
+ The CLI holds the auth, the control plane, and the egress tunnel; this package gives
7
+ you a Pythonic ``register()`` / ``egress()`` / ``verify()`` / ``ip()`` surface over it.
8
+
9
+ from whisper_id import register, egress
10
+
11
+ agent = register("my-bot") # a routable Whisper /128 identity
12
+ with egress(): # route this block's traffic via your /128
13
+ import requests
14
+ requests.get("https://api64.ipify.org").text # leaves from your Whisper IPv6
15
+
16
+ Requires the ``whisper`` CLI on PATH (``curl get.whisper.online | sh``) and, for the
17
+ authenticated calls, ``WHISPER_API_KEY`` in the environment (or a logged-in CLI).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ from contextlib import contextmanager
27
+ from dataclasses import dataclass
28
+ from typing import Iterator, Optional
29
+
30
+ __all__ = [
31
+ "register",
32
+ "egress",
33
+ "verify",
34
+ "ip",
35
+ "Agent",
36
+ "Egress",
37
+ "WhisperError",
38
+ "cli_path",
39
+ "__version__",
40
+ ]
41
+ __version__ = "0.1.0"
42
+
43
+ # The publicly-announced Whisper agent prefix (AS219419) — used to liberally recover a
44
+ # /128 from any control-plane envelope shape (Postel: be liberal in what we accept).
45
+ _ADDR_RE = re.compile(r"2a04:2a01:[0-9a-fA-F:]{2,}")
46
+ _HOSTPORT_RE = re.compile(r"127\.0\.0\.1:(\d{2,5})")
47
+ _PROXY_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
48
+
49
+
50
+ class WhisperError(RuntimeError):
51
+ """A ``whisper`` CLI invocation failed, or the CLI is not installed."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Agent:
56
+ """A Whisper agent — a routable IPv6 /128 that is both the identity and the auth."""
57
+
58
+ address: str
59
+ id: Optional[str] = None
60
+ name: Optional[str] = None
61
+
62
+
63
+ @dataclass
64
+ class Egress:
65
+ """A live local egress proxy bound to your /128."""
66
+
67
+ port: int
68
+ address: Optional[str] = None
69
+
70
+ @property
71
+ def proxy_url(self) -> str:
72
+ return f"http://127.0.0.1:{self.port}"
73
+
74
+ @property
75
+ def socks_url(self) -> str:
76
+ return f"socks5h://127.0.0.1:{self.port}"
77
+
78
+ @property
79
+ def proxies(self) -> dict:
80
+ """A requests/httpx-style proxies mapping for explicit per-call use."""
81
+ return {"http": self.proxy_url, "https": self.proxy_url}
82
+
83
+
84
+ def cli_path() -> str:
85
+ """Locate the ``whisper`` binary (``$WHISPER_BIN`` overrides ``PATH``)."""
86
+ found = os.environ.get("WHISPER_BIN") or shutil.which("whisper")
87
+ if not found:
88
+ raise WhisperError(
89
+ "the `whisper` CLI was not found on PATH. Install it with "
90
+ "`curl get.whisper.online | sh` (see https://whisper.online)."
91
+ )
92
+ return found
93
+
94
+
95
+ def _run(args, *, timeout: int = 120, check: bool = True) -> subprocess.CompletedProcess:
96
+ cmd = [cli_path(), *args]
97
+ try:
98
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
99
+ except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent
100
+ raise WhisperError(f"`whisper {' '.join(args)}` timed out after {timeout}s") from exc
101
+ if check and proc.returncode != 0:
102
+ detail = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
103
+ raise WhisperError(f"`whisper {' '.join(args)}` failed: {detail}")
104
+ return proc
105
+
106
+
107
+ def _run_json(args, **kw):
108
+ proc = _run(["--json", *args], **kw)
109
+ out = (proc.stdout or "").strip()
110
+ try:
111
+ return json.loads(out)
112
+ except json.JSONDecodeError as exc:
113
+ raise WhisperError(
114
+ f"could not parse JSON from `whisper {' '.join(args)}`: {out[:200]!r}"
115
+ ) from exc
116
+
117
+
118
+ def _first_addr(obj) -> Optional[str]:
119
+ """Pull the first Whisper /128 out of an arbitrary JSON envelope, or None."""
120
+ match = _ADDR_RE.search(json.dumps(obj))
121
+ return match.group(0) if match else None
122
+
123
+
124
+ def _get(obj, *keys):
125
+ """Liberally fetch the first present key (one level deep), or None."""
126
+ if isinstance(obj, dict):
127
+ for key in keys:
128
+ if obj.get(key):
129
+ return obj[key]
130
+ for value in obj.values():
131
+ if isinstance(value, dict):
132
+ got = _get(value, *keys)
133
+ if got:
134
+ return got
135
+ return None
136
+
137
+
138
+ def register(name: str, *, new_key: bool = False, timeout: int = 120) -> Agent:
139
+ """Create a named agent identity — a routable Whisper IPv6 /128.
140
+
141
+ Drives ``whisper create --name <name>``. Pass ``new_key=True`` to mint a brand-new
142
+ agent *with its own API key* (``op:register``). Requires ``WHISPER_API_KEY`` (or a
143
+ logged-in CLI), except ``new_key=True`` which bootstraps its own key.
144
+ """
145
+ if not name or not name.strip():
146
+ raise WhisperError("register() needs a non-empty agent name")
147
+ args = ["create", "--name", name]
148
+ if new_key:
149
+ args.append("--register")
150
+ env = _run_json(args, timeout=timeout)
151
+ addr = _first_addr(env)
152
+ if not addr:
153
+ raise WhisperError(f"no /128 returned by `whisper create`: {json.dumps(env)[:200]}")
154
+ return Agent(address=addr, id=_get(env, "id", "agent_id", "agentId"), name=name)
155
+
156
+
157
+ @contextmanager
158
+ def egress(
159
+ agent: Optional[str] = None,
160
+ *,
161
+ tier: str = "socks5",
162
+ set_env: bool = True,
163
+ timeout: int = 90,
164
+ ) -> Iterator[Egress]:
165
+ """Bring egress up bound to your /128 and route this block's traffic through it.
166
+
167
+ Drives ``whisper connect --ensure`` (an idempotent, detached local proxy). While the
168
+ ``with`` block is active the standard proxy env vars (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY)
169
+ point at the local proxy; on exit they are restored to their prior values. The proxy
170
+ daemon itself is left running (it is shared and idempotent).
171
+
172
+ with egress() as e:
173
+ requests.get("https://api64.ipify.org") # via your /128
174
+ requests.get(url, proxies=e.proxies) # or pass explicitly
175
+
176
+ Set ``set_env=False`` to only start the proxy and receive the :class:`Egress` handle
177
+ without mutating the process environment.
178
+ """
179
+ args = ["connect", "--ensure", "--tier", tier]
180
+ if agent:
181
+ args += ["--agent", agent]
182
+ proc = _run(args, timeout=timeout)
183
+ text = (proc.stdout or "") + "\n" + (proc.stderr or "")
184
+ match = _HOSTPORT_RE.search(text)
185
+ if not match:
186
+ raise WhisperError(
187
+ f"could not determine the local proxy port from `whisper connect`: {text.strip()[:200]!r}"
188
+ )
189
+ handle = Egress(port=int(match.group(1)), address=agent)
190
+ saved = {k: os.environ.get(k) for k in _PROXY_VARS} if set_env else {}
191
+ try:
192
+ if set_env:
193
+ os.environ["HTTP_PROXY"] = os.environ["http_proxy"] = handle.proxy_url
194
+ os.environ["HTTPS_PROXY"] = os.environ["https_proxy"] = handle.proxy_url
195
+ os.environ["ALL_PROXY"] = os.environ["all_proxy"] = handle.socks_url
196
+ yield handle
197
+ finally:
198
+ for key, value in saved.items():
199
+ if value is None:
200
+ os.environ.pop(key, None)
201
+ else:
202
+ os.environ[key] = value
203
+
204
+
205
+ def verify(address: str, *, timeout: int = 60) -> bool:
206
+ """Return ``True`` iff ``address`` is a real Whisper agent.
207
+
208
+ Keyless: drives ``whisper verify`` (DANE + DNSSEC + reverse-DNS + JWS). Never raises
209
+ on a negative verdict — it returns ``False``.
210
+ """
211
+ if not address or not address.strip():
212
+ raise WhisperError("verify() needs an address")
213
+ return _run(["verify", address], timeout=timeout, check=False).returncode == 0
214
+
215
+
216
+ def ip(*, timeout: int = 60) -> str:
217
+ """Return the current egress IP, proving it is your Whisper /128 (drives ``whisper ip``)."""
218
+ env = _run_json(["ip"], timeout=timeout)
219
+ return _get(env, "ip", "agent") or _first_addr(env) or ""
File without changes