alyxruntime 0.2.0__tar.gz → 0.3.0__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.
Files changed (20) hide show
  1. alyxruntime-0.2.0/README.md → alyxruntime-0.3.0/PKG-INFO +135 -127
  2. alyxruntime-0.2.0/alyxruntime.egg-info/PKG-INFO → alyxruntime-0.3.0/README.md +119 -143
  3. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/__init__.py +1 -1
  4. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/cli.py +2 -1
  5. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/hardware.py +9 -4
  6. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/server.py +11 -1
  7. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/ui/index.html +25 -180
  8. {alyxruntime-0.2.0 → alyxruntime-0.3.0/alyxruntime.egg-info}/PKG-INFO +3 -11
  9. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/pyproject.toml +1 -1
  10. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/__main__.py +0 -0
  11. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/client.py +0 -0
  12. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/config.py +0 -0
  13. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/tunnel.py +0 -0
  14. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime/webui.py +0 -0
  15. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime.egg-info/SOURCES.txt +0 -0
  16. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime.egg-info/dependency_links.txt +0 -0
  17. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime.egg-info/entry_points.txt +0 -0
  18. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime.egg-info/requires.txt +0 -0
  19. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/alyxruntime.egg-info/top_level.txt +0 -0
  20. {alyxruntime-0.2.0 → alyxruntime-0.3.0}/setup.cfg +0 -0
@@ -1,127 +1,135 @@
1
- # alyxruntime
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
- ```
7
- your laptop Cloudflare edge GPU box (host) - no open ports, outbound only
8
- ┌────────────┐ https ┌───────────────┐ tunnel ┌─────────────────────────────────────────┐
9
- │ alyxruntime│ ────► │ *.trycloudflare│ ◄──────│ cloudflared ─► IP-allowlist gate │
10
- │ connect │ +token│ .com │ │ └─► Jupyter (loopback) │
11
- └────────────┘ └───────────────┘ │ └─► kernel (GPU) │
12
- └─────────────────────────────────────────┘
13
- ```
14
-
15
- The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
16
- and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
17
-
18
- ## Install
19
-
20
- ```bash
21
- pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
22
- pip install alyxruntime # on the machine that CONNECTS (lighter)
23
- ```
24
-
25
- ## Usage
26
-
27
- On the **host** (the machine with the GPU):
28
-
29
- ```bash
30
- alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
31
- alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
32
- alyxruntime stream --allowed_ip all # any IP - the token is the only gate
33
- ```
34
-
35
- It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
36
- binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
37
-
38
- ```bash
39
- alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
40
- ```
41
-
42
- Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
43
- `access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
44
-
45
- On the **client**:
46
-
47
- ```bash
48
- alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
49
- alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
50
- alyxruntime shell # interactive prompt; variables persist between commands
51
- alyxruntime info # remote CPU / RAM / GPU
52
- alyxruntime upload data.csv # copy files to the host's working dir
53
- alyxruntime download model.pt
54
- alyxruntime restart # fresh kernel
55
- alyxruntime disconnect --kill-kernel
56
- ```
57
-
58
- Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
59
- VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
60
-
61
- ### From Python
62
-
63
- ```python
64
- from alyxruntime import Runtime
65
-
66
- rt = Runtime.connect("https://my-host:8765/?token=XXXX")
67
- rt.run("import torch; print(torch.cuda.is_available())")
68
- res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
69
- rt.upload("data.csv"); rt.download("out.pt")
70
- ```
71
-
72
- Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
73
-
74
- ### Stable URL (named tunnel)
75
-
76
- Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
77
- dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
78
-
79
- ```bash
80
- alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
81
- --allowed_ip 203.0.113.7
82
- ```
83
-
84
- ## `stream` options
85
-
86
- | flag | meaning |
87
- |---|---|
88
- | `--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. |
89
- | `--allow-any` | accept any IP (the token is then your only protection) |
90
- | `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
91
- | `--install-cloudflared` | download `cloudflared` automatically if it's missing |
92
- | `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
93
- | `--token` | fixed access token instead of a random one |
94
- | `--root-dir` | directory the client can see (default: current dir) |
95
- | `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
96
- | `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
97
-
98
- ## Security - please read
99
-
100
- A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
101
-
102
- * **Two independent gates:** the IP allowlist *and* a random token.
103
- * **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
104
- socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
105
- Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
106
- such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
107
- each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
108
- * The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
109
- token like a password, and don't paste the connect line in public places.
110
- * Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
111
- tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
112
- * IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
113
- sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
114
- * Local users on the host can reach the gate on its loopback port and are trusted like the host user.
115
- * Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
116
- * Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
117
- Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
118
-
119
- ## Web UI (Colab-style notebook)
120
-
121
- ```bash
122
- alyxruntime connect <url> # once (or use the Connect button in the UI)
123
- alyxruntime ui # opens http://127.0.0.1:8899 - notebook cells run on the remote GPU/CPU
124
- ```
125
- Code/text cells, Shift+Enter / Ctrl+Enter, streaming output and plots, run all / interrupt / restart,
126
- remote file browser with upload/download, open/download `.ipynb`, dark mode. Notebooks autosave in the browser.
127
- The UI only listens on 127.0.0.1 and requires a per-launch secret, so other websites can't drive your runtime.
1
+ Metadata-Version: 2.4
2
+ Name: alyxruntime
3
+ Version: 0.3.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
+ # α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
- Metadata-Version: 2.4
2
- Name: alyxruntime
3
- Version: 0.2.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).
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.
@@ -2,5 +2,5 @@
2
2
  from .client import AlyxError, AuthError, ExecResult, Runtime
3
3
  from .server import serve
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.2.1"
6
6
  __all__ = ["Runtime", "ExecResult", "AlyxError", "AuthError", "serve", "__version__"]
@@ -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) -> None:
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
- :root {
4
- color-scheme: light;
5
- --bg: #f7f8fc;
6
- --surface: #ffffff;
7
- --surface-soft: #f3f5fb;
8
- --fg: #182033;
9
- --mut: #718096;
10
- --bd: #e2e7f0;
11
- --code: #f7f8fc;
12
- --hov: #edf1f8;
13
- --pri: #6759e8;
14
- --pri-hover: #5547d4;
15
- --err: #dc4658;
16
- --success: #15966b;
17
- --shadow: 0 8px 30px rgba(27, 39, 74, .06);
18
- }
19
- body.dark {
20
- color-scheme: dark;
21
- --bg: #111522;
22
- --surface: #191f30;
23
- --surface-soft: #20283b;
24
- --fg: #edf1fb;
25
- --mut: #9aa7bf;
26
- --bd: #303a50;
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{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[0]&&i.gpus[0].name)||(i.cpu_count+' CPU')}`;b.title=`${s.url}\nRAM ${i.ram_gb} GB · ${i.cpu_count} cores`}else{b.textContent='Connect';$('#dlg').showModal()}}
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.0
3
+ Version: 0.3.0
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
- # alyxruntime
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.0"
7
+ version = "0.3.0"
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