alyxruntime 0.1.0__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.
@@ -0,0 +1,6 @@
1
+ """alyxruntime - use another computer's CPU/GPU as your Jupyter runtime."""
2
+ from .client import AlyxError, AuthError, ExecResult, Runtime
3
+ from .server import serve
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Runtime", "ExecResult", "AlyxError", "AuthError", "serve", "__version__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
alyxruntime/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ import argparse
2
+ import getpass
3
+ import json
4
+ import sys
5
+
6
+ from . import __version__, hardware
7
+ from .client import AlyxError, AuthError, Runtime
8
+ from .config import clear_state, load_state, save_state
9
+
10
+
11
+ def die(msg, code=1):
12
+ print("alyxruntime: " + msg, file=sys.stderr)
13
+ sys.exit(code)
14
+
15
+
16
+ def _current() -> Runtime:
17
+ st = load_state()
18
+ if not st:
19
+ die("not connected. Run: alyxruntime connect <url>")
20
+ return Runtime.from_state(st)
21
+
22
+
23
+ def _persist(rt: Runtime):
24
+ save_state(rt.to_state())
25
+
26
+
27
+ # ---------------------------------------------------------------- commands
28
+ def cmd_connect(a):
29
+ rt = Runtime(a.url, token=a.token, insecure=a.insecure, kernel_name=a.kernel)
30
+ try:
31
+ wait = lambda: print("Waiting for the tunnel to come up ...", file=sys.stderr)
32
+ try:
33
+ rt.wait_until_reachable(on_wait=wait)
34
+ except AuthError:
35
+ if rt.token or not sys.stdin.isatty():
36
+ raise
37
+ rt.token = getpass.getpass("Access token: ")
38
+ rt.http.headers["Authorization"] = "token " + rt.token
39
+ rt.ping()
40
+ rt.ensure_kernel()
41
+ info = rt.info()
42
+ except AlyxError as e:
43
+ die(str(e))
44
+ _persist(rt) # save first, so a closed stdout can't lose the session
45
+ print("Connected to %s\n%s\n kernel: %s (%s)" % (rt.base, hardware.format_summary(info),
46
+ rt.kernel_id[:8], rt.kernel_name))
47
+ print("\nNext: alyxruntime run train.py | alyxruntime shell | alyxruntime info")
48
+ print("VS Code / JupyterLab: choose 'Existing Jupyter Server' and paste the same URL.")
49
+
50
+
51
+ def _cells(path):
52
+ if path.endswith(".ipynb"):
53
+ nb = json.load(open(path))
54
+ for c in nb.get("cells", []):
55
+ if c.get("cell_type") == "code":
56
+ yield "".join(c["source"])
57
+ else:
58
+ yield open(path).read()
59
+
60
+
61
+ def cmd_run(a):
62
+ rt = _current()
63
+ if a.code is not None:
64
+ sources = [a.code]
65
+ elif a.file == "-" or (a.file is None and not sys.stdin.isatty()):
66
+ sources = [sys.stdin.read()]
67
+ elif a.file:
68
+ sources = list(_cells(a.file))
69
+ else:
70
+ die("give a file, -c CODE, or pipe code on stdin")
71
+ try:
72
+ for src in sources:
73
+ res = rt.run(src, timeout=a.timeout)
74
+ for p in res.save_images():
75
+ print("[saved plot -> %s]" % p, file=sys.stderr)
76
+ if not res.ok:
77
+ _persist(rt)
78
+ sys.exit(1)
79
+ except KeyboardInterrupt:
80
+ die("interrupted (sent interrupt to remote kernel)", 130)
81
+ except AlyxError as e:
82
+ die(str(e))
83
+ _persist(rt)
84
+
85
+
86
+ def cmd_shell(a):
87
+ import codeop
88
+ rt = _current()
89
+ print("Remote shell on %s (Ctrl+D to leave)" % rt.base)
90
+ buf = []
91
+ while True:
92
+ try:
93
+ line = input("... " if buf else ">>> ")
94
+ except EOFError:
95
+ print()
96
+ break
97
+ except KeyboardInterrupt:
98
+ print("\nKeyboardInterrupt")
99
+ buf = []
100
+ continue
101
+ buf.append(line)
102
+ src = "\n".join(buf)
103
+ try:
104
+ if line.strip() and codeop.compile_command(src) is None:
105
+ continue # statement incomplete, keep reading
106
+ except (SyntaxError, ValueError, OverflowError):
107
+ pass # %magics, !shell etc: let the remote IPython deal with it
108
+ buf = []
109
+ if not src.strip():
110
+ continue
111
+ try:
112
+ for p in rt.run(src).save_images():
113
+ print("[saved plot -> %s]" % p)
114
+ except KeyboardInterrupt:
115
+ print("\nKeyboardInterrupt")
116
+ except AlyxError as e:
117
+ print("error: %s" % e, file=sys.stderr)
118
+ _persist(rt)
119
+
120
+
121
+ def cmd_info(a):
122
+ rt = _current()
123
+ try:
124
+ print(hardware.format_summary(rt.info()))
125
+ except AlyxError as e:
126
+ die(str(e))
127
+
128
+
129
+ def cmd_status(a):
130
+ st = load_state()
131
+ if not st:
132
+ print("Not connected.")
133
+ return
134
+ rt = Runtime.from_state(st)
135
+ try:
136
+ rt.ping()
137
+ print("Connected to %s\n kernel: %s" % (rt.base, (rt.kernel_id or "none")[:8]))
138
+ except AlyxError as e:
139
+ print("Saved session for %s but it is not reachable: %s" % (rt.base, e))
140
+ sys.exit(1)
141
+
142
+
143
+ def cmd_restart(a):
144
+ rt = _current()
145
+ try:
146
+ rt.restart()
147
+ except AlyxError as e:
148
+ die(str(e))
149
+ print("Kernel restarted (variables cleared).")
150
+
151
+
152
+ def cmd_upload(a):
153
+ rt = _current()
154
+ try:
155
+ print("uploaded -> " + rt.upload(a.local, a.remote))
156
+ except (AlyxError, OSError) as e:
157
+ die(str(e))
158
+
159
+
160
+ def cmd_download(a):
161
+ rt = _current()
162
+ try:
163
+ print("saved -> %s" % rt.download(a.remote, a.local))
164
+ except (AlyxError, OSError) as e:
165
+ die(str(e))
166
+
167
+
168
+ def cmd_disconnect(a):
169
+ st = load_state()
170
+ if st and a.kill_kernel:
171
+ rt = Runtime.from_state(st)
172
+ rt.shutdown()
173
+ clear_state()
174
+ print("Disconnected.")
175
+
176
+
177
+ def cmd_stream(a):
178
+ from .server import serve
179
+ try:
180
+ serve(allowed_ip=a.allowed_ip, host=a.host, port=a.port, token=a.token, root_dir=a.root_dir,
181
+ tls_cert=a.tls_cert, tls_key=a.tls_key, allow_any=a.allow_any, public_host=a.public_host,
182
+ tunnel=a.tunnel, tunnel_token=a.tunnel_token, public_url=a.public_url,
183
+ auto_install=a.install_cloudflared)
184
+ except ValueError as e:
185
+ die(str(e))
186
+
187
+
188
+ # ------------------------------------------------------------------ parser
189
+ def build_parser():
190
+ p = argparse.ArgumentParser(prog="alyxruntime",
191
+ description="Use another computer's CPU/GPU as your Jupyter runtime.")
192
+ p.add_argument("--version", action="version", version="alyxruntime " + __version__)
193
+ sub = p.add_subparsers(dest="cmd", metavar="<command>")
194
+ sub.required = True
195
+
196
+ c = sub.add_parser("connect", help="connect to a runtime shared with `stream`")
197
+ c.add_argument("url", help="e.g. https://host:8765/?token=XXXX")
198
+ c.add_argument("--token", help="access token (or put it in the URL / $ALYX_TOKEN)")
199
+ c.add_argument("--insecure", action="store_true", help="skip TLS verification (self-signed certs)")
200
+ c.add_argument("--kernel", default="python3", help="kernelspec name (default python3)")
201
+ c.set_defaults(fn=cmd_connect)
202
+
203
+ s = sub.add_parser("stream", help="share THIS machine's CPU/GPU as a runtime")
204
+ s.add_argument("--allowed_ip", "--allowed-ip", nargs="+", action="extend", metavar="IP",
205
+ help="client IP(s) or CIDR range(s) allowed to connect, e.g. 203.0.113.7 10.0.0.0/24; "
206
+ "or the word 'all' to allow any IP (token is then the only protection)")
207
+ s.add_argument("--allow-any", action="store_true", help="accept any IP (token is the only protection)")
208
+ s.add_argument("--no-tunnel", dest="tunnel", action="store_false",
209
+ help="don't use Cloudflare; listen directly on --host:--port (you handle NAT/TLS)")
210
+ s.add_argument("--tunnel-token", help="run a NAMED Cloudflare tunnel (stable hostname) with this token; "
211
+ "needs --public-url and --port")
212
+ s.add_argument("--public-url", help="the https URL of your named tunnel, e.g. https://gpu.example.com")
213
+ s.add_argument("--install-cloudflared", action="store_true",
214
+ help="download cloudflared automatically if it's missing (no prompt)")
215
+ s.add_argument("--port", type=int, help="local port (default: random with tunnel, 8765 with --no-tunnel)")
216
+ s.add_argument("--host", default="0.0.0.0", help="interface to bind with --no-tunnel (default all)")
217
+ s.add_argument("--token", help="use a fixed token instead of a random one")
218
+ s.add_argument("--root-dir", help="working directory exposed to the client (default: cwd)")
219
+ s.add_argument("--tls-cert", help="PEM certificate to serve HTTPS")
220
+ s.add_argument("--tls-key", help="PEM private key for --tls-cert")
221
+ s.add_argument("--public-host", help="hostname/IP to print in the connect URL (with --no-tunnel)")
222
+ s.set_defaults(fn=cmd_stream)
223
+
224
+ r = sub.add_parser("run", help="run a .py/.ipynb file or code on the remote runtime")
225
+ r.add_argument("file", nargs="?", help="script, notebook, or - for stdin")
226
+ r.add_argument("-c", "--code", help="code string to run")
227
+ r.add_argument("--timeout", type=float, help="seconds before interrupting")
228
+ r.set_defaults(fn=cmd_run)
229
+
230
+ for name, fn, h in (("shell", cmd_shell, "interactive Python prompt on the remote runtime"),
231
+ ("info", cmd_info, "show remote CPU/RAM/GPU"),
232
+ ("status", cmd_status, "show current connection"),
233
+ ("restart", cmd_restart, "restart the remote kernel")):
234
+ sub.add_parser(name, help=h).set_defaults(fn=fn)
235
+
236
+ u = sub.add_parser("upload", help="copy a local file to the host")
237
+ u.add_argument("local"); u.add_argument("remote", nargs="?")
238
+ u.set_defaults(fn=cmd_upload)
239
+ d = sub.add_parser("download", help="copy a file from the host")
240
+ d.add_argument("remote"); d.add_argument("local", nargs="?")
241
+ d.set_defaults(fn=cmd_download)
242
+
243
+ x = sub.add_parser("disconnect", help="forget the saved connection")
244
+ x.add_argument("--kill-kernel", action="store_true", help="also shut down the remote kernel")
245
+ x.set_defaults(fn=cmd_disconnect)
246
+ return p
247
+
248
+
249
+ def main(argv=None):
250
+ a = build_parser().parse_args(argv)
251
+ a.fn(a)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()
alyxruntime/client.py ADDED
@@ -0,0 +1,294 @@
1
+ """Client side: talk to a remote Jupyter runtime exposed by `alyxruntime stream`."""
2
+ import base64
3
+ import json
4
+ import sys
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Callable, List, Optional
11
+ from urllib.parse import parse_qs, urlsplit
12
+
13
+ import requests
14
+ import websocket
15
+
16
+ from . import hardware
17
+
18
+
19
+ class AlyxError(Exception):
20
+ pass
21
+
22
+
23
+ class AuthError(AlyxError):
24
+ pass
25
+
26
+
27
+ class UnreachableError(AlyxError):
28
+ """Host not reachable *yet* (fresh tunnel DNS / edge not registered). Worth retrying."""
29
+
30
+
31
+ @dataclass
32
+ class ExecResult:
33
+ status: str = "ok"
34
+ execution_count: Optional[int] = None
35
+ stdout: str = ""
36
+ stderr: str = ""
37
+ results: List[str] = field(default_factory=list)
38
+ images: List[bytes] = field(default_factory=list)
39
+ error: Optional[dict] = None
40
+
41
+ @property
42
+ def ok(self) -> bool:
43
+ return self.status == "ok"
44
+
45
+ def save_images(self, directory="alyx_outputs") -> List[Path]:
46
+ d = Path(directory)
47
+ paths = []
48
+ for i, data in enumerate(self.images):
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ p = d / ("out_%d_%d.png" % (int(time.time()), i))
51
+ p.write_bytes(data)
52
+ paths.append(p)
53
+ return paths
54
+
55
+
56
+ def split_url(url: str):
57
+ """'https://host:8765/?token=abc' -> ('https://host:8765', 'abc')"""
58
+ if "://" not in url:
59
+ url = "http://" + url
60
+ parts = urlsplit(url)
61
+ token = (parse_qs(parts.query).get("token") or [None])[0]
62
+ base = "%s://%s%s" % (parts.scheme, parts.netloc, parts.path.rstrip("/"))
63
+ # a trailing /lab or /tree from a copied browser URL is not part of the base
64
+ for suffix in ("/lab", "/tree", "/api"):
65
+ if base.endswith(suffix):
66
+ base = base[: -len(suffix)]
67
+ return base, token
68
+
69
+
70
+ def _echo(stream, text):
71
+ out = sys.stderr if stream in ("stderr", "error") else sys.stdout
72
+ out.write(text)
73
+ out.flush()
74
+
75
+
76
+ class Runtime:
77
+ """A connection to a remote runtime (a Jupyter server on another machine).
78
+
79
+ >>> rt = Runtime.connect("http://203.0.113.7:8765/?token=...")
80
+ >>> rt.run("import torch; print(torch.cuda.is_available())")
81
+ """
82
+
83
+ def __init__(self, url, token=None, insecure=False, kernel_name="python3",
84
+ kernel_id=None, timeout=30):
85
+ import os
86
+ self.base, url_token = split_url(url)
87
+ self.token = token or url_token or os.environ.get("ALYX_TOKEN")
88
+ self.insecure = insecure
89
+ self.kernel_name = kernel_name
90
+ self.kernel_id = kernel_id
91
+ self.timeout = timeout
92
+ self.http = requests.Session()
93
+ self.http.verify = not insecure
94
+ if self.token:
95
+ self.http.headers["Authorization"] = "token " + self.token
96
+ if insecure:
97
+ import urllib3
98
+ urllib3.disable_warnings()
99
+
100
+ # ---- construction -------------------------------------------------
101
+ @classmethod
102
+ def connect(cls, url, token=None, insecure=False, kernel_name="python3"):
103
+ rt = cls(url, token=token, insecure=insecure, kernel_name=kernel_name)
104
+ rt.ping()
105
+ rt.ensure_kernel()
106
+ return rt
107
+
108
+ @classmethod
109
+ def from_state(cls, state: dict):
110
+ return cls(state["url"], token=state.get("token"), insecure=state.get("insecure", False),
111
+ kernel_name=state.get("kernel_name", "python3"), kernel_id=state.get("kernel_id"))
112
+
113
+ def to_state(self) -> dict:
114
+ return {"url": self.base, "token": self.token, "insecure": self.insecure,
115
+ "kernel_name": self.kernel_name, "kernel_id": self.kernel_id}
116
+
117
+ # ---- REST ---------------------------------------------------------
118
+ def _api(self, method, path, **kw):
119
+ kw.setdefault("timeout", self.timeout)
120
+ kw.setdefault("allow_redirects", False)
121
+ try:
122
+ r = self.http.request(method, self.base + path, **kw)
123
+ except requests.exceptions.SSLError as e:
124
+ raise AlyxError("TLS verification failed (%s). For a self-signed host use --insecure." % e)
125
+ except requests.exceptions.ConnectionError as e:
126
+ low = str(e).lower()
127
+ if any(m in low for m in ("name or service not known", "nodename nor servname",
128
+ "getaddrinfo", "failed to resolve", "name resolution")):
129
+ raise UnreachableError("Cannot resolve %s (a brand-new tunnel can take a few seconds)." % self.base)
130
+ hint = (" The host closed the connection - your public IP is probably not in its --allowed_ip list."
131
+ if "reset" in str(e).lower() or "aborted" in str(e).lower() else "")
132
+ raise AlyxError("Cannot reach %s.%s" % (self.base, hint))
133
+ except requests.exceptions.RequestException as e:
134
+ raise AlyxError("Cannot reach %s: %s" % (self.base, e))
135
+ if r.status_code in (301, 302, 401, 403):
136
+ if r.text.startswith("alyxruntime:"): # the host's own explanation (names the IP to allow)
137
+ raise AuthError(r.text.strip()[len("alyxruntime:"):].strip())
138
+ raise AuthError("Access denied (HTTP %d). Check the token, and that your IP is in the host's --allowed_ip list."
139
+ % r.status_code)
140
+ if r.status_code in (502, 503, 504, 530):
141
+ raise UnreachableError("Tunnel/host not ready (HTTP %d). Is `alyxruntime stream` still running?"
142
+ % r.status_code)
143
+ if r.status_code >= 400:
144
+ raise AlyxError("%s %s -> HTTP %d: %s" % (method, path, r.status_code, r.text[:200]))
145
+ return r
146
+
147
+ def ping(self):
148
+ return self._api("GET", "/api/status").json()
149
+
150
+ def wait_until_reachable(self, timeout: float = 45, on_wait=None):
151
+ """ping(), retrying while a fresh Cloudflare tunnel finishes coming up."""
152
+ end = time.time() + timeout
153
+ told = False
154
+ while True:
155
+ try:
156
+ return self.ping()
157
+ except UnreachableError:
158
+ if time.time() > end:
159
+ raise
160
+ if on_wait and not told:
161
+ on_wait()
162
+ told = True
163
+ time.sleep(2)
164
+
165
+ def ensure_kernel(self) -> str:
166
+ if self.kernel_id:
167
+ try:
168
+ self._api("GET", "/api/kernels/" + self.kernel_id)
169
+ return self.kernel_id
170
+ except AuthError:
171
+ raise
172
+ except AlyxError:
173
+ self.kernel_id = None
174
+ self.kernel_id = self._api("POST", "/api/kernels", json={"name": self.kernel_name}).json()["id"]
175
+ return self.kernel_id
176
+
177
+ def restart(self):
178
+ self._api("POST", "/api/kernels/%s/restart" % self.ensure_kernel())
179
+
180
+ def interrupt(self):
181
+ if self.kernel_id:
182
+ self._api("POST", "/api/kernels/%s/interrupt" % self.kernel_id)
183
+
184
+ def shutdown(self):
185
+ if self.kernel_id:
186
+ try:
187
+ self._api("DELETE", "/api/kernels/" + self.kernel_id)
188
+ except AlyxError:
189
+ pass
190
+ self.kernel_id = None
191
+
192
+ # ---- code execution ----------------------------------------------
193
+ def _open_ws(self, kernel_id):
194
+ ws_base = ("wss" if self.base.startswith("https") else "ws") + self.base[self.base.index("://"):]
195
+ url = "%s/api/kernels/%s/channels" % (ws_base, kernel_id)
196
+ if self.token:
197
+ url += "?token=" + self.token
198
+ opts = {"cert_reqs": 0, "check_hostname": False} if self.insecure else {}
199
+ try:
200
+ return websocket.create_connection(
201
+ url, header=["Authorization: token %s" % self.token] if self.token else [],
202
+ sslopt=opts, timeout=self.timeout)
203
+ except websocket.WebSocketBadStatusException as e:
204
+ raise AuthError("WebSocket rejected: %s" % e)
205
+ except Exception as e:
206
+ raise AlyxError("WebSocket connection failed: %s" % e)
207
+
208
+ def run(self, code: str, echo: bool = True, timeout: Optional[float] = None,
209
+ on_output: Optional[Callable[[str, str], None]] = None) -> ExecResult:
210
+ """Execute `code` on the remote kernel (state persists between calls)."""
211
+ emit = on_output or (_echo if echo else (lambda s, t: None))
212
+ ws = self._open_ws(self.ensure_kernel())
213
+ ws.settimeout(1.0)
214
+ msg_id = uuid.uuid4().hex
215
+ ws.send(json.dumps({
216
+ "header": {"msg_id": msg_id, "username": "alyx", "session": uuid.uuid4().hex,
217
+ "msg_type": "execute_request", "version": "5.3",
218
+ "date": datetime.now(timezone.utc).isoformat()},
219
+ "parent_header": {}, "metadata": {}, "buffers": [], "channel": "shell",
220
+ "content": {"code": code, "silent": False, "store_history": True,
221
+ "user_expressions": {}, "allow_stdin": False, "stop_on_error": True},
222
+ }))
223
+ res = ExecResult()
224
+ deadline = time.time() + timeout if timeout else None
225
+ idle = replied = False
226
+ try:
227
+ while not (idle and replied):
228
+ try:
229
+ raw = ws.recv()
230
+ except websocket.WebSocketTimeoutException:
231
+ if deadline and time.time() > deadline:
232
+ self.interrupt()
233
+ raise AlyxError("Execution timed out after %ss" % timeout)
234
+ continue
235
+ except websocket.WebSocketConnectionClosedException:
236
+ raise AlyxError("Connection to the runtime was lost.")
237
+ if not raw:
238
+ raise AlyxError("Connection to the runtime was lost.")
239
+ msg = json.loads(raw)
240
+ if (msg.get("parent_header") or {}).get("msg_id") != msg_id:
241
+ continue
242
+ kind = msg.get("msg_type") or msg["header"]["msg_type"]
243
+ c = msg["content"]
244
+ if kind == "stream":
245
+ if c["name"] == "stderr":
246
+ res.stderr += c["text"]
247
+ else:
248
+ res.stdout += c["text"]
249
+ emit(c["name"], c["text"])
250
+ elif kind in ("execute_result", "display_data"):
251
+ data = c.get("data", {})
252
+ if "image/png" in data:
253
+ res.images.append(base64.b64decode(data["image/png"]))
254
+ if "text/plain" in data:
255
+ res.results.append(data["text/plain"])
256
+ emit("result", data["text/plain"] + "\n")
257
+ elif kind == "error":
258
+ res.error = {"ename": c["ename"], "evalue": c["evalue"], "traceback": c["traceback"]}
259
+ emit("error", "\n".join(c["traceback"]) + "\n")
260
+ elif kind == "status" and c.get("execution_state") == "idle":
261
+ idle = True
262
+ elif kind == "execute_reply":
263
+ replied = True
264
+ res.status = c.get("status", "ok")
265
+ res.execution_count = c.get("execution_count")
266
+ except KeyboardInterrupt:
267
+ self.interrupt()
268
+ raise
269
+ finally:
270
+ ws.close()
271
+ return res
272
+
273
+ def info(self) -> dict:
274
+ r = self.run(hardware.remote_snippet(), echo=False)
275
+ if not r.ok:
276
+ raise AlyxError("Could not read hardware info: %s" % (r.error or {}).get("evalue"))
277
+ return json.loads(r.stdout.strip().splitlines()[-1])
278
+
279
+ # ---- files --------------------------------------------------------
280
+ def upload(self, local, remote=None):
281
+ local = Path(local)
282
+ remote = (remote or local.name).lstrip("/")
283
+ body = {"type": "file", "format": "base64",
284
+ "content": base64.b64encode(local.read_bytes()).decode()}
285
+ self._api("PUT", "/api/contents/" + remote, json=body, timeout=600)
286
+ return remote
287
+
288
+ def download(self, remote, local=None):
289
+ remote = remote.lstrip("/")
290
+ r = self._api("GET", "/api/contents/" + remote,
291
+ params={"content": 1, "format": "base64", "type": "file"}, timeout=600).json()
292
+ local = Path(local or Path(remote).name)
293
+ local.write_bytes(base64.b64decode(r["content"]))
294
+ return local
alyxruntime/config.py ADDED
@@ -0,0 +1,34 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ def home() -> Path:
7
+ p = Path(os.environ.get("ALYX_HOME", Path.home() / ".alyxruntime"))
8
+ p.mkdir(parents=True, exist_ok=True)
9
+ return p
10
+
11
+
12
+ def _state_path() -> Path:
13
+ return home() / "session.json"
14
+
15
+
16
+ def load_state():
17
+ try:
18
+ return json.loads(_state_path().read_text())
19
+ except (OSError, ValueError):
20
+ return None
21
+
22
+
23
+ def save_state(state: dict) -> None:
24
+ # holds the access token, so make it owner-readable only
25
+ fd = os.open(_state_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
26
+ with os.fdopen(fd, "w") as f:
27
+ json.dump(state, f, indent=2)
28
+
29
+
30
+ def clear_state() -> None:
31
+ try:
32
+ _state_path().unlink()
33
+ except OSError:
34
+ pass
@@ -0,0 +1,58 @@
1
+ # NOTE: this file is also sent as source to the remote kernel, so keep it
2
+ # self-contained and compatible with old Pythons (no walrus, no f-string "=").
3
+ import json
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+
9
+
10
+ def summary():
11
+ info = {
12
+ "hostname": platform.node(),
13
+ "os": platform.platform(),
14
+ "python": platform.python_version(),
15
+ "cpu_count": os.cpu_count(),
16
+ "ram_gb": None,
17
+ "gpus": [],
18
+ }
19
+ try:
20
+ with open("/proc/meminfo") as f:
21
+ for line in f:
22
+ if line.startswith("MemTotal"):
23
+ info["ram_gb"] = round(int(line.split()[1]) / 1024 / 1024, 1)
24
+ break
25
+ except OSError:
26
+ try:
27
+ import psutil
28
+
29
+ info["ram_gb"] = round(psutil.virtual_memory().total / 1024 ** 3, 1)
30
+ except Exception:
31
+ pass
32
+ exe = shutil.which("nvidia-smi")
33
+ if exe:
34
+ try:
35
+ out = subprocess.run(
36
+ [exe, "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader"],
37
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True, timeout=10,
38
+ ).stdout
39
+ for line in out.strip().splitlines():
40
+ name, mem, drv = [p.strip() for p in line.split(",")]
41
+ info["gpus"].append({"name": name, "memory": mem, "driver": drv})
42
+ except Exception:
43
+ pass
44
+ return info
45
+
46
+
47
+ def format_summary(info):
48
+ gpus = info.get("gpus") or []
49
+ gpu_txt = ", ".join("%s (%s)" % (g["name"], g["memory"]) for g in gpus) or "none detected"
50
+ return " host: %s\n cpu: %s cores\n ram: %s GB\n gpu: %s\n python: %s" % (
51
+ info.get("hostname"), info.get("cpu_count"), info.get("ram_gb"), gpu_txt, info.get("python"))
52
+
53
+
54
+ def remote_snippet():
55
+ """Python source that prints summary() as JSON when run inside a kernel."""
56
+ with open(__file__, "r") as f:
57
+ src = f.read()
58
+ return src + "\nprint(json.dumps(summary()))\n"
alyxruntime/server.py ADDED
@@ -0,0 +1,344 @@
1
+ """Host side: share this machine's CPU/GPU as a Jupyter runtime.
2
+
3
+ Architecture: client --TCP--> [IP-allowlist proxy :8765] --> [Jupyter server 127.0.0.1:random]
4
+
5
+ The Jupyter server only ever listens on loopback. The proxy is the only public
6
+ socket: it drops any connection whose source IP is not allowed *before* a single
7
+ byte reaches Jupyter, and Jupyter then still demands the access token.
8
+ """
9
+ import asyncio
10
+ import importlib.util
11
+ import ipaddress
12
+ import os
13
+ import secrets
14
+ import signal
15
+ import socket
16
+ import ssl
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Iterable, List, Optional
22
+
23
+ import requests
24
+
25
+ from . import hardware
26
+ from .config import home
27
+ from .tunnel import INSTALL_HELP, Tunnel, TunnelError, find_cloudflared, install_cloudflared
28
+
29
+
30
+ ALL_WORDS = {"all", "any", "*"}
31
+
32
+
33
+ def wants_all(values: Optional[Iterable[str]]) -> bool:
34
+ """True if --allowed_ip contains the keyword all / any / * (= allow every IP)."""
35
+ for raw in values or []:
36
+ for item in str(raw).replace(",", " ").split():
37
+ if item.strip("{}'\"").lower() in ALL_WORDS:
38
+ return True
39
+ return False
40
+
41
+
42
+ def parse_allowed(values: Optional[Iterable[str]]) -> list:
43
+ nets = []
44
+ for raw in values or []:
45
+ for item in str(raw).replace(",", " ").split():
46
+ item = item.strip("{}'\"")
47
+ if not item or item.lower() in ALL_WORDS:
48
+ continue
49
+ try:
50
+ nets.append(ipaddress.ip_network(item, strict=False))
51
+ except ValueError:
52
+ raise ValueError("%r is not a valid IP address or CIDR range "
53
+ "(examples: 203.0.113.7 or 10.0.0.0/24)" % item)
54
+ return nets
55
+
56
+
57
+ def is_allowed(addr: str, nets: list, allow_any: bool = False, loopback_ok: bool = True) -> bool:
58
+ try:
59
+ ip = ipaddress.ip_address(addr.split("%")[0])
60
+ except ValueError:
61
+ return False
62
+ if ip.version == 6 and ip.ipv4_mapped:
63
+ ip = ip.ipv4_mapped
64
+ if allow_any:
65
+ return True
66
+ if loopback_ok and ip.is_loopback: # the machine's own user (direct mode only)
67
+ return True
68
+ return any(ip in n for n in nets if n.version == ip.version)
69
+
70
+
71
+ def inspect_head(head: bytes):
72
+ """Inspect one HTTP request head arriving through cloudflared.
73
+
74
+ Returns (client_ip_or_None, rewritten_head). The client IP comes from the
75
+ CF-Connecting-IP header, which Cloudflare's edge sets itself (clients can't forge it).
76
+ The head is rewritten to `Connection: close` (except WebSocket upgrades) so that every
77
+ TCP connection carries exactly ONE request - otherwise cloudflared could reuse a
78
+ connection for a different visitor after we've only vetted the first one.
79
+ """
80
+ lines = [l for l in head.split(b"\r\n") if l]
81
+ out, ips, upgrade = [lines[0]], [], False
82
+ kept = []
83
+ for ln in lines[1:]:
84
+ name, _, value = ln.partition(b":")
85
+ n = name.strip().lower()
86
+ if n == b"cf-connecting-ip":
87
+ ips.append(value.strip().decode("ascii", "ignore"))
88
+ elif n == b"upgrade" and b"websocket" in value.lower():
89
+ upgrade = True
90
+ kept.append((n, ln))
91
+ for n, ln in kept:
92
+ if n == b"connection" and not upgrade:
93
+ continue
94
+ out.append(ln)
95
+ if not upgrade:
96
+ out.append(b"Connection: close")
97
+ ip = ips[0] if len(ips) == 1 else None # missing or ambiguous -> deny
98
+ return ip, b"\r\n".join(out) + b"\r\n\r\n"
99
+
100
+
101
+ def _free_port() -> int:
102
+ with socket.socket() as s:
103
+ s.bind(("127.0.0.1", 0))
104
+ return s.getsockname()[1]
105
+
106
+
107
+ def _guess_ip() -> str:
108
+ try:
109
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
110
+ s.connect(("8.8.8.8", 80))
111
+ return s.getsockname()[0]
112
+ except OSError:
113
+ return socket.gethostname()
114
+
115
+
116
+ class AllowlistProxy:
117
+ """Public-facing gate in front of Jupyter.
118
+
119
+ direct mode (trust_cf_header=False): raw TCP proxy, filters on the socket's peer IP.
120
+ tunnel mode (trust_cf_header=True): listens on loopback for cloudflared and filters on
121
+ the CF-Connecting-IP header of every request.
122
+ """
123
+
124
+ def __init__(self, host, port, target_port, nets, allow_any=False, ssl_ctx=None,
125
+ trust_cf_header=False):
126
+ self.host, self.port, self.target_port = host, port, target_port
127
+ self.nets, self.allow_any, self.ssl_ctx = nets, allow_any, ssl_ctx
128
+ self.cf = trust_cf_header
129
+ self.seen = set()
130
+
131
+ @staticmethod
132
+ def _log(msg):
133
+ print("[%s] %s" % (time.strftime("%H:%M:%S"), msg), flush=True)
134
+
135
+ async def _pipe(self, reader, writer):
136
+ try:
137
+ while True:
138
+ data = await reader.read(65536)
139
+ if not data:
140
+ break
141
+ writer.write(data)
142
+ await writer.drain()
143
+ except (ConnectionError, ssl.SSLError, asyncio.CancelledError, OSError):
144
+ pass
145
+ finally:
146
+ try:
147
+ writer.close()
148
+ except Exception:
149
+ pass
150
+
151
+ async def _deny(self, writer, who, reason):
152
+ self._log("DENIED %s (%s)" % (who or "unknown", reason))
153
+ if self.cf:
154
+ body = ("alyxruntime: access denied for IP %s. Ask the host to add it with "
155
+ "--allowed_ip %s\n" % (who, who)).encode() if who else \
156
+ b"alyxruntime: request did not come through the Cloudflare tunnel.\n"
157
+ try:
158
+ writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n"
159
+ b"Content-Length: %d\r\n\r\n" % len(body) + body)
160
+ await writer.drain()
161
+ except Exception:
162
+ pass
163
+ writer.close()
164
+
165
+ async def _handle(self, creader, cwriter):
166
+ head = None
167
+ if self.cf:
168
+ try:
169
+ raw = await asyncio.wait_for(creader.readuntil(b"\r\n\r\n"), 15)
170
+ except Exception:
171
+ cwriter.close()
172
+ return
173
+ who, head = inspect_head(raw)
174
+ if not who:
175
+ return await self._deny(cwriter, None, "no single CF-Connecting-IP header")
176
+ ok = is_allowed(who, self.nets, self.allow_any, loopback_ok=False)
177
+ else:
178
+ who = (cwriter.get_extra_info("peername") or ("?",))[0]
179
+ ok = is_allowed(who, self.nets, self.allow_any)
180
+ if not ok:
181
+ return await self._deny(cwriter, who, "not in --allowed_ip")
182
+ if who not in self.seen:
183
+ self.seen.add(who)
184
+ self._log("accepted %s" % who)
185
+ try:
186
+ ureader, uwriter = await asyncio.open_connection("127.0.0.1", self.target_port)
187
+ if head:
188
+ uwriter.write(head)
189
+ await uwriter.drain()
190
+ except OSError:
191
+ cwriter.close()
192
+ return
193
+ await asyncio.gather(self._pipe(creader, uwriter), self._pipe(ureader, cwriter))
194
+
195
+ async def start(self):
196
+ return await asyncio.start_server(self._handle, self.host, self.port, ssl=self.ssl_ctx)
197
+
198
+
199
+ def _wait_ready(port, token, proc, timeout=90) -> bool:
200
+ s = requests.Session()
201
+ s.trust_env = False
202
+ end = time.time() + timeout
203
+ while time.time() < end:
204
+ if proc.poll() is not None:
205
+ return False
206
+ try:
207
+ if s.get("http://127.0.0.1:%d/api/status" % port,
208
+ headers={"Authorization": "token " + token}, timeout=2).status_code == 200:
209
+ return True
210
+ except requests.RequestException:
211
+ pass
212
+ time.sleep(0.5)
213
+ return False
214
+
215
+
216
+ def _ensure_cloudflared(auto_install: bool) -> str:
217
+ exe = find_cloudflared()
218
+ if exe:
219
+ return exe
220
+ want = auto_install
221
+ if not want and sys.stdin.isatty():
222
+ ans = input("cloudflared not found. Download the official binary from Cloudflare's GitHub "
223
+ "releases into ~/.alyxruntime/bin? [Y/n] ")
224
+ want = ans.strip().lower() in ("", "y", "yes")
225
+ if not want:
226
+ raise SystemExit(INSTALL_HELP)
227
+ try:
228
+ return install_cloudflared()
229
+ except TunnelError as e:
230
+ raise SystemExit(str(e))
231
+
232
+
233
+ def serve(allowed_ip: Optional[List[str]] = None, host: str = "0.0.0.0", port: Optional[int] = None,
234
+ token: Optional[str] = None, root_dir: Optional[str] = None,
235
+ tls_cert: Optional[str] = None, tls_key: Optional[str] = None,
236
+ allow_any: bool = False, public_host: Optional[str] = None,
237
+ tunnel: bool = True, tunnel_token: Optional[str] = None,
238
+ public_url: Optional[str] = None, auto_install: bool = False) -> None:
239
+ """Start sharing this machine. Blocks until Ctrl+C.
240
+
241
+ tunnel=True (default): expose it through a Cloudflare tunnel - no open ports, HTTPS included.
242
+ tunnel=False: listen directly on host:port (you handle NAT / TLS).
243
+ """
244
+ missing = [m for m in ("jupyter_server", "ipykernel") if importlib.util.find_spec(m) is None]
245
+ if missing:
246
+ raise SystemExit("Missing %s on this machine. Install with: pip install 'alyxruntime[host]'"
247
+ % ", ".join(missing))
248
+ nets = parse_allowed(allowed_ip)
249
+ allow_any = allow_any or wants_all(allowed_ip) # `--allowed_ip all`
250
+ token = token or secrets.token_urlsafe(24)
251
+ root = str(Path(root_dir or os.getcwd()).resolve())
252
+
253
+ exe = None
254
+ if tunnel:
255
+ if tls_cert or tls_key:
256
+ raise SystemExit("--tls-cert/--tls-key are not needed with a tunnel (Cloudflare terminates "
257
+ "HTTPS). Use --no-tunnel to serve TLS yourself.")
258
+ if not nets and not allow_any:
259
+ raise SystemExit("Through a tunnel, nobody counts as 'local', so say who may connect:\n"
260
+ " --allowed_ip <client public IP> [more IPs / CIDRs]\n"
261
+ "or --allow-any to rely on the token alone.")
262
+ if tunnel_token and (not public_url or not port):
263
+ raise SystemExit("A named tunnel needs --public-url https://your.hostname and --port <N>, "
264
+ "where N matches the service URL (http://localhost:N) set for it in the "
265
+ "Cloudflare dashboard.")
266
+ exe = _ensure_cloudflared(auto_install)
267
+ host, port = "127.0.0.1", port or _free_port() # only cloudflared may reach the gate
268
+ else:
269
+ port = port or 8765
270
+
271
+ ssl_ctx = None
272
+ if tls_cert:
273
+ ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
274
+ ssl_ctx.load_cert_chain(tls_cert, tls_key)
275
+
276
+ internal = _free_port()
277
+ env = dict(os.environ, JUPYTER_TOKEN=token) # env, not argv, so it's not visible in `ps`
278
+ logf = open(home() / "jupyter.log", "w")
279
+ cmd = [sys.executable, "-m", "jupyter_server", "--no-browser",
280
+ "--ServerApp.ip=127.0.0.1", "--ServerApp.port=%d" % internal, "--ServerApp.port_retries=0",
281
+ "--ServerApp.allow_remote_access=True", "--ServerApp.root_dir=" + root]
282
+ if hasattr(os, "geteuid") and os.geteuid() == 0:
283
+ cmd.append("--allow-root") # common on Docker / cloud GPU boxes
284
+ print("! Running as root: connected clients get root-level code execution.", flush=True)
285
+ proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT, env=env, cwd=root)
286
+ tun = Tunnel(exe, port, tunnel_token, public_url) if tunnel else None
287
+
288
+ def _stop(*_):
289
+ raise KeyboardInterrupt
290
+
291
+ try:
292
+ signal.signal(signal.SIGTERM, _stop)
293
+ except (ValueError, AttributeError):
294
+ pass
295
+
296
+ async def _amain():
297
+ proxy = AllowlistProxy(host, port, internal, nets, allow_any, ssl_ctx, trust_cf_header=tunnel)
298
+ server = await proxy.start()
299
+ if tunnel:
300
+ print("Opening Cloudflare tunnel ...", flush=True)
301
+ base = await asyncio.get_running_loop().run_in_executor(None, tun.start)
302
+ else:
303
+ base = "%s://%s:%d" % ("https" if ssl_ctx else "http", public_host or _guess_ip(), port)
304
+ print("\nRuntime is up. This machine:")
305
+ print(hardware.format_summary(hardware.summary()))
306
+ print("\nWorking dir : " + root)
307
+ if allow_any:
308
+ print("Allowed IPs : ANY (token is the only protection!)")
309
+ elif tunnel:
310
+ print("Allowed IPs : %s (as seen by Cloudflare)" % ", ".join(str(n) for n in nets))
311
+ elif nets:
312
+ print("Allowed IPs : %s (+ loopback)" % ", ".join(str(n) for n in nets))
313
+ else:
314
+ print("Allowed IPs : loopback only (pass --allowed_ip <your client IP> to let others in)")
315
+ if tunnel:
316
+ print("Transport : Cloudflare tunnel (HTTPS, no open ports)")
317
+ elif not ssl_ctx and (nets or allow_any):
318
+ print("\n! Plain HTTP: the token and your code cross the network unencrypted.\n"
319
+ " Use --tls-cert/--tls-key, drop --no-tunnel, or tunnel it yourself.")
320
+ print("\nOn the client, run:\n\n alyxruntime connect '%s/?token=%s'\n" % (base, token))
321
+ print("Ctrl+C to stop sharing.\n", flush=True)
322
+ async with server:
323
+ await server.serve_forever()
324
+
325
+ try:
326
+ print("Starting Jupyter runtime ...", flush=True)
327
+ if not _wait_ready(internal, token, proc):
328
+ tail = (home() / "jupyter.log").read_text()[-1500:]
329
+ raise SystemExit("Jupyter failed to start. Log (%s):\n%s" % (home() / "jupyter.log", tail))
330
+ asyncio.run(_amain())
331
+ except TunnelError as e:
332
+ print("\nTunnel error: %s" % e, file=sys.stderr)
333
+ except KeyboardInterrupt:
334
+ pass
335
+ finally:
336
+ print("\nShutting down ...")
337
+ if tun:
338
+ tun.stop()
339
+ proc.terminate()
340
+ try:
341
+ proc.wait(10)
342
+ except subprocess.TimeoutExpired:
343
+ proc.kill()
344
+ logf.close()
alyxruntime/tunnel.py ADDED
@@ -0,0 +1,152 @@
1
+ """Cloudflare tunnel (cloudflared) management: find / install / run."""
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import tarfile
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import requests
15
+
16
+ from .config import home
17
+
18
+ QUICK_URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
19
+ REGISTERED_RE = re.compile(r"Registered tunnel connection", re.I)
20
+ RELEASES = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
21
+
22
+ INSTALL_HELP = (
23
+ "cloudflared not found. Install it, or let alyxruntime fetch it:\n"
24
+ " macOS: brew install cloudflared\n"
25
+ " Debian: https://pkg.cloudflare.com/ (apt)\n"
26
+ " Windows: winget install Cloudflare.cloudflared\n"
27
+ " or re-run with --install-cloudflared to download the official binary into ~/.alyxruntime/bin"
28
+ )
29
+
30
+
31
+ class TunnelError(Exception):
32
+ pass
33
+
34
+
35
+ def _exe_name() -> str:
36
+ return "cloudflared.exe" if platform.system() == "Windows" else "cloudflared"
37
+
38
+
39
+ def find_cloudflared() -> Optional[str]:
40
+ env = os.environ.get("ALYX_CLOUDFLARED")
41
+ if env and Path(env).exists():
42
+ return env
43
+ found = shutil.which("cloudflared")
44
+ if found:
45
+ return found
46
+ local = home() / "bin" / _exe_name()
47
+ return str(local) if local.exists() else None
48
+
49
+
50
+ def _asset() -> str:
51
+ system = platform.system().lower()
52
+ machine = platform.machine().lower()
53
+ arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64",
54
+ "armv7l": "arm", "armv6l": "arm", "i386": "386", "i686": "386"}.get(machine)
55
+ if not arch:
56
+ raise TunnelError("No official cloudflared build for CPU %r - install it manually." % machine)
57
+ if system == "linux":
58
+ return "cloudflared-linux-" + arch
59
+ if system == "darwin" and arch in ("amd64", "arm64"):
60
+ return "cloudflared-darwin-%s.tgz" % arch
61
+ if system == "windows" and arch in ("amd64", "386"):
62
+ return "cloudflared-windows-%s.exe" % arch
63
+ raise TunnelError("No official cloudflared build for %s/%s - install it manually." % (system, machine))
64
+
65
+
66
+ def install_cloudflared() -> str:
67
+ """Download the official cloudflared release from GitHub into ~/.alyxruntime/bin."""
68
+ asset = _asset()
69
+ dest_dir = home() / "bin"
70
+ dest_dir.mkdir(parents=True, exist_ok=True)
71
+ dest = dest_dir / _exe_name()
72
+ url = RELEASES + asset
73
+ print("Downloading %s ..." % url, flush=True)
74
+ try:
75
+ with requests.get(url, stream=True, timeout=60) as r:
76
+ r.raise_for_status()
77
+ with tempfile.NamedTemporaryFile(delete=False, dir=str(dest_dir)) as tmp:
78
+ for chunk in r.iter_content(1 << 20):
79
+ tmp.write(chunk)
80
+ tmp_path = Path(tmp.name)
81
+ except requests.RequestException as e:
82
+ raise TunnelError("Download failed: %s\n%s" % (e, INSTALL_HELP))
83
+ if asset.endswith(".tgz"):
84
+ with tarfile.open(tmp_path) as tf:
85
+ member = next(m for m in tf.getmembers() if m.name.endswith("cloudflared"))
86
+ member.name = dest.name # never trust archive paths
87
+ tf.extract(member, str(dest_dir))
88
+ tmp_path.unlink()
89
+ else:
90
+ os.replace(tmp_path, dest)
91
+ dest.chmod(0o755)
92
+ return str(dest)
93
+
94
+
95
+ class Tunnel:
96
+ """Runs `cloudflared` and exposes a public https URL for a local port.
97
+
98
+ * quick tunnel (default): free, no account, random *.trycloudflare.com URL
99
+ * named tunnel (token + public_url): stable hostname on your Cloudflare account
100
+ """
101
+
102
+ def __init__(self, exe: str, local_port: int, token: Optional[str] = None,
103
+ public_url: Optional[str] = None):
104
+ self.exe, self.port, self.token, self.public_url = exe, local_port, token, public_url
105
+ self.proc = None
106
+ self._url = None
107
+ self._url_evt = threading.Event()
108
+ self._reg_evt = threading.Event()
109
+ self.log_path = home() / "cloudflared.log"
110
+
111
+ def _reader(self):
112
+ with open(self.log_path, "w") as log:
113
+ for line in self.proc.stdout:
114
+ log.write(line)
115
+ log.flush()
116
+ if not self.token and not self._url:
117
+ m = QUICK_URL_RE.search(line)
118
+ if m:
119
+ self._url = m.group(0)
120
+ self._url_evt.set()
121
+ if REGISTERED_RE.search(line):
122
+ self._reg_evt.set()
123
+ if self.token:
124
+ self._url_evt.set()
125
+
126
+ def start(self, timeout: float = 60) -> str:
127
+ env = dict(os.environ)
128
+ if self.token:
129
+ env["TUNNEL_TOKEN"] = self.token # via env, not argv, so it's not visible in `ps`
130
+ cmd = [self.exe, "tunnel", "--no-autoupdate", "run"]
131
+ else:
132
+ cmd = [self.exe, "tunnel", "--no-autoupdate", "--url", "http://127.0.0.1:%d" % self.port]
133
+ self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
134
+ universal_newlines=True, env=env)
135
+ threading.Thread(target=self._reader, daemon=True).start()
136
+ end = time.time() + timeout
137
+ while not self._url_evt.wait(0.5):
138
+ if self.proc.poll() is not None or time.time() > end:
139
+ tail = self.log_path.read_text()[-1200:] if self.log_path.exists() else ""
140
+ self.stop()
141
+ raise TunnelError("cloudflared did not come up (log: %s):\n%s" % (self.log_path, tail))
142
+ # URL is known; give the edge a moment to register so the client doesn't hit a 530
143
+ self._reg_evt.wait(20)
144
+ return (self.public_url if self.token else self._url).rstrip("/")
145
+
146
+ def stop(self):
147
+ if self.proc and self.proc.poll() is None:
148
+ self.proc.terminate()
149
+ try:
150
+ self.proc.wait(8)
151
+ except subprocess.TimeoutExpired:
152
+ self.proc.kill()
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: alyxruntime
3
+ Version: 0.1.0
4
+ Summary: Use any remote machine's CPU/GPU as a Jupyter runtime over a Cloudflare tunnel, Colab-style, from the command line or Python.
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.28
8
+ Requires-Dist: websocket-client>=1.4
9
+ Provides-Extra: host
10
+ Requires-Dist: jupyter_server>=2.0; extra == "host"
11
+ Requires-Dist: ipykernel>=6.0; extra == "host"
12
+ Provides-Extra: lab
13
+ Requires-Dist: jupyter_server>=2.0; extra == "lab"
14
+ Requires-Dist: ipykernel>=6.0; extra == "lab"
15
+ Requires-Dist: jupyterlab>=4.0; extra == "lab"
16
+
17
+ # alyxruntime
18
+
19
+ Use any other computer's CPU/GPU as your Jupyter runtime - like Colab's "connect to local runtime",
20
+ but for *any* machine you control, from the command line or from Python.
21
+
22
+ ```
23
+ your laptop Cloudflare edge GPU box (host) - no open ports, outbound only
24
+ ┌────────────┐ https ┌───────────────┐ tunnel ┌─────────────────────────────────────────┐
25
+ │ alyxruntime│ ────► │ *.trycloudflare│ ◄──────│ cloudflared ─► IP-allowlist gate │
26
+ │ connect │ +token│ .com │ │ └─► Jupyter (loopback) │
27
+ └────────────┘ └───────────────┘ │ └─► kernel (GPU) │
28
+ └─────────────────────────────────────────┘
29
+ ```
30
+
31
+ The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
32
+ and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
38
+ pip install alyxruntime # on the machine that CONNECTS (lighter)
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ On the **host** (the machine with the GPU):
44
+
45
+ ```bash
46
+ alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
47
+ alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
48
+ alyxruntime stream --allowed_ip all # any IP - the token is the only gate
49
+ ```
50
+
51
+ It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
52
+ binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
53
+
54
+ ```bash
55
+ alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
56
+ ```
57
+
58
+ Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
59
+ `access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
60
+
61
+ On the **client**:
62
+
63
+ ```bash
64
+ alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
65
+ alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
66
+ alyxruntime shell # interactive prompt; variables persist between commands
67
+ alyxruntime info # remote CPU / RAM / GPU
68
+ alyxruntime upload data.csv # copy files to the host's working dir
69
+ alyxruntime download model.pt
70
+ alyxruntime restart # fresh kernel
71
+ alyxruntime disconnect --kill-kernel
72
+ ```
73
+
74
+ Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
75
+ VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
76
+
77
+ ### From Python
78
+
79
+ ```python
80
+ from alyxruntime import Runtime
81
+
82
+ rt = Runtime.connect("https://my-host:8765/?token=XXXX")
83
+ rt.run("import torch; print(torch.cuda.is_available())")
84
+ res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
85
+ rt.upload("data.csv"); rt.download("out.pt")
86
+ ```
87
+
88
+ Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
89
+
90
+ ### Stable URL (named tunnel)
91
+
92
+ Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
93
+ dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
94
+
95
+ ```bash
96
+ alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
97
+ --allowed_ip 203.0.113.7
98
+ ```
99
+
100
+ ## `stream` options
101
+
102
+ | flag | meaning |
103
+ |---|---|
104
+ | `--allowed_ip IP [IP...]` | public IPs / CIDR ranges (v4 or v6) allowed to connect, as seen by Cloudflare, or the word `all` (same as `--allow-any`). **Required** in tunnel mode. |
105
+ | `--allow-any` | accept any IP (the token is then your only protection) |
106
+ | `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
107
+ | `--install-cloudflared` | download `cloudflared` automatically if it's missing |
108
+ | `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
109
+ | `--token` | fixed access token instead of a random one |
110
+ | `--root-dir` | directory the client can see (default: current dir) |
111
+ | `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
112
+ | `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
113
+
114
+ ## Security - please read
115
+
116
+ A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
117
+
118
+ * **Two independent gates:** the IP allowlist *and* a random token.
119
+ * **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
120
+ socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
121
+ Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
122
+ such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
123
+ each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
124
+ * The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
125
+ token like a password, and don't paste the connect line in public places.
126
+ * Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
127
+ tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
128
+ * IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
129
+ sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
130
+ * Local users on the host can reach the gate on its loopback port and are trusted like the host user.
131
+ * Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
132
+ * Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
133
+ Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
@@ -0,0 +1,13 @@
1
+ alyxruntime/__init__.py,sha256=STqcviNQJ3D5jNF1CHStHU5Tk8_XBNQcArINUkAExz4,273
2
+ alyxruntime/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ alyxruntime/cli.py,sha256=x8FXenSVU99BzjJADfQJhlh_f3NSAMoYtoDAEyzZq_o,9453
4
+ alyxruntime/client.py,sha256=KS_b6QhxG9mn92XC-xkdH8IghWWXIH3w8zGyWqhn7Yc,12317
5
+ alyxruntime/config.py,sha256=1W4cFtWzfRIkn8YuGtT8XCFcy9tRgJNCvj6fxG2t4Iw,747
6
+ alyxruntime/hardware.py,sha256=jN3GI2CgKC0pQ9sTAz6GjmStPCa5nT6gUVe2UY38Lac,2020
7
+ alyxruntime/server.py,sha256=EEcr7r8y80J4ScJuW5kR11gfF8K06F8ztkTqgqik794,13834
8
+ alyxruntime/tunnel.py,sha256=a0NV0PDD58vONyRg7IkwmbD9P8IjwfbvkGfGT8ZLqpY,5916
9
+ alyxruntime-0.1.0.dist-info/METADATA,sha256=VTxiWj-uW9PtAeqFEbR_moHJLVkMzydCYZij7j0TGZc,7212
10
+ alyxruntime-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ alyxruntime-0.1.0.dist-info/entry_points.txt,sha256=kyerWOzN5te9fUZhnL1Fg52cAX5Ow5Sx7_okaQhBp-Q,53
12
+ alyxruntime-0.1.0.dist-info/top_level.txt,sha256=ppH5DWnTRtjyzvpVoPCnGKsdhKPHBqe8HjpWFSB_n-U,12
13
+ alyxruntime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ alyxruntime = alyxruntime.cli:main
@@ -0,0 +1 @@
1
+ alyxruntime