shelldeck 0.0.3__tar.gz → 0.0.4__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.
- {shelldeck-0.0.3 → shelldeck-0.0.4}/CHANGELOG.md +20 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/PKG-INFO +11 -6
- {shelldeck-0.0.3 → shelldeck-0.0.4}/README.md +10 -5
- {shelldeck-0.0.3 → shelldeck-0.0.4}/pyproject.toml +1 -1
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/auth.py +18 -1
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/cli.py +54 -4
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/server.py +160 -16
- {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_cli.py +12 -3
- {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_shelldeck.py +86 -8
- {shelldeck-0.0.3 → shelldeck-0.0.4}/.gitignore +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/LICENSE +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/__init__.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/__main__.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/addons/__init__.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/addons/fast_context.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/db.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/gitgraph.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/bash.sh +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.fish +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.ps1 +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshenv +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshrc +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/pty.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/runner.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/scheduler.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/shells.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/app.css +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/app.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/devices.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/gitgraph.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/history.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-192.png +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-32.png +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon-512.png +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/icon.svg +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/index.html +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/manifest.webmanifest +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/monitor.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/ui.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-fit.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-search.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-serialize.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-web-links.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-webgl.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.css +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/static/views.js +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/shelldeck/stats.py +0 -0
- {shelldeck-0.0.3 → shelldeck-0.0.4}/tests/test_gitgraph.py +0 -0
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.0.4] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Share approval: opening a share link puts the device on a waiting page until you allow it in the `sd share` window (`Allow it? [y/N]`); then it still needs the password.
|
|
10
|
+
- `sd share --new-link` prints another one-use link for the running share.
|
|
11
|
+
- Security headers on every response (no framing, no MIME sniffing, no referrer) and `Cache-Control: no-store` on the API.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Share links work once and expire after 10 minutes unopened. A new link voids the unused old one.
|
|
16
|
+
- Sharing needs a password of 12+ characters (an existing one that long counts after the next login).
|
|
17
|
+
- Revoking a shared device in Devices also voids its approval, so it needs a new link.
|
|
18
|
+
- README: `sd share` is encrypted in transit but not end to end; Cloudflare can see the traffic.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A share left open when `sd share` was killed or its window closed: the server now closes a share within a minute of its last heartbeat and signs its browsers out. Devices no longer shows "sharing via" a dead tunnel.
|
|
23
|
+
|
|
5
24
|
## [0.0.3] - 2026-09-29
|
|
6
25
|
|
|
7
26
|
### Added
|
|
@@ -48,6 +67,7 @@ First public release.
|
|
|
48
67
|
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
|
|
49
68
|
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
|
|
50
69
|
|
|
70
|
+
[0.0.4]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.4
|
|
51
71
|
[0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
|
|
52
72
|
[0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
|
|
53
73
|
[0.0.1]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shelldeck
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/codejunction/shelldeck
|
|
6
6
|
Project-URL: Source, https://github.com/codejunction/shelldeck
|
|
@@ -196,7 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
196
196
|
sd task list|add|move|delete|alarms
|
|
197
197
|
sd serve [--host H] run the server in the foreground
|
|
198
198
|
sd stop stop the background server (closes all terminals)
|
|
199
|
-
sd share
|
|
199
|
+
sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
|
|
200
200
|
sd login-link emergency: one-time login URL (host only)
|
|
201
201
|
sd reset-password emergency: forget the password (host only)
|
|
202
202
|
```
|
|
@@ -221,13 +221,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
|
|
|
221
221
|
**`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
|
|
222
222
|
|
|
223
223
|
```sh
|
|
224
|
-
sd share
|
|
224
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
225
|
+
sd share --new-link # another one-use link for the running share (a second device)
|
|
225
226
|
```
|
|
226
227
|
|
|
227
|
-
- **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
|
|
228
|
-
- **
|
|
228
|
+
- **Encrypted in transit, not end to end.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
|
|
229
|
+
- **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
|
|
230
|
+
- **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
|
|
231
|
+
- **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
|
|
229
232
|
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
230
|
-
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
|
|
233
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
|
|
231
234
|
|
|
232
235
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
233
236
|
|
|
@@ -244,6 +247,8 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
244
247
|
- **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
|
|
245
248
|
- **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
|
|
246
249
|
- **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
|
|
250
|
+
- **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
|
|
251
|
+
- **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
|
|
247
252
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
248
253
|
- **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
|
|
249
254
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -161,7 +161,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
161
161
|
sd task list|add|move|delete|alarms
|
|
162
162
|
sd serve [--host H] run the server in the foreground
|
|
163
163
|
sd stop stop the background server (closes all terminals)
|
|
164
|
-
sd share
|
|
164
|
+
sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
|
|
165
165
|
sd login-link emergency: one-time login URL (host only)
|
|
166
166
|
sd reset-password emergency: forget the password (host only)
|
|
167
167
|
```
|
|
@@ -186,13 +186,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
|
|
|
186
186
|
**`sd share` (any device, no setup).** Needs [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) (`winget install Cloudflare.cloudflared`); no Cloudflare account.
|
|
187
187
|
|
|
188
188
|
```sh
|
|
189
|
-
sd share
|
|
189
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
190
|
+
sd share --new-link # another one-use link for the running share (a second device)
|
|
190
191
|
```
|
|
191
192
|
|
|
192
|
-
- **Encrypted.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened.
|
|
193
|
-
- **
|
|
193
|
+
- **Encrypted in transit, not end to end.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
|
|
194
|
+
- **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
|
|
195
|
+
- **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
|
|
196
|
+
- **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
|
|
194
197
|
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
195
|
-
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Each `sd share` gets a new address and link.
|
|
198
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
|
|
196
199
|
|
|
197
200
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
198
201
|
|
|
@@ -209,6 +212,8 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
209
212
|
- **Local only.** The server binds to `127.0.0.1` and rejects HTTP and WebSocket requests whose `Host` or `Origin` isn't the app itself, so other websites can't reach your shells.
|
|
210
213
|
- **Passwords** are stored as PBKDF2-SHA256 (600k iterations). A login is a random token in an `HttpOnly`, `SameSite=Strict` cookie, and only its SHA-256 is stored. After 5 wrong passwords, each further try waits longer.
|
|
211
214
|
- **Devices.** Each login records where it came from (this machine, the network, or a share). Only one login is in use at a time; the others get `423` until they enter the password again. Revoke any login from the Devices page.
|
|
215
|
+
- **Headers.** Every response forbids framing (`X-Frame-Options: DENY`, `frame-ancestors 'none'`), sniffing and referrers, and API responses are never cached.
|
|
216
|
+
- **Sharing** adds a one-use, 10-minute link, approval on the host and a 12+ character password; see [Remote access](#remote-access).
|
|
212
217
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
213
218
|
- **Forgot the password?** On the host machine, `sd login-link` prints a one-time login URL valid for 5 minutes, and `sd reset-password` removes the password. There is deliberately no way to do either from the browser.
|
|
214
219
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -20,9 +20,11 @@ from . import db
|
|
|
20
20
|
|
|
21
21
|
LOCK_TIMEOUT_SECONDS = 30 * 60
|
|
22
22
|
MIN_PASSWORD = 8
|
|
23
|
+
SHARE_MIN_PASSWORD = 12 # `sd share` puts the login page on the internet
|
|
23
24
|
COOKIE = "sd_session"
|
|
24
25
|
TOKEN_HEADER = "x-shelldeck-token"
|
|
25
26
|
_PASSWORD_KEY = "auth_password_hash"
|
|
27
|
+
_STRONG_KEY = "auth_password_strong" # "1" when the password is long enough to share
|
|
26
28
|
_TOUCH_EVERY = 60 # seconds; last_seen writes are throttled
|
|
27
29
|
_CACHE_TTL = 5 # seconds a session lookup is trusted before re-reading the db
|
|
28
30
|
|
|
@@ -57,7 +59,20 @@ def has_password() -> bool:
|
|
|
57
59
|
|
|
58
60
|
def check_password(password: str) -> bool:
|
|
59
61
|
stored = db.get_setting(_PASSWORD_KEY)
|
|
60
|
-
|
|
62
|
+
ok = bool(stored) and _verify_password(password, stored)
|
|
63
|
+
if ok:
|
|
64
|
+
_note_strength(password) # passwords set before this flag existed get it at their next login
|
|
65
|
+
return ok
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _note_strength(password: str) -> None:
|
|
69
|
+
strong = "1" if len(password) >= SHARE_MIN_PASSWORD else ""
|
|
70
|
+
if db.get_setting(_STRONG_KEY, "") != strong:
|
|
71
|
+
db.set_setting(_STRONG_KEY, strong)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def strong_password() -> bool:
|
|
75
|
+
return db.get_setting(_STRONG_KEY, "") == "1"
|
|
61
76
|
|
|
62
77
|
|
|
63
78
|
def validate_new(password: str, confirm: str) -> str | None:
|
|
@@ -71,6 +86,7 @@ def validate_new(password: str, confirm: str) -> str | None:
|
|
|
71
86
|
|
|
72
87
|
def set_password(password: str) -> None:
|
|
73
88
|
db.set_setting(_PASSWORD_KEY, _hash_password(password))
|
|
89
|
+
_note_strength(password)
|
|
74
90
|
_file("setup-code").unlink(missing_ok=True)
|
|
75
91
|
|
|
76
92
|
|
|
@@ -128,6 +144,7 @@ def init_auth() -> str | None:
|
|
|
128
144
|
def reset() -> str:
|
|
129
145
|
"""Emergency, host-only: forget the password and every login. Returns the new setup code."""
|
|
130
146
|
db.delete_setting(_PASSWORD_KEY)
|
|
147
|
+
db.delete_setting(_STRONG_KEY)
|
|
131
148
|
db.delete_auth_sessions()
|
|
132
149
|
_file("setup-code").unlink(missing_ok=True)
|
|
133
150
|
_cache.clear()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import queue
|
|
3
4
|
import re
|
|
4
5
|
import shutil
|
|
5
6
|
import ssl
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
9
|
+
import threading
|
|
8
10
|
import time
|
|
9
11
|
import urllib.error
|
|
10
12
|
import urllib.request
|
|
@@ -308,13 +310,53 @@ def _show_share(url: str) -> None:
|
|
|
308
310
|
if sys.stdout.isatty():
|
|
309
311
|
segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
|
|
310
312
|
typer.echo(f"\n {url}\n")
|
|
311
|
-
typer.echo("
|
|
312
|
-
typer.echo("
|
|
313
|
+
typer.echo(" Open it on the other device within 10 minutes. It works once; allow the device here, then log in")
|
|
314
|
+
typer.echo(" with your password. Another device: `sd share --new-link`. Ctrl+C stops sharing and signs them out.")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _watch_share() -> None:
|
|
318
|
+
"""Renew the share's lease (the server closes it ~60s after this stops) and ask here before a
|
|
319
|
+
device that opened the link gets in."""
|
|
320
|
+
asks: queue.Queue = queue.Queue()
|
|
321
|
+
|
|
322
|
+
def poll() -> None:
|
|
323
|
+
asked: set[str] = set()
|
|
324
|
+
while True:
|
|
325
|
+
try:
|
|
326
|
+
for p in _api("/api/share").get("pending", []):
|
|
327
|
+
if p["id"] not in asked:
|
|
328
|
+
asked.add(p["id"])
|
|
329
|
+
asks.put(p)
|
|
330
|
+
except typer.Exit:
|
|
331
|
+
pass # server busy or restarting; try again
|
|
332
|
+
time.sleep(3)
|
|
333
|
+
|
|
334
|
+
def ask() -> None:
|
|
335
|
+
while True:
|
|
336
|
+
p = asks.get()
|
|
337
|
+
typer.echo(f"\n A device opened the link: {p['ip']}\n {p['agent'][:100]}")
|
|
338
|
+
allow = input(" Allow it? [y/N] ").strip().lower() in ("y", "yes")
|
|
339
|
+
try:
|
|
340
|
+
_api("/api/share/decide", "POST", {"id": p["id"], "allow": allow})
|
|
341
|
+
typer.echo(" allowed; it can log in with your password now" if allow else " denied")
|
|
342
|
+
except typer.Exit:
|
|
343
|
+
typer.echo(" that request expired; open a new link")
|
|
344
|
+
|
|
345
|
+
for target in (poll, ask):
|
|
346
|
+
threading.Thread(target=target, daemon=True).start()
|
|
313
347
|
|
|
314
348
|
|
|
315
349
|
@app.command()
|
|
316
|
-
def share(
|
|
317
|
-
|
|
350
|
+
def share(
|
|
351
|
+
new_link: bool = typer.Option(False, "--new-link", help="Print a fresh one-use link for the running share."),
|
|
352
|
+
):
|
|
353
|
+
"""Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, one-use QR link, host approval, then your password."""
|
|
354
|
+
if new_link:
|
|
355
|
+
if not (_health() == "ok" and _api("/api/share").get("host")):
|
|
356
|
+
typer.echo("not sharing; start with `sd share`", err=True)
|
|
357
|
+
raise typer.Exit(1)
|
|
358
|
+
_show_share(f"https://{_api('/api/share')['host']}" + _api("/api/share/link", "POST", {})["path"])
|
|
359
|
+
return
|
|
318
360
|
exe = shutil.which("cloudflared")
|
|
319
361
|
if not exe:
|
|
320
362
|
hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
@@ -324,6 +366,13 @@ def share():
|
|
|
324
366
|
if not _api("/api/auth/status").get("has_password"):
|
|
325
367
|
typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
|
|
326
368
|
raise typer.Exit(1)
|
|
369
|
+
if not _api("/api/share").get("strong_password"):
|
|
370
|
+
typer.echo(
|
|
371
|
+
f"Sharing puts the login page on the internet, so it needs a password of {auth.SHARE_MIN_PASSWORD}+ characters.\n"
|
|
372
|
+
"Change it in Settings (or, if it is already that long, log in once), then run `sd share` again.",
|
|
373
|
+
err=True,
|
|
374
|
+
)
|
|
375
|
+
raise typer.Exit(1)
|
|
327
376
|
# the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
|
|
328
377
|
cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
|
|
329
378
|
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
|
|
@@ -335,6 +384,7 @@ def share():
|
|
|
335
384
|
elif host and not shared and "Registered tunnel connection" in line:
|
|
336
385
|
shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
|
|
337
386
|
_show_share(shared)
|
|
387
|
+
_watch_share()
|
|
338
388
|
except KeyboardInterrupt:
|
|
339
389
|
pass
|
|
340
390
|
finally:
|
|
@@ -18,7 +18,7 @@ from urllib.parse import quote, urlsplit
|
|
|
18
18
|
|
|
19
19
|
import uvicorn
|
|
20
20
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
21
|
-
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
21
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
|
22
22
|
from fastapi.staticfiles import StaticFiles
|
|
23
23
|
|
|
24
24
|
from . import auth, db, gitgraph, shells, stats
|
|
@@ -68,26 +68,47 @@ def err(code: str, status: int = 400) -> JSONResponse:
|
|
|
68
68
|
return JSONResponse({"error": code}, status_code=status)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
# `sd share`: the tunnel's public hostname, sha256 of its link token
|
|
71
|
+
# `sd share`: the tunnel's public hostname, the sha256 of its one unused link token (None once used),
|
|
72
|
+
# when that link expires, when the CLI last renewed the lease, and one grant per browser that opened
|
|
73
|
+
# a link: sha256(cookie) -> {id, ip, agent, at, state: pending|ok|denied, session}.
|
|
72
74
|
_share: dict = {}
|
|
73
75
|
SHARE_COOKIE = "sd_share"
|
|
76
|
+
SHARE_LEASE = 60 # seconds without a CLI heartbeat before the share closes itself
|
|
77
|
+
LINK_TTL = 600 # a link must be opened within 10 minutes, once
|
|
78
|
+
APPROVAL_TTL = 300 # a pending device the host didn't answer is dropped after 5 minutes
|
|
74
79
|
|
|
75
80
|
|
|
76
|
-
def
|
|
77
|
-
return
|
|
81
|
+
def _sha(value: str) -> str:
|
|
82
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _share_on() -> bool:
|
|
86
|
+
"""A share exists and its `sd share` process is still renewing it."""
|
|
87
|
+
return bool(_share) and time.time() - _share["seen"] < SHARE_LEASE
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _share_link_ok(token: str) -> bool:
|
|
91
|
+
key = _share.get("link")
|
|
92
|
+
return bool(key) and time.time() < _share["link_until"] and hmac.compare_digest(_sha(token), key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _grant(conn) -> dict | None:
|
|
96
|
+
cookie = conn.cookies.get(SHARE_COOKIE)
|
|
97
|
+
return _share.get("grants", {}).get(_sha(cookie)) if cookie and _share else None
|
|
78
98
|
|
|
79
99
|
|
|
80
100
|
def _via_share(conn) -> bool:
|
|
81
|
-
return
|
|
101
|
+
return _share_on() and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
|
|
82
102
|
|
|
83
103
|
|
|
84
104
|
def _trusted(conn) -> bool:
|
|
85
105
|
"""Same-origin check. Browsers always send Origin cross-site; the CLI sends none.
|
|
86
|
-
|
|
106
|
+
On the share tunnel's host only /share/ pages are open; the rest needs a host-approved grant."""
|
|
87
107
|
headers = conn.headers
|
|
88
108
|
host = headers.get("host", "")
|
|
89
109
|
if _via_share(conn):
|
|
90
|
-
|
|
110
|
+
g = _grant(conn)
|
|
111
|
+
if not (conn.url.path.startswith("/share/") or (g and g["state"] == "ok")):
|
|
91
112
|
return False
|
|
92
113
|
elif ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
|
|
93
114
|
return False
|
|
@@ -127,9 +148,11 @@ async def lifespan(app: FastAPI):
|
|
|
127
148
|
manager.store = db.config_dir() / "scrollback"
|
|
128
149
|
manager.prune({s["id"] for s in db.list_sessions()})
|
|
129
150
|
saver = asyncio.create_task(_save_scrollback())
|
|
151
|
+
reaper = asyncio.create_task(_share_reaper())
|
|
130
152
|
log.info("shelldeck %s started", VERSION)
|
|
131
153
|
yield
|
|
132
154
|
saver.cancel()
|
|
155
|
+
reaper.cancel()
|
|
133
156
|
stats.stop()
|
|
134
157
|
sched.shutdown()
|
|
135
158
|
for task in list(readers.values()):
|
|
@@ -149,6 +172,15 @@ async def _save_scrollback() -> None:
|
|
|
149
172
|
log.warning("saving scrollback failed", exc_info=True)
|
|
150
173
|
|
|
151
174
|
|
|
175
|
+
async def _share_reaper() -> None:
|
|
176
|
+
"""Close a share whose `sd share` stopped renewing it (killed, window closed, crashed)."""
|
|
177
|
+
while True:
|
|
178
|
+
await asyncio.sleep(10)
|
|
179
|
+
if _share and not _share_on():
|
|
180
|
+
log.info("share lease expired; closing it")
|
|
181
|
+
await _share_stop()
|
|
182
|
+
|
|
183
|
+
|
|
152
184
|
class RevalidatedStaticFiles(StaticFiles):
|
|
153
185
|
"""Browsers must revalidate (a cheap 304), or they keep running old JS after an upgrade."""
|
|
154
186
|
|
|
@@ -199,8 +231,24 @@ def _denied() -> JSONResponse:
|
|
|
199
231
|
return err("locked" if auth.has_password() else "setup_required", 401)
|
|
200
232
|
|
|
201
233
|
|
|
234
|
+
SECURITY_HEADERS = {
|
|
235
|
+
"X-Frame-Options": "DENY",
|
|
236
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
237
|
+
"X-Content-Type-Options": "nosniff",
|
|
238
|
+
"Referrer-Policy": "no-referrer",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
202
242
|
@app.middleware("http")
|
|
203
243
|
async def guard(request: Request, call_next):
|
|
244
|
+
response = await _guarded(request, call_next)
|
|
245
|
+
response.headers.update(SECURITY_HEADERS)
|
|
246
|
+
if request.url.path.startswith("/api/"):
|
|
247
|
+
response.headers["Cache-Control"] = "no-store" # terminal output, history, devices
|
|
248
|
+
return response
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def _guarded(request: Request, call_next):
|
|
204
252
|
if not _trusted(request):
|
|
205
253
|
return err("forbidden_origin", 403)
|
|
206
254
|
path = request.url.path
|
|
@@ -262,6 +310,8 @@ async def _login_response(request: Request, body: dict, res=None):
|
|
|
262
310
|
token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
|
|
263
311
|
h = auth.session_hash(token)
|
|
264
312
|
_active["h"] = h
|
|
313
|
+
if _via_share(request) and (g := _grant(request)):
|
|
314
|
+
g["session"] = h # revoking this login also voids the grant
|
|
265
315
|
await _close_session_sockets(lambda owner: owner != h, IN_USE)
|
|
266
316
|
res = res or JSONResponse(body)
|
|
267
317
|
res.set_cookie(
|
|
@@ -342,17 +392,64 @@ async def auth_login_link(request: Request):
|
|
|
342
392
|
|
|
343
393
|
@app.post("/api/share")
|
|
344
394
|
async def share_start(request: Request, payload: dict):
|
|
345
|
-
"""Host CLI only: open the gate for an `sd share` tunnel host; returns its
|
|
395
|
+
"""Host CLI only: open the gate for an `sd share` tunnel host; returns its first link."""
|
|
346
396
|
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
347
397
|
return err("host_cli_only", 403)
|
|
398
|
+
if not auth.strong_password():
|
|
399
|
+
return err("weak_password", 409)
|
|
348
400
|
host = str(payload.get("host", "")).lower()
|
|
349
401
|
if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
|
|
350
402
|
return err("invalid_host")
|
|
351
403
|
await _share_stop()
|
|
352
|
-
|
|
353
|
-
_share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
|
|
404
|
+
_share.update(host=host, seen=time.time(), grants={})
|
|
354
405
|
log.info("sharing via %s", host)
|
|
355
|
-
return {"path":
|
|
406
|
+
return {"path": _new_share_link()}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _new_share_link() -> str:
|
|
410
|
+
"""One link at a time: a new one voids the unused old one."""
|
|
411
|
+
token = secrets.token_urlsafe(32)
|
|
412
|
+
_share.update(link=_sha(token), link_until=time.time() + LINK_TTL)
|
|
413
|
+
return f"/share/{token}"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.get("/api/share")
|
|
417
|
+
async def share_state(request: Request):
|
|
418
|
+
"""Host CLI heartbeat: renews the lease and lists devices waiting for approval."""
|
|
419
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
420
|
+
return err("host_cli_only", 403)
|
|
421
|
+
if not _share:
|
|
422
|
+
return {"host": None, "strong_password": auth.strong_password()}
|
|
423
|
+
now = time.time()
|
|
424
|
+
_share["seen"] = now
|
|
425
|
+
grants = _share["grants"]
|
|
426
|
+
for k in [k for k, g in grants.items() if g["state"] == "pending" and now - g["at"] > APPROVAL_TTL]:
|
|
427
|
+
del grants[k]
|
|
428
|
+
pending = [{"id": g["id"], "ip": g["ip"], "agent": g["agent"]} for g in grants.values() if g["state"] == "pending"]
|
|
429
|
+
return {"host": _share["host"], "pending": pending, "strong_password": auth.strong_password()}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.post("/api/share/link")
|
|
433
|
+
async def share_link(request: Request):
|
|
434
|
+
"""Host CLI only: a fresh link for another device on the running share."""
|
|
435
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
436
|
+
return err("host_cli_only", 403)
|
|
437
|
+
if not _share_on():
|
|
438
|
+
return err("not_sharing", 404)
|
|
439
|
+
return {"path": _new_share_link()}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.post("/api/share/decide")
|
|
443
|
+
async def share_decide(request: Request, payload: dict):
|
|
444
|
+
"""Host CLI only: allow or deny a device that opened a link."""
|
|
445
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
446
|
+
return err("host_cli_only", 403)
|
|
447
|
+
g = next((g for g in _share.get("grants", {}).values() if g["id"] == payload.get("id")), None)
|
|
448
|
+
if not g or g["state"] != "pending":
|
|
449
|
+
return err("not_found", 404)
|
|
450
|
+
g["state"] = "ok" if payload.get("allow") is True else "denied"
|
|
451
|
+
log.info("share device %s from %s %s", g["id"], g["ip"], "allowed" if g["state"] == "ok" else "denied")
|
|
452
|
+
return {"state": g["state"]}
|
|
356
453
|
|
|
357
454
|
|
|
358
455
|
@app.delete("/api/share")
|
|
@@ -372,13 +469,57 @@ async def _share_stop() -> None:
|
|
|
372
469
|
await _revoke(gone)
|
|
373
470
|
|
|
374
471
|
|
|
472
|
+
_WAIT_PAGE = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
473
|
+
<title>shelldeck</title><meta name="referrer" content="no-referrer">
|
|
474
|
+
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#09090b;color:#e4e4e7;
|
|
475
|
+
font:16px/1.5 Inter,"Segoe UI",system-ui,sans-serif;text-align:center;padding:24px;box-sizing:border-box}
|
|
476
|
+
p{max-width:28em;margin:.4em auto}b{color:#a78bfa}.m{color:#a1a1aa;font-size:14px}</style>
|
|
477
|
+
<main><p><b>shelldeck</b></p><p id="s">Waiting for the host to allow this device…</p>
|
|
478
|
+
<p class="m" id="m">Answer the prompt in the <code>sd share</code> window on the host.</p></main>
|
|
479
|
+
<script>
|
|
480
|
+
const say = (s, m) => { document.getElementById("s").textContent = s; document.getElementById("m").textContent = m; };
|
|
481
|
+
async function poll() {
|
|
482
|
+
let st = "none";
|
|
483
|
+
try { st = (await (await fetch("/share/status", { cache: "no-store" })).json()).state; } catch {}
|
|
484
|
+
if (st === "ok") return location.replace("/");
|
|
485
|
+
if (st === "denied") return say("The host denied this device.", "Ask for a new link if that was a mistake.");
|
|
486
|
+
if (st !== "pending") return say("This request expired.", "Ask the host for a new link.");
|
|
487
|
+
setTimeout(poll, 2000);
|
|
488
|
+
}
|
|
489
|
+
poll();
|
|
490
|
+
</script>"""
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _share_page(body: str, status: int = 200):
|
|
494
|
+
return HTMLResponse(body, status_code=status, headers={"Cache-Control": "no-store"})
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@app.get("/share/status")
|
|
498
|
+
async def share_status(request: Request):
|
|
499
|
+
"""The waiting page's poll: pending, ok, denied, or none."""
|
|
500
|
+
g = _grant(request) if _via_share(request) else None
|
|
501
|
+
return JSONResponse({"state": g["state"] if g else "none"}, headers={"Cache-Control": "no-store"})
|
|
502
|
+
|
|
503
|
+
|
|
375
504
|
@app.get("/share/{token}")
|
|
376
505
|
async def share_open(request: Request, token: str):
|
|
377
|
-
"""The QR link:
|
|
378
|
-
|
|
506
|
+
"""The QR link: works once, within LINK_TTL. It makes a pending grant that the host must allow in
|
|
507
|
+
the `sd share` window; after that the usual password login takes over."""
|
|
508
|
+
if not _via_share(request):
|
|
379
509
|
return err("link_expired", 403)
|
|
380
|
-
|
|
381
|
-
|
|
510
|
+
if g := _grant(request): # this browser already opened a link (e.g. a reload)
|
|
511
|
+
return RedirectResponse("/", status_code=303) if g["state"] == "ok" else _share_page(_WAIT_PAGE)
|
|
512
|
+
if not _share_link_ok(token):
|
|
513
|
+
return _share_page(_WAIT_PAGE.replace("poll();", 'say("This link has expired or was already used.", "Ask the host for a new one.");'), 403)
|
|
514
|
+
_share["link"] = None # one use
|
|
515
|
+
cookie = secrets.token_urlsafe(32)
|
|
516
|
+
_share["grants"][_sha(cookie)] = {
|
|
517
|
+
"id": secrets.token_hex(4), "ip": _client(request), "agent": request.headers.get("user-agent", "")[:200],
|
|
518
|
+
"at": time.time(), "state": "pending", "session": None,
|
|
519
|
+
}
|
|
520
|
+
log.info("share link opened from %s; waiting for the host", _client(request))
|
|
521
|
+
res = _share_page(_WAIT_PAGE)
|
|
522
|
+
res.set_cookie(SHARE_COOKIE, cookie, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
|
|
382
523
|
return res
|
|
383
524
|
|
|
384
525
|
|
|
@@ -420,7 +561,7 @@ async def devices(request: Request):
|
|
|
420
561
|
"created_at": r["created_at"], "last_seen": r["last_seen"],
|
|
421
562
|
"active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
|
|
422
563
|
})
|
|
423
|
-
return {"devices": rows, "share": _share
|
|
564
|
+
return {"devices": rows, "share": _share["host"] if _share_on() else None}
|
|
424
565
|
|
|
425
566
|
|
|
426
567
|
@app.delete("/api/devices/{device_id}")
|
|
@@ -432,6 +573,9 @@ async def revoke_device(request: Request, device_id: str):
|
|
|
432
573
|
|
|
433
574
|
|
|
434
575
|
async def _revoke(hashes: list[str]) -> dict:
|
|
576
|
+
grants = _share.get("grants", {})
|
|
577
|
+
for k in [k for k, g in grants.items() if g["session"] in hashes]:
|
|
578
|
+
del grants[k] # a revoked share device needs a new link and approval
|
|
435
579
|
for h in hashes:
|
|
436
580
|
auth.end_session(h)
|
|
437
581
|
if _active["h"] == h:
|
|
@@ -124,13 +124,22 @@ def test_share_waits_for_tunnel_then_prints_link_and_cleans_up(monkeypatch, caps
|
|
|
124
124
|
|
|
125
125
|
def api(path, method="GET", payload=None):
|
|
126
126
|
calls.append((method, path))
|
|
127
|
-
return {"has_password": True
|
|
127
|
+
return {"has_password": True, "strong_password": True, "path": "/share/tok"}
|
|
128
128
|
|
|
129
129
|
monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
|
|
130
130
|
monkeypatch.setattr(cli, "ensure_server", lambda: None)
|
|
131
131
|
monkeypatch.setattr(cli, "_health", lambda: "ok")
|
|
132
132
|
monkeypatch.setattr(cli, "_api", api)
|
|
133
|
+
monkeypatch.setattr(cli, "_watch_share", lambda: calls.append("watch"))
|
|
133
134
|
monkeypatch.setattr(cli.subprocess, "Popen", lambda argv, **kw: calls.append(argv[1:4]) or FakeTunnel())
|
|
134
|
-
cli.share()
|
|
135
|
+
cli.share(new_link=False)
|
|
135
136
|
assert "https://abc-def.trycloudflare.com/share/tok" in capsys.readouterr().out
|
|
136
|
-
assert calls[
|
|
137
|
+
assert calls[2:] == [["tunnel", "--no-autoupdate", "--url"], ("POST", "/api/share"), "watch", "terminate", ("DELETE", "/api/share")]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_share_refuses_a_short_password(monkeypatch):
|
|
141
|
+
monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
|
|
142
|
+
monkeypatch.setattr(cli, "ensure_server", lambda: None)
|
|
143
|
+
monkeypatch.setattr(cli, "_api", lambda path, *a: {"has_password": True, "strong_password": False})
|
|
144
|
+
r = runner.invoke(cli.app, ["share"])
|
|
145
|
+
assert r.exit_code == 1 and "12+ characters" in r.output
|
|
@@ -369,28 +369,48 @@ def test_projects_get_distinct_colors(client, tmp_path):
|
|
|
369
369
|
assert client.put("/api/settings", json={"layout_mode": "grid"}).status_code == 400
|
|
370
370
|
|
|
371
371
|
|
|
372
|
-
def
|
|
373
|
-
b =
|
|
374
|
-
b.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
|
|
372
|
+
def _share_setup(b, password="long enough pass"):
|
|
373
|
+
b.post("/api/auth/setup", json={"password": password, "confirm": password})
|
|
375
374
|
cli = {"X-Shelldeck-Token": auth.read_cli_token()}
|
|
376
375
|
tunnel = {"host": "abc-def.trycloudflare.com", "cf-connecting-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9"}
|
|
376
|
+
return cli, tunnel
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _open_link(b, path, tunnel):
|
|
380
|
+
r = b.get(path, headers=tunnel)
|
|
381
|
+
return r, {**tunnel, "cookie": r.headers["set-cookie"].split(";")[0]}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _allow(b, cli, allow=True):
|
|
385
|
+
[p] = b.get("/api/share", headers=cli).json()["pending"]
|
|
386
|
+
return b.post("/api/share/decide", json={"id": p["id"], "allow": allow}, headers=cli)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_share_gate_needs_link_approval_then_password(browser):
|
|
390
|
+
b = browser
|
|
391
|
+
cli, tunnel = _share_setup(b)
|
|
377
392
|
assert b.get("/", headers=tunnel).status_code == 403 # not shared
|
|
378
393
|
assert b.post("/api/share", json={"host": tunnel["host"]}).status_code == 403 # browsers can't open it
|
|
394
|
+
assert b.get("/api/share").status_code == 403
|
|
379
395
|
assert b.post("/api/share", json={"host": "bad host"}, headers=cli).status_code == 400
|
|
380
396
|
path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
|
|
381
397
|
|
|
382
|
-
# the bare tunnel URL and a wrong token get nothing; the link
|
|
398
|
+
# the bare tunnel URL and a wrong token get nothing; the link waits for the host
|
|
383
399
|
assert b.get("/", headers=tunnel).status_code == 403
|
|
384
400
|
assert b.get("/share/wrong", headers=tunnel).status_code == 403
|
|
385
|
-
r = b
|
|
386
|
-
assert r.status_code ==
|
|
387
|
-
|
|
401
|
+
r, gate = _open_link(b, path, tunnel)
|
|
402
|
+
assert r.status_code == 200 and "secure" in r.headers["set-cookie"].lower() and "Waiting" in r.text
|
|
403
|
+
assert b.get("/", headers=gate).status_code == 403 # pending
|
|
404
|
+
assert b.get("/share/status", headers=gate).json() == {"state": "pending"}
|
|
405
|
+
assert b.get(path, headers=tunnel).status_code == 403 # the link worked once
|
|
406
|
+
assert _allow(b, cli).json() == {"state": "ok"}
|
|
407
|
+
assert b.get("/share/status", headers=gate).json() == {"state": "ok"}
|
|
388
408
|
assert b.get("/", headers=gate).status_code == 200
|
|
389
409
|
|
|
390
410
|
# the password is still required, and tunnel visitors are remote
|
|
391
411
|
assert b.get("/api/projects", headers=gate).json() == {"error": "locked"}
|
|
392
412
|
assert b.post("/api/auth/login-link", headers=gate).status_code == 403
|
|
393
|
-
r = b.post("/api/auth/login", json={"password": "
|
|
413
|
+
r = b.post("/api/auth/login", json={"password": "long enough pass"}, headers={**gate, "origin": "https://" + tunnel["host"]})
|
|
394
414
|
assert r.status_code == 200
|
|
395
415
|
login = r.headers["set-cookie"].split(";")[0]
|
|
396
416
|
authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
|
|
@@ -405,6 +425,64 @@ def test_share_gate_needs_link_then_password(browser):
|
|
|
405
425
|
assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
|
|
406
426
|
|
|
407
427
|
|
|
428
|
+
def test_share_denied_expired_and_new_link(browser, monkeypatch):
|
|
429
|
+
b = browser
|
|
430
|
+
cli, tunnel = _share_setup(b)
|
|
431
|
+
path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
|
|
432
|
+
_, gate = _open_link(b, path, tunnel)
|
|
433
|
+
assert _allow(b, cli, allow=False).json() == {"state": "denied"}
|
|
434
|
+
assert b.get("/", headers=gate).status_code == 403
|
|
435
|
+
assert b.get("/share/status", headers=gate).json() == {"state": "denied"}
|
|
436
|
+
|
|
437
|
+
# a new link replaces the old one, and expires unopened
|
|
438
|
+
old = b.post("/api/share/link", headers=cli).json()["path"]
|
|
439
|
+
new = b.post("/api/share/link", headers=cli).json()["path"]
|
|
440
|
+
assert b.get(old, headers=tunnel).status_code == 403
|
|
441
|
+
now = time.time()
|
|
442
|
+
monkeypatch.setattr(server.time, "time", lambda: now + server.LINK_TTL + 1)
|
|
443
|
+
b.get("/api/share", headers=cli) # heartbeat keeps the share itself alive
|
|
444
|
+
assert b.get(new, headers=tunnel).status_code == 403
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_share_lease_expires_without_heartbeat(browser, monkeypatch):
|
|
448
|
+
b = browser
|
|
449
|
+
cli, tunnel = _share_setup(b)
|
|
450
|
+
path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
|
|
451
|
+
_, gate = _open_link(b, path, tunnel)
|
|
452
|
+
_allow(b, cli)
|
|
453
|
+
assert b.get("/", headers=gate).status_code == 200
|
|
454
|
+
now = time.time()
|
|
455
|
+
monkeypatch.setattr(server.time, "time", lambda: now + server.SHARE_LEASE + 1)
|
|
456
|
+
assert b.get("/", headers=gate).status_code == 403 # `sd share` died: the tunnel host is refused
|
|
457
|
+
assert b.get("/api/devices").json()["share"] is None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_share_revoked_device_loses_its_grant(browser):
|
|
461
|
+
b = browser
|
|
462
|
+
cli, tunnel = _share_setup(b)
|
|
463
|
+
path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
|
|
464
|
+
_, gate = _open_link(b, path, tunnel)
|
|
465
|
+
_allow(b, cli)
|
|
466
|
+
phone = TestClient(server.app)
|
|
467
|
+
r = phone.post("/api/auth/login", json={"password": "long enough pass"}, headers={**gate, "origin": "https://" + tunnel["host"]})
|
|
468
|
+
h = auth.session_hash(r.cookies[auth.COOKIE])
|
|
469
|
+
assert b.post("/api/auth/login", json={"password": "long enough pass"}).status_code == 200 # host takes over
|
|
470
|
+
assert b.delete(f"/api/devices/{h}").json() == {"revoked": 1}
|
|
471
|
+
assert b.get("/", headers=gate).status_code == 403
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_share_needs_a_long_password(browser):
|
|
475
|
+
cli, tunnel = _share_setup(browser, password="short123")
|
|
476
|
+
assert browser.get("/api/share", headers=cli).json()["strong_password"] is False
|
|
477
|
+
assert browser.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json() == {"error": "weak_password"}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_security_headers(browser):
|
|
481
|
+
r = browser.get("/api/auth/status")
|
|
482
|
+
assert r.headers["x-frame-options"] == "DENY" and r.headers["cache-control"] == "no-store"
|
|
483
|
+
assert "frame-ancestors 'none'" in browser.get("/").headers["content-security-policy"]
|
|
484
|
+
|
|
485
|
+
|
|
408
486
|
def test_devices_one_active_takeover_and_revoke(browser, tmp_path):
|
|
409
487
|
a = browser
|
|
410
488
|
a.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|