mtrx-cli 0.1.11 → 0.1.14
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.
- package/package.json +7 -2
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_ca.py +275 -0
- package/src/matrx/cli/cursor_config.py +262 -74
- package/src/matrx/cli/cursor_daemon.py +64 -0
- package/src/matrx/cli/cursor_launcher.py +106 -0
- package/src/matrx/cli/cursor_proxy.py +459 -261
- package/src/matrx/cli/cursor_service.py +343 -0
- package/src/matrx/cli/launcher.py +47 -27
- package/src/matrx/cli/main.py +156 -52
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrx-cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "MATRX CLI for routing Codex and
|
|
3
|
+
"version": "0.1.14",
|
|
4
|
+
"description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
|
|
5
5
|
"homepage": "https://mtrx.so",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"cli",
|
|
16
16
|
"codex",
|
|
17
17
|
"claude",
|
|
18
|
+
"cursor",
|
|
18
19
|
"ai"
|
|
19
20
|
],
|
|
20
21
|
"bin": {
|
|
@@ -24,8 +25,12 @@
|
|
|
24
25
|
"bin/mtrx.js",
|
|
25
26
|
"src/matrx/__init__.py",
|
|
26
27
|
"src/matrx/cli/__init__.py",
|
|
28
|
+
"src/matrx/cli/cursor_ca.py",
|
|
27
29
|
"src/matrx/cli/cursor_config.py",
|
|
30
|
+
"src/matrx/cli/cursor_daemon.py",
|
|
31
|
+
"src/matrx/cli/cursor_launcher.py",
|
|
28
32
|
"src/matrx/cli/cursor_proxy.py",
|
|
33
|
+
"src/matrx/cli/cursor_service.py",
|
|
29
34
|
"src/matrx/cli/launcher.py",
|
|
30
35
|
"src/matrx/cli/main.py",
|
|
31
36
|
"src/matrx/cli/state.py"
|
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.14"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CA certificate management for the MTRX Cursor MITM proxy.
|
|
3
|
+
|
|
4
|
+
Generates a root CA key + certificate, dynamically signs per-host leaf
|
|
5
|
+
certificates, and provides platform-specific helpers for trusting the CA
|
|
6
|
+
in the system/Cursor environment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from cryptography import x509
|
|
20
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
22
|
+
from cryptography.x509.oid import NameOID
|
|
23
|
+
|
|
24
|
+
from matrx.cli.state import config_dir
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_CA_DIR_NAME = "ca"
|
|
29
|
+
_CA_KEY_FILE = "mtrx-ca.key"
|
|
30
|
+
_CA_CERT_FILE = "mtrx-ca.pem"
|
|
31
|
+
_CERT_VALIDITY_YEARS = 5
|
|
32
|
+
_LEAF_VALIDITY_DAYS = 365
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ca_dir() -> Path:
|
|
36
|
+
return config_dir() / _CA_DIR_NAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ca_key_path() -> Path:
|
|
40
|
+
return ca_dir() / _CA_KEY_FILE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ca_cert_path() -> Path:
|
|
44
|
+
return ca_dir() / _CA_CERT_FILE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ca_exists() -> bool:
|
|
48
|
+
return ca_key_path().exists() and ca_cert_path().exists()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_ca(*, force: bool = False) -> tuple[Path, Path]:
|
|
52
|
+
"""Generate a root CA key and self-signed certificate.
|
|
53
|
+
|
|
54
|
+
Returns (key_path, cert_path). Skips generation if files already exist
|
|
55
|
+
unless *force* is True.
|
|
56
|
+
"""
|
|
57
|
+
key_path = ca_key_path()
|
|
58
|
+
cert_path = ca_cert_path()
|
|
59
|
+
if not force and key_path.exists() and cert_path.exists():
|
|
60
|
+
return key_path, cert_path
|
|
61
|
+
|
|
62
|
+
ca_dir().mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
65
|
+
subject = issuer = x509.Name([
|
|
66
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MTRX"),
|
|
67
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "MTRX Cursor Proxy CA"),
|
|
68
|
+
])
|
|
69
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
70
|
+
cert = (
|
|
71
|
+
x509.CertificateBuilder()
|
|
72
|
+
.subject_name(subject)
|
|
73
|
+
.issuer_name(issuer)
|
|
74
|
+
.public_key(key.public_key())
|
|
75
|
+
.serial_number(x509.random_serial_number())
|
|
76
|
+
.not_valid_before(now)
|
|
77
|
+
.not_valid_after(now + datetime.timedelta(days=_CERT_VALIDITY_YEARS * 365))
|
|
78
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
|
79
|
+
.add_extension(
|
|
80
|
+
x509.KeyUsage(
|
|
81
|
+
digital_signature=True,
|
|
82
|
+
key_cert_sign=True,
|
|
83
|
+
crl_sign=True,
|
|
84
|
+
content_commitment=False,
|
|
85
|
+
key_encipherment=False,
|
|
86
|
+
data_encipherment=False,
|
|
87
|
+
key_agreement=False,
|
|
88
|
+
encipher_only=False,
|
|
89
|
+
decipher_only=False,
|
|
90
|
+
),
|
|
91
|
+
critical=True,
|
|
92
|
+
)
|
|
93
|
+
.sign(key, hashes.SHA256())
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
key_path.write_bytes(
|
|
97
|
+
key.private_bytes(
|
|
98
|
+
encoding=serialization.Encoding.PEM,
|
|
99
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
100
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
os.chmod(key_path, 0o600)
|
|
104
|
+
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
105
|
+
|
|
106
|
+
return key_path, cert_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_ca() -> tuple[rsa.RSAPrivateKey, x509.Certificate]:
|
|
110
|
+
"""Load the CA key and certificate from disk."""
|
|
111
|
+
key_pem = ca_key_path().read_bytes()
|
|
112
|
+
cert_pem = ca_cert_path().read_bytes()
|
|
113
|
+
key = serialization.load_pem_private_key(key_pem, password=None)
|
|
114
|
+
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
115
|
+
return key, cert # type: ignore[return-value]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CertCache:
|
|
119
|
+
"""Thread-safe cache for dynamically-signed leaf certificates.
|
|
120
|
+
|
|
121
|
+
Caches both the raw PEM bytes and ready-to-use ``ssl.SSLContext``
|
|
122
|
+
objects so that repeated connections to the same host avoid temp-file
|
|
123
|
+
I/O and key-generation overhead.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, ca_key: rsa.RSAPrivateKey, ca_cert: x509.Certificate):
|
|
127
|
+
self._ca_key = ca_key
|
|
128
|
+
self._ca_cert = ca_cert
|
|
129
|
+
self._lock = threading.Lock()
|
|
130
|
+
self._cache: dict[str, tuple[bytes, bytes]] = {}
|
|
131
|
+
self._ctx_cache: dict[str, "ssl.SSLContext"] = {}
|
|
132
|
+
self._certs_dir = ca_dir() / "certs"
|
|
133
|
+
self._certs_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
def get_cert_pair(self, hostname: str) -> tuple[bytes, bytes]:
|
|
136
|
+
"""Return (cert_pem, key_pem) for *hostname*, creating if needed."""
|
|
137
|
+
with self._lock:
|
|
138
|
+
if hostname in self._cache:
|
|
139
|
+
return self._cache[hostname]
|
|
140
|
+
|
|
141
|
+
cert_pem, key_pem = self._sign_leaf(hostname)
|
|
142
|
+
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._cache[hostname] = (cert_pem, key_pem)
|
|
145
|
+
return cert_pem, key_pem
|
|
146
|
+
|
|
147
|
+
def get_ssl_context(self, hostname: str) -> "ssl.SSLContext":
|
|
148
|
+
"""Return a server-side ``ssl.SSLContext`` for *hostname*."""
|
|
149
|
+
import ssl as _ssl
|
|
150
|
+
|
|
151
|
+
with self._lock:
|
|
152
|
+
if hostname in self._ctx_cache:
|
|
153
|
+
return self._ctx_cache[hostname]
|
|
154
|
+
|
|
155
|
+
cert_pem, key_pem = self.get_cert_pair(hostname)
|
|
156
|
+
|
|
157
|
+
cert_file = self._certs_dir / f"{hostname}.crt"
|
|
158
|
+
key_file = self._certs_dir / f"{hostname}.key"
|
|
159
|
+
cert_file.write_bytes(cert_pem)
|
|
160
|
+
key_file.write_bytes(key_pem)
|
|
161
|
+
os.chmod(key_file, 0o600)
|
|
162
|
+
|
|
163
|
+
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
|
|
164
|
+
ctx.load_cert_chain(str(cert_file), str(key_file))
|
|
165
|
+
ctx.set_alpn_protocols(["http/1.1"])
|
|
166
|
+
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._ctx_cache[hostname] = ctx
|
|
169
|
+
return ctx
|
|
170
|
+
|
|
171
|
+
def _sign_leaf(self, hostname: str) -> tuple[bytes, bytes]:
|
|
172
|
+
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
173
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
174
|
+
subject = x509.Name([
|
|
175
|
+
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
builder = (
|
|
179
|
+
x509.CertificateBuilder()
|
|
180
|
+
.subject_name(subject)
|
|
181
|
+
.issuer_name(self._ca_cert.subject)
|
|
182
|
+
.public_key(leaf_key.public_key())
|
|
183
|
+
.serial_number(x509.random_serial_number())
|
|
184
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
185
|
+
.not_valid_after(now + datetime.timedelta(days=_LEAF_VALIDITY_DAYS))
|
|
186
|
+
.add_extension(
|
|
187
|
+
x509.SubjectAlternativeName([x509.DNSName(hostname)]),
|
|
188
|
+
critical=False,
|
|
189
|
+
)
|
|
190
|
+
.add_extension(
|
|
191
|
+
x509.BasicConstraints(ca=False, path_length=None),
|
|
192
|
+
critical=True,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
leaf_cert = builder.sign(self._ca_key, hashes.SHA256())
|
|
196
|
+
|
|
197
|
+
cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
|
|
198
|
+
key_pem = leaf_key.private_bytes(
|
|
199
|
+
encoding=serialization.Encoding.PEM,
|
|
200
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
201
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
202
|
+
)
|
|
203
|
+
return cert_pem, key_pem
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Platform-specific CA trust helpers
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def trust_ca_system(cert_path: Path | None = None) -> bool:
|
|
211
|
+
"""Attempt to trust the CA certificate in the OS certificate store.
|
|
212
|
+
|
|
213
|
+
Returns True on success. May require elevated privileges.
|
|
214
|
+
"""
|
|
215
|
+
cert_path = cert_path or ca_cert_path()
|
|
216
|
+
system = platform.system()
|
|
217
|
+
try:
|
|
218
|
+
if system == "Darwin":
|
|
219
|
+
return _trust_ca_macos(cert_path)
|
|
220
|
+
if system == "Linux":
|
|
221
|
+
return _trust_ca_linux(cert_path)
|
|
222
|
+
if system == "Windows":
|
|
223
|
+
return _trust_ca_windows(cert_path)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.warning("CA trust failed: %s", exc)
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _trust_ca_macos(cert_path: Path) -> bool:
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
[
|
|
232
|
+
"security", "add-trusted-cert",
|
|
233
|
+
"-d", # user trust domain
|
|
234
|
+
"-r", "trustRoot",
|
|
235
|
+
"-k", str(Path.home() / "Library" / "Keychains" / "login.keychain-db"),
|
|
236
|
+
str(cert_path),
|
|
237
|
+
],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
timeout=30,
|
|
240
|
+
)
|
|
241
|
+
return result.returncode == 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _trust_ca_linux(cert_path: Path) -> bool:
|
|
245
|
+
dest = Path("/usr/local/share/ca-certificates/mtrx-cursor-proxy.crt")
|
|
246
|
+
subprocess.run(["sudo", "cp", str(cert_path), str(dest)], check=True, timeout=30)
|
|
247
|
+
result = subprocess.run(["sudo", "update-ca-certificates"], capture_output=True, timeout=30)
|
|
248
|
+
return result.returncode == 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _trust_ca_windows(cert_path: Path) -> bool:
|
|
252
|
+
result = subprocess.run(
|
|
253
|
+
["certutil", "-addstore", "Root", str(cert_path)],
|
|
254
|
+
capture_output=True,
|
|
255
|
+
timeout=30,
|
|
256
|
+
)
|
|
257
|
+
return result.returncode == 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_ca_trusted() -> bool:
|
|
261
|
+
"""Best-effort check whether the MTRX CA is trusted by the OS."""
|
|
262
|
+
if not ca_exists():
|
|
263
|
+
return False
|
|
264
|
+
system = platform.system()
|
|
265
|
+
try:
|
|
266
|
+
if system == "Darwin":
|
|
267
|
+
result = subprocess.run(
|
|
268
|
+
["security", "find-certificate", "-c", "MTRX Cursor Proxy CA"],
|
|
269
|
+
capture_output=True,
|
|
270
|
+
timeout=10,
|
|
271
|
+
)
|
|
272
|
+
return result.returncode == 0
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
return False
|