hexproxy 0.2.2__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.
- hexproxy/__init__.py +7 -0
- hexproxy/__main__.py +5 -0
- hexproxy/app.py +192 -0
- hexproxy/bodyview.py +435 -0
- hexproxy/certs.py +222 -0
- hexproxy/clipboard.py +89 -0
- hexproxy/extensions.py +739 -0
- hexproxy/mcp.py +2114 -0
- hexproxy/models.py +72 -0
- hexproxy/preferences.py +131 -0
- hexproxy/proxy.py +1178 -0
- hexproxy/store.py +1001 -0
- hexproxy/themes.py +274 -0
- hexproxy/tui.py +8796 -0
- hexproxy-0.2.2.dist-info/METADATA +556 -0
- hexproxy-0.2.2.dist-info/RECORD +20 -0
- hexproxy-0.2.2.dist-info/WHEEL +5 -0
- hexproxy-0.2.2.dist-info/entry_points.txt +2 -0
- hexproxy-0.2.2.dist-info/licenses/LICENSE +37 -0
- hexproxy-0.2.2.dist-info/top_level.txt +1 -0
hexproxy/certs.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from threading import Lock
|
|
10
|
+
|
|
11
|
+
from .preferences import default_config_dir
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_certificate_dir() -> Path:
|
|
15
|
+
return default_config_dir() / "certs"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CertificateAuthority:
|
|
19
|
+
def __init__(self, base_dir: str | Path) -> None:
|
|
20
|
+
self.base_dir = Path(base_dir)
|
|
21
|
+
self.hosts_dir = self.base_dir / "hosts"
|
|
22
|
+
self.ca_cert = self.base_dir / "hexproxy-ca.crt"
|
|
23
|
+
self.ca_key = self.base_dir / "hexproxy-ca.key"
|
|
24
|
+
self.ca_serial = self.base_dir / "hexproxy-ca.srl"
|
|
25
|
+
self._lock = Lock()
|
|
26
|
+
|
|
27
|
+
def cert_path(self) -> Path:
|
|
28
|
+
return self.ca_cert
|
|
29
|
+
|
|
30
|
+
def is_ready(self) -> bool:
|
|
31
|
+
with self._lock:
|
|
32
|
+
return self.ca_cert.exists() and self.ca_key.exists()
|
|
33
|
+
|
|
34
|
+
def ensure_ready(self) -> Path:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.hosts_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
if self.ca_cert.exists() and self.ca_key.exists():
|
|
39
|
+
return self.ca_cert
|
|
40
|
+
|
|
41
|
+
openssl = self._openssl_path()
|
|
42
|
+
|
|
43
|
+
config_text = "\n".join(
|
|
44
|
+
[
|
|
45
|
+
"[req]",
|
|
46
|
+
"prompt = no",
|
|
47
|
+
"distinguished_name = dn",
|
|
48
|
+
"x509_extensions = v3_ca",
|
|
49
|
+
"",
|
|
50
|
+
"[dn]",
|
|
51
|
+
"CN = HexProxy Root CA",
|
|
52
|
+
"",
|
|
53
|
+
"[v3_ca]",
|
|
54
|
+
"basicConstraints = critical,CA:TRUE,pathlen:1",
|
|
55
|
+
"keyUsage = critical,keyCertSign,cRLSign",
|
|
56
|
+
"subjectKeyIdentifier = hash",
|
|
57
|
+
"authorityKeyIdentifier = keyid:always,issuer",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".cnf", delete=False) as handle:
|
|
61
|
+
config_path = Path(handle.name)
|
|
62
|
+
handle.write(config_text)
|
|
63
|
+
try:
|
|
64
|
+
subprocess.run(
|
|
65
|
+
[
|
|
66
|
+
openssl,
|
|
67
|
+
"req",
|
|
68
|
+
"-x509",
|
|
69
|
+
"-newkey",
|
|
70
|
+
"rsa:2048",
|
|
71
|
+
"-nodes",
|
|
72
|
+
"-days",
|
|
73
|
+
"3650",
|
|
74
|
+
"-keyout",
|
|
75
|
+
str(self.ca_key),
|
|
76
|
+
"-out",
|
|
77
|
+
str(self.ca_cert),
|
|
78
|
+
"-config",
|
|
79
|
+
str(config_path),
|
|
80
|
+
],
|
|
81
|
+
check=True,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
)
|
|
85
|
+
except subprocess.CalledProcessError as exc:
|
|
86
|
+
raise RuntimeError(exc.stderr.strip() or "failed to generate HexProxy CA") from exc
|
|
87
|
+
finally:
|
|
88
|
+
config_path.unlink(missing_ok=True)
|
|
89
|
+
return self.ca_cert
|
|
90
|
+
|
|
91
|
+
def issue_server_cert(self, host: str) -> tuple[Path, Path]:
|
|
92
|
+
self.ensure_ready()
|
|
93
|
+
safe_name = self._safe_name(host)
|
|
94
|
+
cert_path = self.hosts_dir / f"{safe_name}.crt"
|
|
95
|
+
key_path = self.hosts_dir / f"{safe_name}.key"
|
|
96
|
+
if self._host_cert_is_current(cert_path, key_path):
|
|
97
|
+
return cert_path, key_path
|
|
98
|
+
|
|
99
|
+
with self._lock:
|
|
100
|
+
if self._host_cert_is_current(cert_path, key_path):
|
|
101
|
+
return cert_path, key_path
|
|
102
|
+
|
|
103
|
+
openssl = self._openssl_path()
|
|
104
|
+
|
|
105
|
+
with tempfile.TemporaryDirectory(prefix="hexproxy-cert-") as tmpdir:
|
|
106
|
+
temp_dir = Path(tmpdir)
|
|
107
|
+
config_path = temp_dir / "leaf.cnf"
|
|
108
|
+
csr_path = temp_dir / "leaf.csr"
|
|
109
|
+
config_path.write_text(self._leaf_config(host), encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(
|
|
113
|
+
[
|
|
114
|
+
openssl,
|
|
115
|
+
"req",
|
|
116
|
+
"-new",
|
|
117
|
+
"-newkey",
|
|
118
|
+
"rsa:2048",
|
|
119
|
+
"-nodes",
|
|
120
|
+
"-keyout",
|
|
121
|
+
str(key_path),
|
|
122
|
+
"-out",
|
|
123
|
+
str(csr_path),
|
|
124
|
+
"-config",
|
|
125
|
+
str(config_path),
|
|
126
|
+
],
|
|
127
|
+
check=True,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
)
|
|
131
|
+
subprocess.run(
|
|
132
|
+
[
|
|
133
|
+
openssl,
|
|
134
|
+
"x509",
|
|
135
|
+
"-req",
|
|
136
|
+
"-in",
|
|
137
|
+
str(csr_path),
|
|
138
|
+
"-CA",
|
|
139
|
+
str(self.ca_cert),
|
|
140
|
+
"-CAkey",
|
|
141
|
+
str(self.ca_key),
|
|
142
|
+
"-CAcreateserial",
|
|
143
|
+
"-CAserial",
|
|
144
|
+
str(self.ca_serial),
|
|
145
|
+
"-days",
|
|
146
|
+
"90",
|
|
147
|
+
"-sha256",
|
|
148
|
+
"-out",
|
|
149
|
+
str(cert_path),
|
|
150
|
+
"-extfile",
|
|
151
|
+
str(config_path),
|
|
152
|
+
"-extensions",
|
|
153
|
+
"req_ext",
|
|
154
|
+
],
|
|
155
|
+
check=True,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
)
|
|
159
|
+
except subprocess.CalledProcessError as exc:
|
|
160
|
+
cert_path.unlink(missing_ok=True)
|
|
161
|
+
key_path.unlink(missing_ok=True)
|
|
162
|
+
raise RuntimeError(exc.stderr.strip() or f"failed to issue certificate for {host}") from exc
|
|
163
|
+
|
|
164
|
+
return cert_path, key_path
|
|
165
|
+
|
|
166
|
+
def _host_cert_is_current(self, cert_path: Path, key_path: Path) -> bool:
|
|
167
|
+
if not cert_path.exists() or not key_path.exists():
|
|
168
|
+
return False
|
|
169
|
+
ca_mtime = max(self.ca_cert.stat().st_mtime, self.ca_key.stat().st_mtime)
|
|
170
|
+
leaf_mtime = min(cert_path.stat().st_mtime, key_path.stat().st_mtime)
|
|
171
|
+
return leaf_mtime >= ca_mtime
|
|
172
|
+
|
|
173
|
+
def regenerate(self) -> Path:
|
|
174
|
+
with self._lock:
|
|
175
|
+
if self.hosts_dir.exists():
|
|
176
|
+
shutil.rmtree(self.hosts_dir)
|
|
177
|
+
self.ca_cert.unlink(missing_ok=True)
|
|
178
|
+
self.ca_key.unlink(missing_ok=True)
|
|
179
|
+
self.ca_serial.unlink(missing_ok=True)
|
|
180
|
+
return self.ensure_ready()
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _openssl_path() -> str:
|
|
184
|
+
openssl = shutil.which("openssl")
|
|
185
|
+
if openssl is not None:
|
|
186
|
+
return openssl
|
|
187
|
+
if os.name == "nt":
|
|
188
|
+
raise RuntimeError("openssl is required on Windows; install OpenSSL and add openssl.exe to PATH")
|
|
189
|
+
raise RuntimeError("openssl is required to generate HexProxy certificates")
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _safe_name(host: str) -> str:
|
|
193
|
+
return "".join(character if character.isalnum() or character in {"-", "_", "."} else "_" for character in host)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _leaf_config(host: str) -> str:
|
|
197
|
+
alt_name = CertificateAuthority._subject_alt_name(host)
|
|
198
|
+
return "\n".join(
|
|
199
|
+
[
|
|
200
|
+
"[req]",
|
|
201
|
+
"prompt = no",
|
|
202
|
+
"distinguished_name = dn",
|
|
203
|
+
"req_extensions = req_ext",
|
|
204
|
+
"",
|
|
205
|
+
"[dn]",
|
|
206
|
+
f"CN = {host}",
|
|
207
|
+
"",
|
|
208
|
+
"[req_ext]",
|
|
209
|
+
"basicConstraints = critical,CA:FALSE",
|
|
210
|
+
"keyUsage = critical,digitalSignature,keyEncipherment",
|
|
211
|
+
"extendedKeyUsage = serverAuth",
|
|
212
|
+
f"subjectAltName = {alt_name}",
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _subject_alt_name(host: str) -> str:
|
|
218
|
+
try:
|
|
219
|
+
ipaddress.ip_address(host)
|
|
220
|
+
except ValueError:
|
|
221
|
+
return f"DNS:{host}"
|
|
222
|
+
return f"IP:{host}"
|
hexproxy/clipboard.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from shutil import which
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_active_clipboard_process: subprocess.Popen[bytes] | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def copy_text_to_clipboard(text: str) -> str:
|
|
12
|
+
if sys.platform == "darwin":
|
|
13
|
+
_run_clipboard_command(["pbcopy"], text)
|
|
14
|
+
return "pbcopy"
|
|
15
|
+
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
if which("clip.exe"):
|
|
18
|
+
_run_clipboard_command(["clip.exe"], text)
|
|
19
|
+
return "clip.exe"
|
|
20
|
+
if which("pwsh.exe"):
|
|
21
|
+
_run_clipboard_command(["pwsh.exe", "-NoProfile", "-Command", "Set-Clipboard"], text)
|
|
22
|
+
return "pwsh.exe"
|
|
23
|
+
if which("powershell.exe"):
|
|
24
|
+
_run_clipboard_command(["powershell.exe", "-NoProfile", "-Command", "Set-Clipboard"], text)
|
|
25
|
+
return "powershell.exe"
|
|
26
|
+
raise RuntimeError("no clipboard command found on Windows; install PowerShell or ensure clip.exe is available")
|
|
27
|
+
|
|
28
|
+
if os.environ.get("WAYLAND_DISPLAY") and which("wl-copy"):
|
|
29
|
+
_run_resident_clipboard_command(["wl-copy"], text)
|
|
30
|
+
return "wl-copy"
|
|
31
|
+
if which("xclip"):
|
|
32
|
+
_run_resident_clipboard_command(["xclip", "-selection", "clipboard"], text)
|
|
33
|
+
return "xclip"
|
|
34
|
+
if which("xsel"):
|
|
35
|
+
_run_resident_clipboard_command(["xsel", "--clipboard", "--input"], text)
|
|
36
|
+
return "xsel"
|
|
37
|
+
|
|
38
|
+
raise RuntimeError("no clipboard command found; install wl-clipboard, xclip or xsel")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_clipboard_command(command: list[str], text: str) -> None:
|
|
42
|
+
completed = subprocess.run(command, input=text.encode("utf-8"), capture_output=True, check=False)
|
|
43
|
+
if completed.returncode != 0:
|
|
44
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
45
|
+
raise RuntimeError(stderr or f"{command[0]} exited with status {completed.returncode}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_resident_clipboard_command(command: list[str], text: str) -> None:
|
|
49
|
+
global _active_clipboard_process
|
|
50
|
+
|
|
51
|
+
_cleanup_active_clipboard_process()
|
|
52
|
+
process = subprocess.Popen(
|
|
53
|
+
command,
|
|
54
|
+
stdin=subprocess.PIPE,
|
|
55
|
+
stdout=subprocess.DEVNULL,
|
|
56
|
+
stderr=subprocess.PIPE,
|
|
57
|
+
start_new_session=True,
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
_, stderr = process.communicate(text.encode("utf-8"), timeout=0.2)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
_active_clipboard_process = process
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if process.returncode != 0:
|
|
66
|
+
message = stderr.decode("utf-8", errors="replace").strip()
|
|
67
|
+
raise RuntimeError(message or f"{command[0]} exited with status {process.returncode}")
|
|
68
|
+
_active_clipboard_process = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cleanup_active_clipboard_process() -> None:
|
|
72
|
+
global _active_clipboard_process
|
|
73
|
+
|
|
74
|
+
process = _active_clipboard_process
|
|
75
|
+
if process is None:
|
|
76
|
+
return
|
|
77
|
+
if process.poll() is not None:
|
|
78
|
+
_active_clipboard_process = None
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
process.terminate()
|
|
82
|
+
process.wait(timeout=0.2)
|
|
83
|
+
except (subprocess.TimeoutExpired, ProcessLookupError):
|
|
84
|
+
try:
|
|
85
|
+
process.kill()
|
|
86
|
+
process.wait(timeout=0.2)
|
|
87
|
+
except (subprocess.TimeoutExpired, ProcessLookupError):
|
|
88
|
+
pass
|
|
89
|
+
_active_clipboard_process = None
|