alyxruntime 0.2.0__tar.gz → 0.2.1__tar.gz
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-0.2.0/README.md → alyxruntime-0.2.1/PKG-INFO +135 -127
- alyxruntime-0.2.0/alyxruntime.egg-info/PKG-INFO → alyxruntime-0.2.1/README.md +119 -143
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/__init__.py +1 -1
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/cli.py +2 -1
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/hardware.py +9 -4
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/server.py +11 -1
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/ui/index.html +25 -180
- {alyxruntime-0.2.0 → alyxruntime-0.2.1/alyxruntime.egg-info}/PKG-INFO +3 -11
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/pyproject.toml +1 -1
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/__main__.py +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/client.py +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/config.py +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/tunnel.py +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime/webui.py +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime.egg-info/SOURCES.txt +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime.egg-info/dependency_links.txt +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime.egg-info/entry_points.txt +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime.egg-info/requires.txt +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/alyxruntime.egg-info/top_level.txt +0 -0
- {alyxruntime-0.2.0 → alyxruntime-0.2.1}/setup.cfg +0 -0
|
@@ -1,127 +1,135 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
##
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
alyxruntime
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
alyxruntime
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
|
94
|
-
|
|
95
|
-
| `--
|
|
96
|
-
| `--
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alyxruntime
|
|
3
|
+
Version: 0.2.1
|
|
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
|
+
# αlyxruntime
|
|
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
|
+
The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
|
|
23
|
+
and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
|
|
29
|
+
pip install alyxruntime # on the machine that CONNECTS (lighter)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
On the **host** (the machine with the GPU):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
|
|
38
|
+
alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
|
|
39
|
+
alyxruntime stream --allowed_ip all # any IP - the token is the only gate
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
|
|
43
|
+
binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
|
|
50
|
+
`access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
|
|
51
|
+
|
|
52
|
+
On the **client**:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
|
|
56
|
+
alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
|
|
57
|
+
alyxruntime shell # interactive prompt; variables persist between commands
|
|
58
|
+
alyxruntime info # remote CPU / RAM / GPU
|
|
59
|
+
alyxruntime upload data.csv # copy files to the host's working dir
|
|
60
|
+
alyxruntime download model.pt
|
|
61
|
+
alyxruntime restart # fresh kernel
|
|
62
|
+
alyxruntime disconnect --kill-kernel
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
|
|
66
|
+
VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
|
|
67
|
+
|
|
68
|
+
### From Python
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from alyxruntime import Runtime
|
|
72
|
+
|
|
73
|
+
rt = Runtime.connect("https://my-host:8765/?token=XXXX")
|
|
74
|
+
rt.run("import torch; print(torch.cuda.is_available())")
|
|
75
|
+
res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
|
|
76
|
+
rt.upload("data.csv"); rt.download("out.pt")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
|
|
80
|
+
|
|
81
|
+
### Stable URL (named tunnel)
|
|
82
|
+
|
|
83
|
+
Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
|
|
84
|
+
dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
|
|
88
|
+
--allowed_ip 203.0.113.7
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## `stream` options
|
|
92
|
+
|
|
93
|
+
| flag | meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `--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. |
|
|
96
|
+
| `--allow-any` | accept any IP (the token is then your only protection) |
|
|
97
|
+
| `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
|
|
98
|
+
| `--install-cloudflared` | download `cloudflared` automatically if it's missing |
|
|
99
|
+
| `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
|
|
100
|
+
| `--gpus 0,1` | share only these GPU indices (`none` = CPU only). Run several `stream`s with different `--gpus` to give each client its own GPU |
|
|
101
|
+
| `--token` | fixed access token instead of a random one |
|
|
102
|
+
| `--root-dir` | directory the client can see (default: current dir) |
|
|
103
|
+
| `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
|
|
104
|
+
| `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
|
|
105
|
+
|
|
106
|
+
## Security - please read
|
|
107
|
+
|
|
108
|
+
A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
|
|
109
|
+
|
|
110
|
+
* **Two independent gates:** the IP allowlist *and* a random token.
|
|
111
|
+
* **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
|
|
112
|
+
socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
|
|
113
|
+
Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
|
|
114
|
+
such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
|
|
115
|
+
each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
|
|
116
|
+
* The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
|
|
117
|
+
token like a password, and don't paste the connect line in public places.
|
|
118
|
+
* Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
|
|
119
|
+
tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
|
|
120
|
+
* IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
|
|
121
|
+
sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
|
|
122
|
+
* Local users on the host can reach the gate on its loopback port and are trusted like the host user.
|
|
123
|
+
* Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
|
|
124
|
+
* Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
|
|
125
|
+
Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
|
|
126
|
+
|
|
127
|
+
## Web UI (Colab-style notebook)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
alyxruntime connect <url> # once (or use the Connect button in the UI)
|
|
131
|
+
alyxruntime ui # opens http://127.0.0.1:8899 - notebook cells run on the remote GPU/CPU
|
|
132
|
+
```
|
|
133
|
+
Code/text cells, Shift+Enter / Ctrl+Enter, streaming output and plots, run all / interrupt / restart,
|
|
134
|
+
remote file browser with upload/download, open/download `.ipynb`, dark mode. Notebooks autosave in the browser.
|
|
135
|
+
The UI only listens on 127.0.0.1 and requires a per-launch secret, so other websites can't drive your runtime.
|
|
@@ -1,143 +1,119 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
alyxruntime
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
```
|
|
55
|
-
alyxruntime
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
alyxruntime
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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).
|
|
134
|
-
|
|
135
|
-
## Web UI (Colab-style notebook)
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
alyxruntime connect <url> # once (or use the Connect button in the UI)
|
|
139
|
-
alyxruntime ui # opens http://127.0.0.1:8899 - notebook cells run on the remote GPU/CPU
|
|
140
|
-
```
|
|
141
|
-
Code/text cells, Shift+Enter / Ctrl+Enter, streaming output and plots, run all / interrupt / restart,
|
|
142
|
-
remote file browser with upload/download, open/download `.ipynb`, dark mode. Notebooks autosave in the browser.
|
|
143
|
-
The UI only listens on 127.0.0.1 and requires a per-launch secret, so other websites can't drive your runtime.
|
|
1
|
+
# αlyxruntime
|
|
2
|
+
|
|
3
|
+
Use any other computer's CPU/GPU as your Jupyter runtime - like Colab's "connect to local runtime",
|
|
4
|
+
but for *any* machine you control, from the command line or from Python.
|
|
5
|
+
|
|
6
|
+
The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
|
|
7
|
+
and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
|
|
13
|
+
pip install alyxruntime # on the machine that CONNECTS (lighter)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
On the **host** (the machine with the GPU):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
|
|
22
|
+
alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
|
|
23
|
+
alyxruntime stream --allowed_ip all # any IP - the token is the only gate
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
|
|
27
|
+
binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
|
|
34
|
+
`access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
|
|
35
|
+
|
|
36
|
+
On the **client**:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
|
|
40
|
+
alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
|
|
41
|
+
alyxruntime shell # interactive prompt; variables persist between commands
|
|
42
|
+
alyxruntime info # remote CPU / RAM / GPU
|
|
43
|
+
alyxruntime upload data.csv # copy files to the host's working dir
|
|
44
|
+
alyxruntime download model.pt
|
|
45
|
+
alyxruntime restart # fresh kernel
|
|
46
|
+
alyxruntime disconnect --kill-kernel
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
|
|
50
|
+
VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
|
|
51
|
+
|
|
52
|
+
### From Python
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from alyxruntime import Runtime
|
|
56
|
+
|
|
57
|
+
rt = Runtime.connect("https://my-host:8765/?token=XXXX")
|
|
58
|
+
rt.run("import torch; print(torch.cuda.is_available())")
|
|
59
|
+
res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
|
|
60
|
+
rt.upload("data.csv"); rt.download("out.pt")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
|
|
64
|
+
|
|
65
|
+
### Stable URL (named tunnel)
|
|
66
|
+
|
|
67
|
+
Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
|
|
68
|
+
dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
|
|
72
|
+
--allowed_ip 203.0.113.7
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## `stream` options
|
|
76
|
+
|
|
77
|
+
| flag | meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `--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. |
|
|
80
|
+
| `--allow-any` | accept any IP (the token is then your only protection) |
|
|
81
|
+
| `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
|
|
82
|
+
| `--install-cloudflared` | download `cloudflared` automatically if it's missing |
|
|
83
|
+
| `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
|
|
84
|
+
| `--gpus 0,1` | share only these GPU indices (`none` = CPU only). Run several `stream`s with different `--gpus` to give each client its own GPU |
|
|
85
|
+
| `--token` | fixed access token instead of a random one |
|
|
86
|
+
| `--root-dir` | directory the client can see (default: current dir) |
|
|
87
|
+
| `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
|
|
88
|
+
| `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
|
|
89
|
+
|
|
90
|
+
## Security - please read
|
|
91
|
+
|
|
92
|
+
A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
|
|
93
|
+
|
|
94
|
+
* **Two independent gates:** the IP allowlist *and* a random token.
|
|
95
|
+
* **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
|
|
96
|
+
socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
|
|
97
|
+
Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
|
|
98
|
+
such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
|
|
99
|
+
each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
|
|
100
|
+
* The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
|
|
101
|
+
token like a password, and don't paste the connect line in public places.
|
|
102
|
+
* Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
|
|
103
|
+
tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
|
|
104
|
+
* IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
|
|
105
|
+
sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
|
|
106
|
+
* Local users on the host can reach the gate on its loopback port and are trusted like the host user.
|
|
107
|
+
* Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
|
|
108
|
+
* Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
|
|
109
|
+
Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
|
|
110
|
+
|
|
111
|
+
## Web UI (Colab-style notebook)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
alyxruntime connect <url> # once (or use the Connect button in the UI)
|
|
115
|
+
alyxruntime ui # opens http://127.0.0.1:8899 - notebook cells run on the remote GPU/CPU
|
|
116
|
+
```
|
|
117
|
+
Code/text cells, Shift+Enter / Ctrl+Enter, streaming output and plots, run all / interrupt / restart,
|
|
118
|
+
remote file browser with upload/download, open/download `.ipynb`, dark mode. Notebooks autosave in the browser.
|
|
119
|
+
The UI only listens on 127.0.0.1 and requires a per-launch secret, so other websites can't drive your runtime.
|
|
@@ -180,7 +180,7 @@ def cmd_stream(a):
|
|
|
180
180
|
serve(allowed_ip=a.allowed_ip, host=a.host, port=a.port, token=a.token, root_dir=a.root_dir,
|
|
181
181
|
tls_cert=a.tls_cert, tls_key=a.tls_key, allow_any=a.allow_any, public_host=a.public_host,
|
|
182
182
|
tunnel=a.tunnel, tunnel_token=a.tunnel_token, public_url=a.public_url,
|
|
183
|
-
auto_install=a.install_cloudflared)
|
|
183
|
+
auto_install=a.install_cloudflared, gpus=a.gpus)
|
|
184
184
|
except ValueError as e:
|
|
185
185
|
die(str(e))
|
|
186
186
|
|
|
@@ -219,6 +219,7 @@ def build_parser():
|
|
|
219
219
|
help="download cloudflared automatically if it's missing (no prompt)")
|
|
220
220
|
s.add_argument("--port", type=int, help="local port (default: random with tunnel, 8765 with --no-tunnel)")
|
|
221
221
|
s.add_argument("--host", default="0.0.0.0", help="interface to bind with --no-tunnel (default all)")
|
|
222
|
+
s.add_argument("--gpus", help="only share these GPU indices, e.g. 0,1 (or 'none' for CPU only); default: all")
|
|
222
223
|
s.add_argument("--token", help="use a fixed token instead of a random one")
|
|
223
224
|
s.add_argument("--root-dir", help="working directory exposed to the client (default: cwd)")
|
|
224
225
|
s.add_argument("--tls-cert", help="PEM certificate to serve HTTPS")
|
|
@@ -33,12 +33,17 @@ def summary():
|
|
|
33
33
|
if exe:
|
|
34
34
|
try:
|
|
35
35
|
out = subprocess.run(
|
|
36
|
-
[exe, "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader"],
|
|
36
|
+
[exe, "--query-gpu=index,name,memory.total,driver_version", "--format=csv,noheader"],
|
|
37
37
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True, timeout=10,
|
|
38
38
|
).stdout
|
|
39
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})
|
|
40
|
+
idx, name, mem, drv = [p.strip() for p in line.split(",")]
|
|
41
|
+
info["gpus"].append({"index": idx, "name": name, "memory": mem, "driver": drv})
|
|
42
|
+
vis = os.environ.get("CUDA_VISIBLE_DEVICES")
|
|
43
|
+
if vis is not None:
|
|
44
|
+
want = [v.strip() for v in vis.split(",") if v.strip()]
|
|
45
|
+
if all(v.isdigit() for v in want):
|
|
46
|
+
info["gpus"] = [g for g in info["gpus"] if g["index"] in want]
|
|
42
47
|
except Exception:
|
|
43
48
|
pass
|
|
44
49
|
return info
|
|
@@ -46,7 +51,7 @@ def summary():
|
|
|
46
51
|
|
|
47
52
|
def format_summary(info):
|
|
48
53
|
gpus = info.get("gpus") or []
|
|
49
|
-
gpu_txt = ", ".join("%s (%s)" % (g["name"], g["memory"]) for g in gpus) or "none detected"
|
|
54
|
+
gpu_txt = ", ".join("#%s %s (%s)" % (g.get("index", "?"), g["name"], g["memory"]) for g in gpus) or "none detected"
|
|
50
55
|
return " host: %s\n cpu: %s cores\n ram: %s GB\n gpu: %s\n python: %s" % (
|
|
51
56
|
info.get("hostname"), info.get("cpu_count"), info.get("ram_gb"), gpu_txt, info.get("python"))
|
|
52
57
|
|
|
@@ -10,6 +10,7 @@ import asyncio
|
|
|
10
10
|
import importlib.util
|
|
11
11
|
import ipaddress
|
|
12
12
|
import os
|
|
13
|
+
import re
|
|
13
14
|
import secrets
|
|
14
15
|
import signal
|
|
15
16
|
import socket
|
|
@@ -235,7 +236,8 @@ def serve(allowed_ip: Optional[List[str]] = None, host: str = "0.0.0.0", port: O
|
|
|
235
236
|
tls_cert: Optional[str] = None, tls_key: Optional[str] = None,
|
|
236
237
|
allow_any: bool = False, public_host: Optional[str] = None,
|
|
237
238
|
tunnel: bool = True, tunnel_token: Optional[str] = None,
|
|
238
|
-
public_url: Optional[str] = None, auto_install: bool = False
|
|
239
|
+
public_url: Optional[str] = None, auto_install: bool = False,
|
|
240
|
+
gpus: Optional[str] = None) -> None:
|
|
239
241
|
"""Start sharing this machine. Blocks until Ctrl+C.
|
|
240
242
|
|
|
241
243
|
tunnel=True (default): expose it through a Cloudflare tunnel - no open ports, HTTPS included.
|
|
@@ -273,6 +275,12 @@ def serve(allowed_ip: Optional[List[str]] = None, host: str = "0.0.0.0", port: O
|
|
|
273
275
|
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
274
276
|
ssl_ctx.load_cert_chain(tls_cert, tls_key)
|
|
275
277
|
|
|
278
|
+
if gpus is not None: # e.g. "0,1" or "none": limits what every kernel on this share can see
|
|
279
|
+
gpus = gpus.strip()
|
|
280
|
+
gpus = "" if gpus.lower() in ("none", "cpu") else gpus
|
|
281
|
+
if not re.fullmatch(r"[\w.,-]*", gpus):
|
|
282
|
+
raise SystemExit("--gpus expects device indices like 0,1 (or 'none' for CPU only)")
|
|
283
|
+
os.environ["CUDA_VISIBLE_DEVICES"] = gpus
|
|
276
284
|
internal = _free_port()
|
|
277
285
|
env = dict(os.environ, JUPYTER_TOKEN=token) # env, not argv, so it's not visible in `ps`
|
|
278
286
|
logf = open(home() / "jupyter.log", "w")
|
|
@@ -304,6 +312,8 @@ def serve(allowed_ip: Optional[List[str]] = None, host: str = "0.0.0.0", port: O
|
|
|
304
312
|
print("\nRuntime is up. This machine:")
|
|
305
313
|
print(hardware.format_summary(hardware.summary()))
|
|
306
314
|
print("\nWorking dir : " + root)
|
|
315
|
+
if gpus is not None:
|
|
316
|
+
print("GPUs shared : %s" % (gpus or "none (CPU only)"))
|
|
307
317
|
if allow_any:
|
|
308
318
|
print("Allowed IPs : ANY (token is the only protection!)")
|
|
309
319
|
elif tunnel:
|
|
@@ -1,184 +1,29 @@
|
|
|
1
1
|
<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>alyxruntime notebook</title>
|
|
2
2
|
<style>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
--code: #151b2a;
|
|
28
|
-
--hov: #283149;
|
|
29
|
-
--pri: #a69bff;
|
|
30
|
-
--pri-hover: #b8afff;
|
|
31
|
-
--err: #ff8794;
|
|
32
|
-
--success: #54d6a8;
|
|
33
|
-
--shadow: 0 8px 30px rgba(0, 0, 0, .18);
|
|
34
|
-
}
|
|
35
|
-
* { box-sizing: border-box; }
|
|
36
|
-
body {
|
|
37
|
-
margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
38
|
-
overflow: hidden; background: var(--bg); color: var(--fg);
|
|
39
|
-
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
40
|
-
transition: background .18s ease, color .18s ease;
|
|
41
|
-
}
|
|
42
|
-
button, input, textarea { font: inherit; }
|
|
43
|
-
button {
|
|
44
|
-
color: inherit; background: transparent; border: 1px solid transparent;
|
|
45
|
-
cursor: pointer; border-radius: 9px; padding: 7px 10px;
|
|
46
|
-
transition: background .15s ease, border-color .15s ease, transform .15s ease;
|
|
47
|
-
}
|
|
48
|
-
button:hover { background: var(--hov); }
|
|
49
|
-
button:active { transform: translateY(1px); }
|
|
50
|
-
button:focus-visible, input:focus-visible { outline: 2px solid var(--pri); outline-offset: 2px; }
|
|
51
|
-
header {
|
|
52
|
-
position: relative; z-index: 10; min-height: 68px; padding: 12px 22px;
|
|
53
|
-
display: flex; align-items: center; gap: 13px; background: var(--surface);
|
|
54
|
-
border-bottom: 1px solid var(--bd);
|
|
55
|
-
}
|
|
56
|
-
.logo {
|
|
57
|
-
width: 42px; height: 42px; flex: 0 0 42px; border-radius: 14px;
|
|
58
|
-
background: linear-gradient(140deg, #7b6cff, #4d43c9);
|
|
59
|
-
color: white; display: grid; place-items: center; font-weight: 800;
|
|
60
|
-
font-size: 21px; box-shadow: 0 5px 14px rgba(103, 89, 232, .24);
|
|
61
|
-
}
|
|
62
|
-
.brand { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
63
|
-
.brand small { color: var(--mut); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; font-weight: 700; }
|
|
64
|
-
#title {
|
|
65
|
-
width: min(34vw, 330px); min-width: 140px; padding: 2px 5px;
|
|
66
|
-
color: var(--fg); border: 0; outline: 0; background: transparent;
|
|
67
|
-
border-radius: 6px; font-size: 17px; font-weight: 700;
|
|
68
|
-
}
|
|
69
|
-
#title:hover, #title:focus { background: var(--surface-soft); }
|
|
70
|
-
.menus { display: flex; align-items: center; gap: 3px; margin-left: 12px; }
|
|
71
|
-
.m { position: relative; }
|
|
72
|
-
.m > span { display: block; padding: 8px 11px; border-radius: 8px; cursor: pointer; color: var(--mut); font-weight: 600; }
|
|
73
|
-
.m:hover > span { background: var(--hov); color: var(--fg); }
|
|
74
|
-
.dd {
|
|
75
|
-
display: none; position: absolute; top: calc(100% + 5px); left: 0;
|
|
76
|
-
min-width: 225px; padding: 7px; background: var(--surface);
|
|
77
|
-
border: 1px solid var(--bd); border-radius: 13px; box-shadow: var(--shadow); z-index: 30;
|
|
78
|
-
}
|
|
79
|
-
.m:hover .dd, .m:focus-within .dd { display: block; }
|
|
80
|
-
.dd button { display: block; width: 100%; padding: 9px 11px; text-align: left; border-radius: 8px; }
|
|
81
|
-
#conn {
|
|
82
|
-
margin-left: auto; border: 1px solid var(--bd); border-radius: 999px;
|
|
83
|
-
padding: 9px 15px; background: var(--surface); white-space: nowrap; font-weight: 650;
|
|
84
|
-
}
|
|
85
|
-
#conn:hover { border-color: var(--pri); }
|
|
86
|
-
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--success); margin-right: 7px; box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 14%, transparent); }
|
|
87
|
-
.bar {
|
|
88
|
-
display: flex; align-items: center; gap: 8px; padding: 12px 22px;
|
|
89
|
-
background: var(--surface); border-bottom: 1px solid var(--bd);
|
|
90
|
-
}
|
|
91
|
-
.bar button { border-color: var(--bd); background: var(--surface); padding: 8px 12px; font-weight: 600; }
|
|
92
|
-
.bar button:hover { border-color: var(--pri); color: var(--pri); }
|
|
93
|
-
.bar button:first-child { background: var(--pri); border-color: var(--pri); color: white; }
|
|
94
|
-
.bar button:first-child:hover { background: var(--pri-hover); }
|
|
95
|
-
.wrap { flex: 1; display: flex; min-height: 0; }
|
|
96
|
-
aside {
|
|
97
|
-
width: 270px; flex: 0 0 270px; overflow: auto; padding: 17px 13px;
|
|
98
|
-
background: var(--surface); border-right: 1px solid var(--bd);
|
|
99
|
-
}
|
|
100
|
-
aside[hidden] { display: none; }
|
|
101
|
-
aside b { display: inline-block; padding: 5px 8px 10px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--mut); }
|
|
102
|
-
aside ul { margin: 5px 0; padding: 0; }
|
|
103
|
-
aside li { list-style: none; padding: 9px 10px; cursor: pointer; border-radius: 9px; overflow-wrap: anywhere; }
|
|
104
|
-
aside li:hover { background: var(--hov); }
|
|
105
|
-
#nb { flex: 1; min-width: 0; overflow: auto; padding: 30px 28px 180px; scroll-behavior: smooth; }
|
|
106
|
-
.cell { max-width: 1050px; margin: 0 auto 15px; }
|
|
107
|
-
.row { display: flex; gap: 10px; align-items: flex-start; }
|
|
108
|
-
.gut { width: 42px; flex: 0 0 42px; padding-top: 6px; text-align: center; color: var(--mut); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
109
|
-
.play {
|
|
110
|
-
width: 34px; height: 34px; padding: 0; border-radius: 11px;
|
|
111
|
-
background: var(--surface); border: 1px solid var(--bd); color: var(--pri);
|
|
112
|
-
}
|
|
113
|
-
.play:hover { background: var(--pri); border-color: var(--pri); color: white; }
|
|
114
|
-
.run .play { background: var(--pri); border-color: var(--pri); color: white; animation: pulse 1s infinite; }
|
|
115
|
-
@keyframes pulse { 50% { opacity: .55; } }
|
|
116
|
-
.ed {
|
|
117
|
-
flex: 1; min-width: 0; overflow: hidden; border: 1px solid var(--bd);
|
|
118
|
-
border-radius: 13px; background: var(--code); box-shadow: 0 1px 2px rgba(20, 30, 60, .02);
|
|
119
|
-
}
|
|
120
|
-
.cell:focus-within .ed { border-color: var(--pri); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pri) 12%, transparent); }
|
|
121
|
-
textarea {
|
|
122
|
-
display: block; width: 100%; min-height: 42px; resize: vertical; overflow: hidden;
|
|
123
|
-
padding: 13px 15px; border: 0; outline: 0; background: transparent; color: var(--fg);
|
|
124
|
-
font: 13px/1.65 "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
125
|
-
tab-size: 4;
|
|
126
|
-
}
|
|
127
|
-
.text .ed { background: transparent; border-color: transparent; box-shadow: none; }
|
|
128
|
-
.text:focus-within .ed { border-color: var(--pri); }
|
|
129
|
-
.text textarea { border: 1px solid var(--bd); border-radius: 12px; background: var(--surface); }
|
|
130
|
-
.md { padding: 5px 12px 12px; line-height: 1.75; }
|
|
131
|
-
.md h1 { font-size: clamp(25px, 3vw, 34px); letter-spacing: -.035em; margin: .3em 0; }
|
|
132
|
-
.md h2 { font-size: 23px; letter-spacing: -.02em; margin: .35em 0; }
|
|
133
|
-
.md h3 { font-size: 18px; margin: .35em 0; }
|
|
134
|
-
.md code { background: var(--surface-soft); padding: 2px 5px; border-radius: 5px; font-family: ui-monospace, monospace; }
|
|
135
|
-
.md a { color: var(--pri); }
|
|
136
|
-
.tools { display: flex; gap: 2px; visibility: hidden; opacity: 0; transition: opacity .15s ease; }
|
|
137
|
-
.cell:hover .tools, .cell:focus-within .tools { visibility: visible; opacity: 1; }
|
|
138
|
-
.tools button { padding: 6px 8px; color: var(--mut); }
|
|
139
|
-
.tools button:hover { color: var(--fg); }
|
|
140
|
-
.out { margin: 8px 0 0 52px; overflow: auto; }
|
|
141
|
-
.out pre {
|
|
142
|
-
margin: 0 0 6px; padding: 12px 14px; border: 1px solid var(--bd);
|
|
143
|
-
border-radius: 11px; background: var(--surface); white-space: pre-wrap;
|
|
144
|
-
overflow-wrap: anywhere; font: 12px/1.6 "SFMono-Regular", Consolas, monospace;
|
|
145
|
-
}
|
|
146
|
-
.out .stderr { border-left: 3px solid #e4a13a; }
|
|
147
|
-
.out .error { color: var(--err); border-left: 3px solid var(--err); }
|
|
148
|
-
.out img { max-width: 100%; border-radius: 12px; border: 1px solid var(--bd); }
|
|
149
|
-
dialog {
|
|
150
|
-
width: min(460px, calc(100vw - 30px)); padding: 24px; border: 1px solid var(--bd);
|
|
151
|
-
border-radius: 18px; background: var(--surface); color: var(--fg); box-shadow: var(--shadow);
|
|
152
|
-
}
|
|
153
|
-
dialog::backdrop { background: rgba(12, 18, 34, .55); backdrop-filter: blur(4px); }
|
|
154
|
-
dialog h3 { margin: 0 0 20px; font-size: 21px; }
|
|
155
|
-
dialog label { display: block; margin: 8px 0 15px; color: var(--mut); }
|
|
156
|
-
dialog input[type=text], dialog input[type=password] {
|
|
157
|
-
display: block; width: 100%; margin: 6px 0 16px; padding: 11px 12px;
|
|
158
|
-
color: var(--fg); background: var(--code); border: 1px solid var(--bd); border-radius: 9px;
|
|
159
|
-
}
|
|
160
|
-
.pri { background: var(--pri); color: white; border-color: var(--pri); padding: 9px 15px; font-weight: 700; }
|
|
161
|
-
.pri:hover { background: var(--pri-hover); }
|
|
162
|
-
#err { color: var(--err); margin: 8px 0; }
|
|
163
|
-
@media (max-width: 850px) {
|
|
164
|
-
header { padding: 10px 13px; gap: 9px; flex-wrap: wrap; }
|
|
165
|
-
.menus { order: 3; width: 100%; margin: 0; }
|
|
166
|
-
#conn { margin-left: auto; }
|
|
167
|
-
.bar { padding: 9px 13px; overflow-x: auto; }
|
|
168
|
-
#nb { padding: 20px 12px 120px; }
|
|
169
|
-
aside { width: 220px; flex-basis: 220px; }
|
|
170
|
-
.tools { visibility: visible; opacity: 1; }
|
|
171
|
-
}
|
|
172
|
-
@media (max-width: 560px) {
|
|
173
|
-
.logo { width: 36px; height: 36px; flex-basis: 36px; }
|
|
174
|
-
#title { width: 180px; min-width: 100px; font-size: 15px; }
|
|
175
|
-
#conn { padding: 7px 10px; font-size: 12px; }
|
|
176
|
-
.bar button { white-space: nowrap; }
|
|
177
|
-
aside { width: 170px; flex-basis: 170px; padding: 10px 7px; }
|
|
178
|
-
.gut { width: 30px; flex-basis: 30px; }
|
|
179
|
-
.row { gap: 6px; }
|
|
180
|
-
.out { margin-left: 36px; }
|
|
181
|
-
}
|
|
3
|
+
:root{--bg:#fff;--fg:#202124;--mut:#5f6368;--bd:#dadce0;--code:#f8f9fa;--hov:#f1f3f4;--pri:#1a73e8;--err:#d93025}
|
|
4
|
+
body.dark{--bg:#1f1f1f;--fg:#e8eaed;--mut:#9aa0a6;--bd:#3c4043;--code:#2a2a2a;--hov:#303134;--pri:#8ab4f8;--err:#f28b82}
|
|
5
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px "Google Sans",Roboto,system-ui,sans-serif;height:100vh;display:flex;flex-direction:column}
|
|
6
|
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;border-radius:4px;padding:4px 8px}button:hover{background:var(--hov)}
|
|
7
|
+
header{border-bottom:1px solid var(--bd);padding:6px 12px;display:flex;align-items:center;gap:10px}
|
|
8
|
+
.logo{width:34px;height:34px;border-radius:50%;background:#f9ab00;color:#fff;display:grid;place-items:center;font-weight:700;font-size:18px}
|
|
9
|
+
#title{font-size:18px;border:0;background:none;color:var(--fg);width:260px;padding:2px 6px;border-radius:4px}#title:hover,#title:focus{outline:1px solid var(--bd)}
|
|
10
|
+
.menus{display:flex;margin-left:46px;position:absolute;top:34px}.m{position:relative}.m>span{padding:3px 9px;display:block;cursor:default;border-radius:4px}.m:hover>span{background:var(--hov)}
|
|
11
|
+
.dd{display:none;position:absolute;top:100%;left:0;background:var(--bg);border:1px solid var(--bd);border-radius:4px;box-shadow:0 2px 8px #0003;min-width:230px;z-index:9;padding:4px 0}.m:hover .dd{display:block}
|
|
12
|
+
.dd button{display:block;width:100%;text-align:left;border-radius:0;padding:6px 16px}
|
|
13
|
+
#conn{margin-left:auto;border:1px solid var(--bd);border-radius:18px;padding:6px 14px}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#1e8e3e;margin-right:6px}
|
|
14
|
+
.bar{border-bottom:1px solid var(--bd);padding:4px 12px 4px 12px;margin-top:14px;display:flex;gap:6px}.bar button{border:1px solid var(--bd)}
|
|
15
|
+
.wrap{flex:1;display:flex;min-height:0}aside{width:260px;border-right:1px solid var(--bd);overflow:auto;padding:8px}aside[hidden]{display:none}
|
|
16
|
+
aside li{list-style:none;padding:5px 8px;cursor:pointer;border-radius:4px}aside li:hover{background:var(--hov)}aside ul{margin:6px 0;padding:0}
|
|
17
|
+
#nb{flex:1;overflow:auto;padding:16px 24px 200px}.cell{max-width:980px;margin:0 auto 10px}
|
|
18
|
+
.row{display:flex;gap:6px;align-items:flex-start}.gut{width:62px;flex:none;text-align:center;color:var(--mut);font:12px monospace}
|
|
19
|
+
.play{width:34px;height:34px;border-radius:50%;background:var(--hov)}.run .play{background:var(--pri);color:#fff;animation:p 1s infinite}@keyframes p{50%{opacity:.5}}
|
|
20
|
+
.ed{flex:1;border:1px solid var(--bd);border-radius:4px;background:var(--code);min-width:0}.cell:focus-within .ed{border-color:var(--pri)}
|
|
21
|
+
textarea{width:100%;border:0;background:none;color:var(--fg);font:13px/1.5 "Roboto Mono",Consolas,monospace;resize:none;padding:8px 10px;outline:0;display:block;overflow:hidden}
|
|
22
|
+
.text .ed{background:none;border-color:transparent}.text:focus-within .ed{border-color:var(--pri)}.text textarea{background:var(--code);border:1px solid var(--bd)}.md{padding:4px 8px;line-height:1.6}.md h1{font-size:28px;margin:.3em 0}.md h2{font-size:22px;margin:.3em 0}.md code{background:var(--code);padding:1px 4px;border-radius:3px}
|
|
23
|
+
.tools{display:flex;visibility:hidden}.cell:hover .tools,.cell:focus-within .tools{visibility:visible}
|
|
24
|
+
.out{margin:4px 0 0 68px}.out pre{margin:0;white-space:pre-wrap;word-break:break-word;font:13px/1.45 "Roboto Mono",Consolas,monospace}.out .stderr{background:#fce8e610}.out .error{color:var(--err)}.out img{max-width:100%}
|
|
25
|
+
dialog{border:1px solid var(--bd);border-radius:8px;background:var(--bg);color:var(--fg);width:440px}dialog::backdrop{background:#0006}dialog input[type=text],dialog input[type=password]{width:100%;padding:8px;margin:4px 0 12px;background:var(--code);color:var(--fg);border:1px solid var(--bd);border-radius:4px}
|
|
26
|
+
.pri{background:var(--pri);color:#fff}.pri:hover{background:var(--pri);opacity:.9}#err{color:var(--err);margin:8px 0}
|
|
182
27
|
</style></head><body>
|
|
183
28
|
<header><div class=logo>α</div><div><input id=title value="Untitled.ipynb"></div>
|
|
184
29
|
<div class=menus>
|
|
@@ -233,7 +78,7 @@ const run=c=>c.type=='code'?(q=q.then(()=>exec(c))):0;
|
|
|
233
78
|
async function runAll(){abort=false;all=true;for(const c of cells)run(c);await q;all=false;abort=false}
|
|
234
79
|
async function intr(){abort=true;await api('interrupt',{method:'POST'});q=q.then(()=>{abort=false})}
|
|
235
80
|
async function restart(){await api('restart',{method:'POST'});cells.forEach(c=>{c.n=0;c.el.querySelector('.n')&&(c.el.querySelector('.n').textContent='')})}
|
|
236
|
-
function setConn(s){const b=$('#conn');if(s.connected){const i=s.info||{};b.innerHTML=`<i class=dot></i>Connected · ${(i.gpus&&i.gpus
|
|
81
|
+
function setConn(s){const b=$('#conn');if(s.connected){const i=s.info||{};b.innerHTML=`<i class=dot></i>Connected · ${(i.gpus&&i.gpus.length?(i.gpus.length>1?i.gpus.length+'× ':'')+i.gpus[0].name:i.cpu_count+' CPU')}`;b.title=`${s.url}\nRAM ${i.ram_gb} GB · ${i.cpu_count} cores${(i.gpus||[]).map(g=>'\n#'+g.index+' '+g.name+' '+g.memory).join('')}`}else{b.textContent='Connect';$('#dlg').showModal()}}
|
|
237
82
|
async function conn(){$('#err').textContent='Connecting…';const r=await api('connect',{method:'POST',body:JSON.stringify({url:$('#cu').value,token:$('#ct').value,insecure:$('#ci').checked})});
|
|
238
83
|
const s=await r.json();if(s.error){$('#err').textContent=s.error;return}$('#err').textContent='';$('#dlg').close();setConn(s);ls('')}
|
|
239
84
|
async function ls(p){cwd=p;if($('#files').hidden)return;const r=await(await api('files?path='+encodeURIComponent(p))).json();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alyxruntime
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
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
5
|
Requires-Python: >=3.8
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -14,20 +14,11 @@ Requires-Dist: jupyter_server>=2.0; extra == "lab"
|
|
|
14
14
|
Requires-Dist: ipykernel>=6.0; extra == "lab"
|
|
15
15
|
Requires-Dist: jupyterlab>=4.0; extra == "lab"
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# αlyxruntime
|
|
18
18
|
|
|
19
19
|
Use any other computer's CPU/GPU as your Jupyter runtime - like Colab's "connect to local runtime",
|
|
20
20
|
but for *any* machine you control, from the command line or from Python.
|
|
21
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
22
|
The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
|
|
32
23
|
and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
|
|
33
24
|
|
|
@@ -106,6 +97,7 @@ alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com -
|
|
|
106
97
|
| `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
|
|
107
98
|
| `--install-cloudflared` | download `cloudflared` automatically if it's missing |
|
|
108
99
|
| `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
|
|
100
|
+
| `--gpus 0,1` | share only these GPU indices (`none` = CPU only). Run several `stream`s with different `--gpus` to give each client its own GPU |
|
|
109
101
|
| `--token` | fixed access token instead of a random one |
|
|
110
102
|
| `--root-dir` | directory the client can see (default: current dir) |
|
|
111
103
|
| `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "alyxruntime"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Use any remote machine's CPU/GPU as a Jupyter runtime over a Cloudflare tunnel, Colab-style, from the command line or Python."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|