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.
- whisper_id-0.1.0/.github/workflows/ci.yml +15 -0
- whisper_id-0.1.0/.github/workflows/publish.yml +21 -0
- whisper_id-0.1.0/.gitignore +7 -0
- whisper_id-0.1.0/LICENSE +21 -0
- whisper_id-0.1.0/PKG-INFO +74 -0
- whisper_id-0.1.0/README.md +53 -0
- whisper_id-0.1.0/pyproject.toml +31 -0
- whisper_id-0.1.0/tests/test_whisper_id.py +129 -0
- whisper_id-0.1.0/whisper_id/__init__.py +219 -0
- whisper_id-0.1.0/whisper_id/py.typed +0 -0
|
@@ -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
|
whisper_id-0.1.0/LICENSE
ADDED
|
@@ -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
|