shelldeck 0.0.1__tar.gz → 0.0.2__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.1 → shelldeck-0.0.2}/CHANGELOG.md +15 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/PKG-INFO +15 -2
- {shelldeck-0.0.1 → shelldeck-0.0.2}/README.md +13 -1
- {shelldeck-0.0.1 → shelldeck-0.0.2}/pyproject.toml +2 -1
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/cli.py +46 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/server.py +71 -7
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/app.css +69 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/app.js +36 -1
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/index.html +6 -1
- {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_cli.py +24 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_shelldeck.py +34 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/.gitignore +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/LICENSE +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/__init__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/__main__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/addons/__init__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/addons/fast_context.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/auth.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/db.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/gitgraph.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/bash.sh +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/shelldeck.fish +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/shelldeck.ps1 +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/zsh/.zshenv +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/integration/zsh/.zshrc +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/pty.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/runner.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/scheduler.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/shells.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/gitgraph.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/history.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-192.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-32.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon-512.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/icon.svg +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/manifest.webmanifest +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/monitor.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/ui.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-fit.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-search.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-serialize.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-web-links.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/addon-webgl.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/xterm.css +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/vendor/xterm.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/static/views.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/shelldeck/stats.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.2}/tests/test_gitgraph.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
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.2] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `sd share`: reach shelldeck from another device over an HTTPS Cloudflare quick tunnel (needs `cloudflared`, no account). It prints a QR code and a one-per-share link; the tunnel address is refused without that link, and your password is still required. Ctrl+C stops sharing and signs those browsers out.
|
|
10
|
+
- Phone key bar on touch screens: Esc, Tab, a sticky Ctrl, arrows and `| ~ / -`.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Touch devices use xterm's DOM renderer (WebGL came up blank on high-DPI phones).
|
|
15
|
+
- History rows and the Settings dialog stack into one column on narrow screens.
|
|
16
|
+
- Requests through a proxy that sets `CF-Connecting-IP` count as remote.
|
|
17
|
+
- CI actions updated: checkout v7, setup-uv v7, upload-artifact v7, download-artifact v8.
|
|
18
|
+
|
|
5
19
|
## [0.0.1] - 2026-09-28
|
|
6
20
|
|
|
7
21
|
First public release.
|
|
@@ -18,4 +32,5 @@ First public release.
|
|
|
18
32
|
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
|
|
19
33
|
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
|
|
20
34
|
|
|
35
|
+
[0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
|
|
21
36
|
[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.2
|
|
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
|
|
@@ -28,6 +28,7 @@ Requires-Dist: fastapi>=0.110
|
|
|
28
28
|
Requires-Dist: psutil>=5.9
|
|
29
29
|
Requires-Dist: pywinpty>=2.0; sys_platform == 'win32'
|
|
30
30
|
Requires-Dist: rich>=13
|
|
31
|
+
Requires-Dist: segno>=1.6
|
|
31
32
|
Requires-Dist: typer>=0.12
|
|
32
33
|
Requires-Dist: uvicorn[standard]>=0.29
|
|
33
34
|
Description-Content-Type: text/markdown
|
|
@@ -43,7 +44,7 @@ Description-Content-Type: text/markdown
|
|
|
43
44
|
Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
|
|
44
45
|
in split or free-floating panes that survive restarts.
|
|
45
46
|
|
|
46
|
-
[](https://pypi.org/project/shelldeck/)
|
|
47
48
|
[](https://pypi.org/project/shelldeck/)
|
|
48
49
|
[](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
|
|
49
50
|
[](#install)
|
|
@@ -194,6 +195,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
194
195
|
sd task list|add|move|delete|alarms
|
|
195
196
|
sd serve [--host H] run the server in the foreground
|
|
196
197
|
sd stop stop the background server (closes all terminals)
|
|
198
|
+
sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
|
|
197
199
|
sd login-link emergency: one-time login URL (host only)
|
|
198
200
|
sd reset-password emergency: forget the password (host only)
|
|
199
201
|
```
|
|
@@ -215,6 +217,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
|
|
|
215
217
|
ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
|
|
216
218
|
```
|
|
217
219
|
|
|
220
|
+
**`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.
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **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.
|
|
227
|
+
- **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
|
|
228
|
+
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
229
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
|
|
230
|
+
|
|
218
231
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
219
232
|
|
|
220
233
|
```sh
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
Real shells (PowerShell, cmd, Git Bash, WSL, bash, zsh, fish) grouped by project,<br>
|
|
10
10
|
in split or free-floating panes that survive restarts.
|
|
11
11
|
|
|
12
|
-
[](https://pypi.org/project/shelldeck/)
|
|
13
13
|
[](https://pypi.org/project/shelldeck/)
|
|
14
14
|
[](https://github.com/codejunction/shelldeck/actions/workflows/ci.yml)
|
|
15
15
|
[](#install)
|
|
@@ -160,6 +160,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
160
160
|
sd task list|add|move|delete|alarms
|
|
161
161
|
sd serve [--host H] run the server in the foreground
|
|
162
162
|
sd stop stop the background server (closes all terminals)
|
|
163
|
+
sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
|
|
163
164
|
sd login-link emergency: one-time login URL (host only)
|
|
164
165
|
sd reset-password emergency: forget the password (host only)
|
|
165
166
|
```
|
|
@@ -181,6 +182,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
|
|
|
181
182
|
ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
|
|
182
183
|
```
|
|
183
184
|
|
|
185
|
+
**`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.
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **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.
|
|
192
|
+
- **Two locks.** The random `trycloudflare.com` address alone is refused; only the printed link (a one-per-share token made on the host) lets a browser in, and then it still needs your password. A password must exist before sharing.
|
|
193
|
+
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
|
|
194
|
+
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it. Each `sd share` gets a new address and link.
|
|
195
|
+
|
|
184
196
|
**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:
|
|
185
197
|
|
|
186
198
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "shelldeck"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.2"
|
|
4
4
|
description = "Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"rich>=13",
|
|
30
30
|
"apscheduler>=3.10,<4",
|
|
31
31
|
"psutil>=5.9",
|
|
32
|
+
"segno>=1.6",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
[project.urls]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import shutil
|
|
4
5
|
import ssl
|
|
5
6
|
import subprocess
|
|
@@ -301,6 +302,51 @@ def login_link():
|
|
|
301
302
|
typer.echo("One use, valid for 5 minutes. Through an SSH tunnel, open it on your machine as-is.")
|
|
302
303
|
|
|
303
304
|
|
|
305
|
+
def _show_share(url: str) -> None:
|
|
306
|
+
import segno
|
|
307
|
+
|
|
308
|
+
if sys.stdout.isatty():
|
|
309
|
+
segno.make(url, error="l").terminal(compact=(sys.stdout.encoding or "").lower().startswith("utf"))
|
|
310
|
+
typer.echo(f"\n {url}\n")
|
|
311
|
+
typer.echo(" Scan or open it on the other device, then log in with your shelldeck password.")
|
|
312
|
+
typer.echo(" Without this link the tunnel URL is refused. Ctrl+C stops sharing and signs those browsers out.")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command()
|
|
316
|
+
def share():
|
|
317
|
+
"""Reach this shelldeck from another device: HTTPS Cloudflare quick tunnel, QR link, then your password."""
|
|
318
|
+
exe = shutil.which("cloudflared")
|
|
319
|
+
if not exe:
|
|
320
|
+
hint = "winget install Cloudflare.cloudflared" if sys.platform == "win32" else "see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
321
|
+
typer.echo(f"cloudflared is not installed ({hint})", err=True)
|
|
322
|
+
raise typer.Exit(1)
|
|
323
|
+
ensure_server()
|
|
324
|
+
if not _api("/api/auth/status").get("has_password"):
|
|
325
|
+
typer.echo("Set a password first: open shelldeck on this machine (`sd`), then run `sd share` again.", err=True)
|
|
326
|
+
raise typer.Exit(1)
|
|
327
|
+
# the tunnel ends at our own loopback server, whose certificate (if any) is self-signed
|
|
328
|
+
cmd = [exe, "tunnel", "--no-autoupdate", "--url", _url()] + (["--no-tls-verify"] if _scheme() == "https" else [])
|
|
329
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, errors="replace")
|
|
330
|
+
host = shared = None
|
|
331
|
+
try:
|
|
332
|
+
for line in proc.stderr: # cloudflared logs to stderr: the URL first, then each edge connection
|
|
333
|
+
if not host and (m := re.search(r"https://([a-z0-9-]+\.trycloudflare\.com)", line)):
|
|
334
|
+
host = m.group(1)
|
|
335
|
+
elif host and not shared and "Registered tunnel connection" in line:
|
|
336
|
+
shared = f"https://{host}" + _api("/api/share", "POST", {"host": host})["path"]
|
|
337
|
+
_show_share(shared)
|
|
338
|
+
except KeyboardInterrupt:
|
|
339
|
+
pass
|
|
340
|
+
finally:
|
|
341
|
+
proc.terminate()
|
|
342
|
+
if shared and _health() == "ok":
|
|
343
|
+
_api("/api/share", "DELETE")
|
|
344
|
+
if not shared:
|
|
345
|
+
typer.echo("cloudflared exited without a tunnel URL; run it by hand to see why.", err=True)
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
typer.echo("sharing stopped")
|
|
348
|
+
|
|
349
|
+
|
|
304
350
|
@app.command()
|
|
305
351
|
def stop():
|
|
306
352
|
"""Stop the background server (closes all terminals)."""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import hashlib
|
|
2
3
|
import hmac
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
@@ -67,10 +68,28 @@ def err(code: str, status: int = 400) -> JSONResponse:
|
|
|
67
68
|
return JSONResponse({"error": code}, status_code=status)
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
# `sd share`: the tunnel's public hostname, sha256 of its link token, and the logins made through it
|
|
72
|
+
_share: dict = {}
|
|
73
|
+
SHARE_COOKIE = "sd_share"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _share_key(token: str | None) -> bool:
|
|
77
|
+
return bool(token and _share) and hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), _share["key"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _via_share(conn) -> bool:
|
|
81
|
+
return bool(_share) and conn.headers.get("host", "").rsplit(":", 1)[0] == _share["host"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _trusted(conn) -> bool:
|
|
85
|
+
"""Same-origin check. Browsers always send Origin cross-site; the CLI sends none.
|
|
86
|
+
The share tunnel's host also needs the cookie its link sets (or to be opening that link)."""
|
|
87
|
+
headers = conn.headers
|
|
72
88
|
host = headers.get("host", "")
|
|
73
|
-
if
|
|
89
|
+
if _via_share(conn):
|
|
90
|
+
if not (conn.url.path.startswith("/share/") or _share_key(conn.cookies.get(SHARE_COOKIE))):
|
|
91
|
+
return False
|
|
92
|
+
elif ALLOWED_HOSTS is not None and host.rsplit(":", 1)[0] not in ALLOWED_HOSTS:
|
|
74
93
|
return False
|
|
75
94
|
origin = headers.get("origin")
|
|
76
95
|
return not origin or urlsplit(origin).netloc == host
|
|
@@ -152,7 +171,8 @@ def _client(conn) -> str:
|
|
|
152
171
|
|
|
153
172
|
def _is_local(conn) -> bool:
|
|
154
173
|
"""A request from the host itself (not through a reverse proxy)."""
|
|
155
|
-
|
|
174
|
+
proxied = "x-forwarded-for" in conn.headers or "cf-connecting-ip" in conn.headers # e.g. `sd share`
|
|
175
|
+
return _client(conn) in LOOPBACK and not proxied
|
|
156
176
|
|
|
157
177
|
|
|
158
178
|
def _who(conn) -> tuple[str, str | None] | None:
|
|
@@ -169,7 +189,7 @@ def _denied() -> JSONResponse:
|
|
|
169
189
|
|
|
170
190
|
@app.middleware("http")
|
|
171
191
|
async def guard(request: Request, call_next):
|
|
172
|
-
if not _trusted(request
|
|
192
|
+
if not _trusted(request):
|
|
173
193
|
return err("forbidden_origin", 403)
|
|
174
194
|
path = request.url.path
|
|
175
195
|
request.state.session = None
|
|
@@ -217,6 +237,8 @@ async def shutdown():
|
|
|
217
237
|
def _login_response(request: Request, body: dict, res=None):
|
|
218
238
|
"""Start a login session: cookie on `res` (JSON body by default)."""
|
|
219
239
|
token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}")
|
|
240
|
+
if _via_share(request):
|
|
241
|
+
_share["sessions"].add(auth.session_hash(token))
|
|
220
242
|
res = res or JSONResponse(body)
|
|
221
243
|
res.set_cookie(
|
|
222
244
|
auth.COOKIE, token, httponly=True, samesite="strict", path="/",
|
|
@@ -293,6 +315,48 @@ async def auth_login_link(request: Request):
|
|
|
293
315
|
return {"path": f"/api/auth/link/{code}"}
|
|
294
316
|
|
|
295
317
|
|
|
318
|
+
@app.post("/api/share")
|
|
319
|
+
async def share_start(request: Request, payload: dict):
|
|
320
|
+
"""Host CLI only: open the gate for an `sd share` tunnel host; returns its one link."""
|
|
321
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
322
|
+
return err("host_cli_only", 403)
|
|
323
|
+
host = str(payload.get("host", "")).lower()
|
|
324
|
+
if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
|
|
325
|
+
return err("invalid_host")
|
|
326
|
+
await _share_stop()
|
|
327
|
+
token = secrets.token_urlsafe(32)
|
|
328
|
+
_share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest(), sessions=set())
|
|
329
|
+
log.info("sharing via %s", host)
|
|
330
|
+
return {"path": f"/share/{token}"}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.delete("/api/share")
|
|
334
|
+
async def share_stop(request: Request):
|
|
335
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
336
|
+
return err("host_cli_only", 403)
|
|
337
|
+
await _share_stop()
|
|
338
|
+
return {"status": "ok"}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _share_stop() -> None:
|
|
342
|
+
"""Close the gate and sign out every browser that logged in through the tunnel."""
|
|
343
|
+
sessions = _share.get("sessions", set())
|
|
344
|
+
_share.clear()
|
|
345
|
+
for h in sessions:
|
|
346
|
+
auth.end_session(h)
|
|
347
|
+
await _close_session_sockets(lambda owner: owner in sessions)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/share/{token}")
|
|
351
|
+
async def share_open(request: Request, token: str):
|
|
352
|
+
"""The QR link: sets the tunnel cookie, then the usual password login takes over."""
|
|
353
|
+
if not (_via_share(request) and _share_key(token)):
|
|
354
|
+
return err("link_expired", 403)
|
|
355
|
+
res = RedirectResponse("/", status_code=303) # drops the token from the address bar
|
|
356
|
+
res.set_cookie(SHARE_COOKIE, token, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
|
|
357
|
+
return res
|
|
358
|
+
|
|
359
|
+
|
|
296
360
|
@app.get("/api/auth/link/{code}")
|
|
297
361
|
async def auth_use_link(request: Request, code: str):
|
|
298
362
|
expiry = _login_links.pop(code, 0)
|
|
@@ -825,7 +889,7 @@ def _attach(session: dict, rows: int, cols: int) -> None:
|
|
|
825
889
|
|
|
826
890
|
@app.websocket("/ws/alarms")
|
|
827
891
|
async def alarms_ws(ws: WebSocket):
|
|
828
|
-
who = _who(ws) if _trusted(ws
|
|
892
|
+
who = _who(ws) if _trusted(ws) else None
|
|
829
893
|
if not who:
|
|
830
894
|
await ws.close(code=1008)
|
|
831
895
|
return
|
|
@@ -846,7 +910,7 @@ async def alarms_ws(ws: WebSocket):
|
|
|
846
910
|
|
|
847
911
|
@app.websocket("/ws/{session_id}")
|
|
848
912
|
async def terminal_ws(ws: WebSocket, session_id: str):
|
|
849
|
-
who = _who(ws) if _trusted(ws
|
|
913
|
+
who = _who(ws) if _trusted(ws) else None
|
|
850
914
|
if not who:
|
|
851
915
|
await ws.close(code=1008)
|
|
852
916
|
return
|
|
@@ -1700,7 +1700,42 @@ textarea:focus {
|
|
|
1700
1700
|
}
|
|
1701
1701
|
}
|
|
1702
1702
|
|
|
1703
|
+
#keybar {
|
|
1704
|
+
display: none;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1703
1707
|
@media (pointer: coarse) {
|
|
1708
|
+
#keybar {
|
|
1709
|
+
display: flex;
|
|
1710
|
+
gap: 4px;
|
|
1711
|
+
padding: 4px 6px calc(4px + env(safe-area-inset-bottom));
|
|
1712
|
+
overflow-x: auto;
|
|
1713
|
+
border-top: 1px solid var(--border);
|
|
1714
|
+
background: var(--bg);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
#main:has(#view-terminals[hidden]) #keybar,
|
|
1718
|
+
#main:has(#layout:empty) #keybar {
|
|
1719
|
+
display: none;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
#keybar button {
|
|
1723
|
+
flex: 1 0 auto;
|
|
1724
|
+
min-width: 40px;
|
|
1725
|
+
height: 36px;
|
|
1726
|
+
border: 1px solid var(--border);
|
|
1727
|
+
border-radius: 6px;
|
|
1728
|
+
background: var(--panel);
|
|
1729
|
+
color: var(--text);
|
|
1730
|
+
font: 500 13px "Cascadia Mono", Consolas, monospace;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
#keybar button[aria-pressed="true"] {
|
|
1734
|
+
background: var(--accent);
|
|
1735
|
+
border-color: var(--accent);
|
|
1736
|
+
color: var(--primary-fg);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1704
1739
|
.proj-row,
|
|
1705
1740
|
.sess-row,
|
|
1706
1741
|
.sb-link,
|
|
@@ -2286,3 +2321,37 @@ textarea:focus {
|
|
|
2286
2321
|
color: var(--accent);
|
|
2287
2322
|
border-radius: 2px;
|
|
2288
2323
|
}
|
|
2324
|
+
|
|
2325
|
+
/* ------------------------------------------------------------ phones */
|
|
2326
|
+
|
|
2327
|
+
@media (max-width: 560px) {
|
|
2328
|
+
.field-row {
|
|
2329
|
+
grid-template-columns: minmax(0, 1fr);
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/* history rows stack: command on top, then time / exit / duration, then actions */
|
|
2333
|
+
.hist-table tr {
|
|
2334
|
+
display: grid;
|
|
2335
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
2336
|
+
align-items: center;
|
|
2337
|
+
column-gap: 10px;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
.hist-table td {
|
|
2341
|
+
border: 0 !important;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
.hist-table tr + tr {
|
|
2345
|
+
border-top: 1px solid var(--border);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
.hist-table .hist-cmd {
|
|
2349
|
+
grid-column: 1 / -1;
|
|
2350
|
+
grid-row: 1;
|
|
2351
|
+
max-width: none;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
.hist-table .hist-actions {
|
|
2355
|
+
grid-column: 1 / -1;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
@@ -197,6 +197,7 @@ class Term {
|
|
|
197
197
|
this.replaying = false;
|
|
198
198
|
this.xterm.onData((data) => {
|
|
199
199
|
if (this.replaying) return;
|
|
200
|
+
data = withCtrl(data);
|
|
200
201
|
this.track(data);
|
|
201
202
|
this.send({ type: "input", data });
|
|
202
203
|
});
|
|
@@ -378,7 +379,8 @@ class Term {
|
|
|
378
379
|
container.append(this.el);
|
|
379
380
|
if (!this.opened) {
|
|
380
381
|
this.xterm.open(this.el);
|
|
381
|
-
|
|
382
|
+
// phones: WebGL canvases came up blank at high pixel ratios; the DOM renderer is fine there
|
|
383
|
+
if (!matchMedia("(pointer: coarse)").matches) try {
|
|
382
384
|
const gl = new WebglAddon.WebglAddon();
|
|
383
385
|
gl.onContextLoss(() => gl.dispose());
|
|
384
386
|
this.xterm.loadAddon(gl);
|
|
@@ -1777,9 +1779,42 @@ function connectAlarms() {
|
|
|
1777
1779
|
};
|
|
1778
1780
|
}
|
|
1779
1781
|
|
|
1782
|
+
// ----------------------------------------------------------------- phone keys
|
|
1783
|
+
|
|
1784
|
+
// keys a phone keyboard lacks; Ctrl applies to the next typed letter
|
|
1785
|
+
const KEYS = { esc: "", tab: " ", up: "[A", down: "[B", right: "[C", left: "[D" };
|
|
1786
|
+
let ctrlNext = false;
|
|
1787
|
+
|
|
1788
|
+
function setCtrl(on) {
|
|
1789
|
+
ctrlNext = on;
|
|
1790
|
+
$("#keybar [data-key=ctrl]").setAttribute("aria-pressed", String(on));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function withCtrl(data) {
|
|
1794
|
+
if (!ctrlNext || data.length !== 1) return data;
|
|
1795
|
+
setCtrl(false);
|
|
1796
|
+
const c = data.toUpperCase().charCodeAt(0);
|
|
1797
|
+
return c >= 64 && c < 96 ? String.fromCharCode(c - 64) : data;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function wireKeybar() {
|
|
1801
|
+
const bar = $("#keybar");
|
|
1802
|
+
bar.addEventListener("pointerdown", (e) => e.preventDefault()); // keep the soft keyboard open
|
|
1803
|
+
bar.addEventListener("click", (e) => {
|
|
1804
|
+
const k = e.target.closest("[data-key]")?.dataset.key;
|
|
1805
|
+
const t = S.terms.get(S.focused);
|
|
1806
|
+
if (!k || !t) return;
|
|
1807
|
+
if (k === "ctrl") return setCtrl(!ctrlNext);
|
|
1808
|
+
const data = withCtrl(KEYS[k] ?? k);
|
|
1809
|
+
t.track(data);
|
|
1810
|
+
t.send({ type: "input", data });
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1780
1814
|
// ----------------------------------------------------------------- init
|
|
1781
1815
|
|
|
1782
1816
|
function wireGlobal() {
|
|
1817
|
+
wireKeybar();
|
|
1783
1818
|
document.addEventListener("keydown", onKey, true);
|
|
1784
1819
|
document.addEventListener("click", (e) => {
|
|
1785
1820
|
const a = e.target.closest("[data-action]");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en" data-theme="dark">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
|
6
6
|
<meta name="color-scheme" content="dark light" />
|
|
7
7
|
<title>shelldeck</title>
|
|
8
8
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
|
|
@@ -88,6 +88,11 @@
|
|
|
88
88
|
<section id="view-tasks" class="view page" hidden></section>
|
|
89
89
|
<section id="view-monitor" class="view page" hidden></section>
|
|
90
90
|
<section id="view-history" class="view page" hidden></section>
|
|
91
|
+
<div id="keybar" aria-label="Terminal keys">
|
|
92
|
+
<button data-key="esc">Esc</button><button data-key="tab">Tab</button><button data-key="ctrl" aria-pressed="false">Ctrl</button>
|
|
93
|
+
<button data-key="left" aria-label="Left">←</button><button data-key="up" aria-label="Up">↑</button><button data-key="down" aria-label="Down">↓</button><button data-key="right" aria-label="Right">→</button>
|
|
94
|
+
<button data-key="|">|</button><button data-key="~">~</button><button data-key="/">/</button><button data-key="-">-</button>
|
|
95
|
+
</div>
|
|
91
96
|
</main>
|
|
92
97
|
</div>
|
|
93
98
|
|
|
@@ -110,3 +110,27 @@ def test_search_formats(codebase):
|
|
|
110
110
|
def test_search_rejects_bad_format(codebase):
|
|
111
111
|
r = runner.invoke(cli.app, ["search", "x", "--root", str(codebase), "-f", "xml"])
|
|
112
112
|
assert r.exit_code == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_share_waits_for_tunnel_then_prints_link_and_cleans_up(monkeypatch, capsys):
|
|
116
|
+
calls = []
|
|
117
|
+
|
|
118
|
+
class FakeTunnel:
|
|
119
|
+
stderr = iter([
|
|
120
|
+
"INF | https://abc-def.trycloudflare.com |\n",
|
|
121
|
+
"INF Registered tunnel connection connIndex=0\n",
|
|
122
|
+
])
|
|
123
|
+
terminate = lambda self: calls.append("terminate") # noqa: E731
|
|
124
|
+
|
|
125
|
+
def api(path, method="GET", payload=None):
|
|
126
|
+
calls.append((method, path))
|
|
127
|
+
return {"has_password": True} if path == "/api/auth/status" else {"path": "/share/tok"}
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(cli.shutil, "which", lambda name: "cloudflared")
|
|
130
|
+
monkeypatch.setattr(cli, "ensure_server", lambda: None)
|
|
131
|
+
monkeypatch.setattr(cli, "_health", lambda: "ok")
|
|
132
|
+
monkeypatch.setattr(cli, "_api", api)
|
|
133
|
+
monkeypatch.setattr(cli.subprocess, "Popen", lambda argv, **kw: calls.append(argv[1:4]) or FakeTunnel())
|
|
134
|
+
cli.share()
|
|
135
|
+
assert "https://abc-def.trycloudflare.com/share/tok" in capsys.readouterr().out
|
|
136
|
+
assert calls[1:] == [["tunnel", "--no-autoupdate", "--url"], ("POST", "/api/share"), "terminate", ("DELETE", "/api/share")]
|
|
@@ -366,3 +366,37 @@ def test_projects_get_distinct_colors(client, tmp_path):
|
|
|
366
366
|
assert colors == [0, 1, 2]
|
|
367
367
|
assert client.put("/api/settings", json={"layout_mode": "free"}).json()["layout_mode"] == "free"
|
|
368
368
|
assert client.put("/api/settings", json={"layout_mode": "grid"}).status_code == 400
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_share_gate_needs_link_then_password(browser):
|
|
372
|
+
b = browser
|
|
373
|
+
b.post("/api/auth/setup", json={"password": "longenough", "confirm": "longenough"})
|
|
374
|
+
cli = {"X-Shelldeck-Token": auth.read_cli_token()}
|
|
375
|
+
tunnel = {"host": "abc-def.trycloudflare.com", "cf-connecting-ip": "203.0.113.9", "x-forwarded-for": "203.0.113.9"}
|
|
376
|
+
assert b.get("/", headers=tunnel).status_code == 403 # not shared
|
|
377
|
+
assert b.post("/api/share", json={"host": tunnel["host"]}).status_code == 403 # browsers can't open it
|
|
378
|
+
assert b.post("/api/share", json={"host": "bad host"}, headers=cli).status_code == 400
|
|
379
|
+
path = b.post("/api/share", json={"host": tunnel["host"]}, headers=cli).json()["path"]
|
|
380
|
+
|
|
381
|
+
# the bare tunnel URL and a wrong token get nothing; the link sets the gate cookie
|
|
382
|
+
assert b.get("/", headers=tunnel).status_code == 403
|
|
383
|
+
assert b.get("/share/wrong", headers=tunnel).status_code == 403
|
|
384
|
+
r = b.get(path, headers=tunnel, follow_redirects=False)
|
|
385
|
+
assert r.status_code == 303 and r.headers["location"] == "/" and "secure" in r.headers["set-cookie"].lower()
|
|
386
|
+
gate = {**tunnel, "cookie": f"{server.SHARE_COOKIE}={path.rsplit('/', 1)[1]}"}
|
|
387
|
+
assert b.get("/", headers=gate).status_code == 200
|
|
388
|
+
|
|
389
|
+
# the password is still required, and tunnel visitors are remote
|
|
390
|
+
assert b.get("/api/projects", headers=gate).json() == {"error": "locked"}
|
|
391
|
+
assert b.post("/api/auth/login-link", headers=gate).status_code == 403
|
|
392
|
+
r = b.post("/api/auth/login", json={"password": "longenough"}, headers={**gate, "origin": "https://" + tunnel["host"]})
|
|
393
|
+
assert r.status_code == 200
|
|
394
|
+
login = r.headers["set-cookie"].split(";")[0]
|
|
395
|
+
authed = {**tunnel, "cookie": f"{gate['cookie']}; {login}"}
|
|
396
|
+
assert b.get("/api/projects", headers=authed).status_code == 200
|
|
397
|
+
|
|
398
|
+
# stopping closes the gate and signs out tunnel logins
|
|
399
|
+
assert b.delete("/api/share", headers=cli).status_code == 200
|
|
400
|
+
assert b.get("/", headers=authed).status_code == 403
|
|
401
|
+
auth._cache.clear()
|
|
402
|
+
assert b.get("/api/projects", headers={"cookie": login}).status_code == 401
|
|
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
|