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.
- alyxruntime/__init__.py +6 -0
- alyxruntime/__main__.py +4 -0
- alyxruntime/cli.py +255 -0
- alyxruntime/client.py +294 -0
- alyxruntime/config.py +34 -0
- alyxruntime/hardware.py +58 -0
- alyxruntime/server.py +344 -0
- alyxruntime/tunnel.py +152 -0
- alyxruntime-0.1.0.dist-info/METADATA +133 -0
- alyxruntime-0.1.0.dist-info/RECORD +13 -0
- alyxruntime-0.1.0.dist-info/WHEEL +5 -0
- alyxruntime-0.1.0.dist-info/entry_points.txt +2 -0
- alyxruntime-0.1.0.dist-info/top_level.txt +1 -0
alyxruntime/__init__.py
ADDED
|
@@ -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__"]
|
alyxruntime/__main__.py
ADDED
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
|
alyxruntime/hardware.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
alyxruntime
|