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/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