shelldeck 0.0.1__tar.gz → 0.0.3__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/CHANGELOG.md +53 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/PKG-INFO +18 -3
- {shelldeck-0.0.1 → shelldeck-0.0.3}/README.md +16 -2
- {shelldeck-0.0.1 → shelldeck-0.0.3}/pyproject.toml +2 -1
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/auth.py +11 -2
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/cli.py +46 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/db.py +23 -4
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/server.py +151 -15
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/app.css +114 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/app.js +50 -7
- shelldeck-0.0.3/shelldeck/static/devices.js +76 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/index.html +8 -1
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/ui.js +2 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/views.js +2 -1
- {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_cli.py +24 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_shelldeck.py +75 -0
- shelldeck-0.0.1/CHANGELOG.md +0 -21
- {shelldeck-0.0.1 → shelldeck-0.0.3}/.gitignore +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/LICENSE +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/__init__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/__main__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/addons/__init__.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/addons/fast_context.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/gitgraph.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/bash.sh +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.fish +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/shelldeck.ps1 +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshenv +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/integration/zsh/.zshrc +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/pty.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/runner.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/scheduler.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/shells.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/gitgraph.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/history.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-192.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-32.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon-512.png +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/icon.svg +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/manifest.webmanifest +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/monitor.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-fit.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-search.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-serialize.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-web-links.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/addon-webgl.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.css +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/static/vendor/xterm.js +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/shelldeck/stats.py +0 -0
- {shelldeck-0.0.1 → shelldeck-0.0.3}/tests/test_gitgraph.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## [0.0.3] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Devices page: every signed-in browser with where it came from (this machine, network, or a share), when it signed in, last activity, and whether it is in use; revoke one or sign out all others.
|
|
10
|
+
- One device at a time: other browsers stay signed in but idle; logging in on one takes over and shows the others an "In use on another device" screen.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Logging in again in the same browser replaces its old login instead of adding one.
|
|
15
|
+
- Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Re-running the one-line installer now upgrades an existing install to the latest release.
|
|
20
|
+
|
|
21
|
+
## [0.0.2] - 2026-09-29
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `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.
|
|
26
|
+
- Phone key bar on touch screens: Esc, Tab, a sticky Ctrl, arrows and `| ~ / -`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Touch devices use xterm's DOM renderer (WebGL came up blank on high-DPI phones).
|
|
31
|
+
- History rows and the Settings dialog stack into one column on narrow screens.
|
|
32
|
+
- Requests through a proxy that sets `CF-Connecting-IP` count as remote.
|
|
33
|
+
- CI actions updated: checkout v7, setup-uv v7, upload-artifact v7, download-artifact v8.
|
|
34
|
+
|
|
35
|
+
## [0.0.1] - 2026-09-28
|
|
36
|
+
|
|
37
|
+
First public release.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- Projects sidebar with real shells per project: pwsh, PowerShell, cmd, Git Bash and WSL on Windows; every shell in `/etc/shells` on Linux.
|
|
42
|
+
- Tiled split layout and a free-floating window layout, both persisted.
|
|
43
|
+
- Shell integration (prompts, exit codes, current folder) for pwsh, PowerShell, cmd, bash, zsh and fish.
|
|
44
|
+
- Session restore across restarts, reconnect with scrollback replay, and search across every terminal's output.
|
|
45
|
+
- Git graph per project with branch and commit checkout.
|
|
46
|
+
- Command history, bookmarks, cron scheduler, task board with reminders, and a task manager with CPU, RAM and GPU usage.
|
|
47
|
+
- Clickable file paths, listening-port chips, finished-command alerts and a command palette.
|
|
48
|
+
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
|
|
49
|
+
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
|
|
50
|
+
|
|
51
|
+
[0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
|
|
52
|
+
[0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
|
|
53
|
+
[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.3
|
|
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)
|
|
@@ -69,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
|
|
|
69
70
|
curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
|
|
73
|
+
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
76
|
uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
|
|
@@ -129,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
|
|
|
129
130
|
### Secure by default
|
|
130
131
|
|
|
131
132
|
- **Password required.** Every browser logs in separately and locks after 30 minutes idle.
|
|
133
|
+
- **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
|
|
132
134
|
- **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
|
|
133
135
|
- **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
|
|
134
136
|
|
|
@@ -194,6 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
194
196
|
sd task list|add|move|delete|alarms
|
|
195
197
|
sd serve [--host H] run the server in the foreground
|
|
196
198
|
sd stop stop the background server (closes all terminals)
|
|
199
|
+
sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
|
|
197
200
|
sd login-link emergency: one-time login URL (host only)
|
|
198
201
|
sd reset-password emergency: forget the password (host only)
|
|
199
202
|
```
|
|
@@ -215,6 +218,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
|
|
|
215
218
|
ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
|
|
216
219
|
```
|
|
217
220
|
|
|
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
|
+
|
|
223
|
+
```sh
|
|
224
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
225
|
+
```
|
|
226
|
+
|
|
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
|
+
- **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.
|
|
229
|
+
- **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.
|
|
231
|
+
|
|
218
232
|
**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
233
|
|
|
220
234
|
```sh
|
|
@@ -229,6 +243,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
229
243
|
|
|
230
244
|
- **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.
|
|
231
245
|
- **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
|
+
- **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.
|
|
232
247
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
233
248
|
- **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.
|
|
234
249
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -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)
|
|
@@ -35,7 +35,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
|
|
|
35
35
|
curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Already use Python tooling? Any of these work too:
|
|
38
|
+
The installer sets up [uv](https://docs.astral.sh/uv/) if you don't have it, then installs shelldeck with its own Python 3.12+. Run it again to update to the latest release (or `uv tool upgrade shelldeck`); stop the server first with `sd stop` so Windows can replace `sd.exe`. Already use Python tooling? Any of these work too:
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
|
|
@@ -95,6 +95,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
|
|
|
95
95
|
### Secure by default
|
|
96
96
|
|
|
97
97
|
- **Password required.** Every browser logs in separately and locks after 30 minutes idle.
|
|
98
|
+
- **One device at a time.** Any number of browsers can stay signed in, but only one uses shelldeck; logging in on another takes over and idles the rest. The **Devices** page lists every signed-in browser (where from, last active, in use or idle) and revokes any of them.
|
|
98
99
|
- **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
|
|
99
100
|
- **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
|
|
100
101
|
|
|
@@ -160,6 +161,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
160
161
|
sd task list|add|move|delete|alarms
|
|
161
162
|
sd serve [--host H] run the server in the foreground
|
|
162
163
|
sd stop stop the background server (closes all terminals)
|
|
164
|
+
sd share share over an HTTPS Cloudflare tunnel with a QR link (needs cloudflared)
|
|
163
165
|
sd login-link emergency: one-time login URL (host only)
|
|
164
166
|
sd reset-password emergency: forget the password (host only)
|
|
165
167
|
```
|
|
@@ -181,6 +183,17 @@ sd serve # on the VM: stays on 127.0.0.1:5455
|
|
|
181
183
|
ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://127.0.0.1:5455
|
|
182
184
|
```
|
|
183
185
|
|
|
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
|
+
|
|
188
|
+
```sh
|
|
189
|
+
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
|
|
190
|
+
```
|
|
191
|
+
|
|
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
|
+
- **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.
|
|
194
|
+
- **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.
|
|
196
|
+
|
|
184
197
|
**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
198
|
|
|
186
199
|
```sh
|
|
@@ -195,6 +208,7 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
195
208
|
|
|
196
209
|
- **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.
|
|
197
210
|
- **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
|
+
- **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.
|
|
198
212
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
199
213
|
- **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.
|
|
200
214
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "shelldeck"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.3"
|
|
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]
|
|
@@ -141,12 +141,21 @@ def _h(token: str) -> str:
|
|
|
141
141
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
-
def new_session(client: str) -> str:
|
|
144
|
+
def new_session(client: str, via: str = "local") -> str:
|
|
145
145
|
token = secrets.token_urlsafe(32)
|
|
146
|
-
db.add_auth_session(_h(token), time.time(), client)
|
|
146
|
+
db.add_auth_session(_h(token), time.time(), client, via)
|
|
147
147
|
return token
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def alive(h: str | None) -> bool:
|
|
151
|
+
"""A session (by hash) that still exists and hasn't idled out."""
|
|
152
|
+
if not h:
|
|
153
|
+
return False
|
|
154
|
+
hit = _cache.get(h)
|
|
155
|
+
row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
|
|
156
|
+
return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
|
|
157
|
+
|
|
158
|
+
|
|
150
159
|
def session_hash(token: str | None) -> str | None:
|
|
151
160
|
"""sha256 of a live session token, else None. Idle sessions are removed here."""
|
|
152
161
|
if not token:
|
|
@@ -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)."""
|
|
@@ -136,7 +136,8 @@ def init_db() -> None:
|
|
|
136
136
|
token_hash TEXT PRIMARY KEY,
|
|
137
137
|
created_at REAL NOT NULL,
|
|
138
138
|
last_seen REAL NOT NULL,
|
|
139
|
-
client TEXT
|
|
139
|
+
client TEXT,
|
|
140
|
+
via TEXT
|
|
140
141
|
);
|
|
141
142
|
|
|
142
143
|
-- command history (sessions/projects may be deleted later; keep the text)
|
|
@@ -162,6 +163,9 @@ def init_db() -> None:
|
|
|
162
163
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)")}
|
|
163
164
|
if "name" not in cols:
|
|
164
165
|
conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
|
|
166
|
+
if "via" not in {r[1] for r in conn.execute("PRAGMA table_info(auth_sessions)")}:
|
|
167
|
+
conn.execute("ALTER TABLE auth_sessions ADD COLUMN via TEXT")
|
|
168
|
+
conn.execute("DELETE FROM auth_sessions WHERE via LIKE 'share:%'") # shares end with the server
|
|
165
169
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
|
166
170
|
if "color" not in cols:
|
|
167
171
|
conn.execute("ALTER TABLE projects ADD COLUMN color INTEGER")
|
|
@@ -317,15 +321,30 @@ def list_sessions_with_project() -> list[dict]:
|
|
|
317
321
|
return [dict(r) for r in rows]
|
|
318
322
|
|
|
319
323
|
|
|
320
|
-
def add_auth_session(token_hash: str, now: float, client: str) -> None:
|
|
324
|
+
def add_auth_session(token_hash: str, now: float, client: str, via: str = "local") -> None:
|
|
321
325
|
with _connect() as conn:
|
|
322
326
|
conn.execute(
|
|
323
|
-
"INSERT INTO auth_sessions (token_hash, created_at, last_seen, client) VALUES (?, ?, ?, ?)",
|
|
324
|
-
(token_hash, now, now, client[:200]),
|
|
327
|
+
"INSERT INTO auth_sessions (token_hash, created_at, last_seen, client, via) VALUES (?, ?, ?, ?, ?)",
|
|
328
|
+
(token_hash, now, now, client[:200], via),
|
|
325
329
|
)
|
|
326
330
|
conn.commit()
|
|
327
331
|
|
|
328
332
|
|
|
333
|
+
def list_auth_sessions() -> list[dict]:
|
|
334
|
+
with _connect() as conn:
|
|
335
|
+
conn.row_factory = sqlite3.Row
|
|
336
|
+
return [dict(r) for r in conn.execute("SELECT * FROM auth_sessions ORDER BY last_seen DESC")]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def delete_auth_sessions_via(via: str) -> list[str]:
|
|
340
|
+
"""Delete the logins made through `via` (e.g. one share); returns their hashes."""
|
|
341
|
+
with _connect() as conn:
|
|
342
|
+
gone = [r[0] for r in conn.execute("SELECT token_hash FROM auth_sessions WHERE via = ?", (via,))]
|
|
343
|
+
conn.execute("DELETE FROM auth_sessions WHERE via = ?", (via,))
|
|
344
|
+
conn.commit()
|
|
345
|
+
return gone
|
|
346
|
+
|
|
347
|
+
|
|
329
348
|
def get_auth_session(token_hash: str) -> dict | None:
|
|
330
349
|
with _connect() as conn:
|
|
331
350
|
conn.row_factory = sqlite3.Row
|
|
@@ -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:
|
|
@@ -163,13 +183,25 @@ def _who(conn) -> tuple[str, str | None] | None:
|
|
|
163
183
|
return ("browser", h) if h else None
|
|
164
184
|
|
|
165
185
|
|
|
186
|
+
# One device uses shelldeck at a time; the others stay signed in but idle until they log in again.
|
|
187
|
+
_active: dict[str, str | None] = {"h": None}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _claim(h: str) -> bool:
|
|
191
|
+
"""True when browser session `h` may act: it is the active one, or no live session is."""
|
|
192
|
+
if _active["h"] != h and auth.alive(_active["h"]):
|
|
193
|
+
return False
|
|
194
|
+
_active["h"] = h
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
166
198
|
def _denied() -> JSONResponse:
|
|
167
199
|
return err("locked" if auth.has_password() else "setup_required", 401)
|
|
168
200
|
|
|
169
201
|
|
|
170
202
|
@app.middleware("http")
|
|
171
203
|
async def guard(request: Request, call_next):
|
|
172
|
-
if not _trusted(request
|
|
204
|
+
if not _trusted(request):
|
|
173
205
|
return err("forbidden_origin", 403)
|
|
174
206
|
path = request.url.path
|
|
175
207
|
request.state.session = None
|
|
@@ -178,6 +210,8 @@ async def guard(request: Request, call_next):
|
|
|
178
210
|
if not who:
|
|
179
211
|
return _denied()
|
|
180
212
|
request.state.session = who[1]
|
|
213
|
+
if who[1] and not _claim(who[1]):
|
|
214
|
+
return err("in_use", 423)
|
|
181
215
|
if who[1] and request.method != "GET": # polling GETs are not activity
|
|
182
216
|
auth.touch(who[1])
|
|
183
217
|
return await call_next(request)
|
|
@@ -214,9 +248,21 @@ async def shutdown():
|
|
|
214
248
|
# ---------------------------------------------------------------------------
|
|
215
249
|
|
|
216
250
|
|
|
217
|
-
def
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
def _via(request: Request) -> str:
|
|
252
|
+
if _via_share(request):
|
|
253
|
+
return f"share:{_share['host']}"
|
|
254
|
+
return "local" if _is_local(request) else "network"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def _login_response(request: Request, body: dict, res=None):
|
|
258
|
+
"""Start a login session (cookie on `res`, JSON body by default). It becomes the active device:
|
|
259
|
+
this browser's previous login is replaced and every other device goes idle."""
|
|
260
|
+
if old := auth.session_hash(request.cookies.get(auth.COOKIE)):
|
|
261
|
+
auth.end_session(old)
|
|
262
|
+
token = auth.new_session(f"{_client(request)} {request.headers.get('user-agent', '')}", _via(request))
|
|
263
|
+
h = auth.session_hash(token)
|
|
264
|
+
_active["h"] = h
|
|
265
|
+
await _close_session_sockets(lambda owner: owner != h, IN_USE)
|
|
220
266
|
res = res or JSONResponse(body)
|
|
221
267
|
res.set_cookie(
|
|
222
268
|
auth.COOKIE, token, httponly=True, samesite="strict", path="/",
|
|
@@ -241,6 +287,7 @@ async def auth_status(request: Request):
|
|
|
241
287
|
"has_password": auth.has_password(),
|
|
242
288
|
"setup_code_required": not auth.has_password() and not _is_local(request),
|
|
243
289
|
"authenticated": bool(who),
|
|
290
|
+
"in_use_elsewhere": bool(who and who[1] and who[1] != _active["h"] and auth.alive(_active["h"])),
|
|
244
291
|
"idle_timeout": auth.LOCK_TIMEOUT_SECONDS,
|
|
245
292
|
"min_length": auth.MIN_PASSWORD,
|
|
246
293
|
}
|
|
@@ -263,7 +310,7 @@ async def auth_setup(request: Request, payload: dict):
|
|
|
263
310
|
return err(problem)
|
|
264
311
|
auth.set_password(password)
|
|
265
312
|
log.info("password created from %s", _client(request))
|
|
266
|
-
return _login_response(request, {"status": "ok"})
|
|
313
|
+
return await _login_response(request, {"status": "ok"})
|
|
267
314
|
|
|
268
315
|
|
|
269
316
|
@app.post("/api/auth/login")
|
|
@@ -277,7 +324,7 @@ async def auth_login(request: Request, payload: dict):
|
|
|
277
324
|
if not ok:
|
|
278
325
|
log.warning("failed login from %s", _client(request))
|
|
279
326
|
return err("invalid_password", 401)
|
|
280
|
-
return _login_response(request, {"status": "ok"})
|
|
327
|
+
return await _login_response(request, {"status": "ok"})
|
|
281
328
|
|
|
282
329
|
|
|
283
330
|
_login_links: dict[str, float] = {} # one-time code -> expiry
|
|
@@ -293,12 +340,54 @@ async def auth_login_link(request: Request):
|
|
|
293
340
|
return {"path": f"/api/auth/link/{code}"}
|
|
294
341
|
|
|
295
342
|
|
|
343
|
+
@app.post("/api/share")
|
|
344
|
+
async def share_start(request: Request, payload: dict):
|
|
345
|
+
"""Host CLI only: open the gate for an `sd share` tunnel host; returns its one link."""
|
|
346
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
347
|
+
return err("host_cli_only", 403)
|
|
348
|
+
host = str(payload.get("host", "")).lower()
|
|
349
|
+
if not re.fullmatch(r"[a-z0-9-]+(\.[a-z0-9-]+)+", host):
|
|
350
|
+
return err("invalid_host")
|
|
351
|
+
await _share_stop()
|
|
352
|
+
token = secrets.token_urlsafe(32)
|
|
353
|
+
_share.update(host=host, key=hashlib.sha256(token.encode()).hexdigest())
|
|
354
|
+
log.info("sharing via %s", host)
|
|
355
|
+
return {"path": f"/share/{token}"}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.delete("/api/share")
|
|
359
|
+
async def share_stop(request: Request):
|
|
360
|
+
if not auth.cli_ok(request.headers.get(auth.TOKEN_HEADER)):
|
|
361
|
+
return err("host_cli_only", 403)
|
|
362
|
+
await _share_stop()
|
|
363
|
+
return {"status": "ok"}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def _share_stop() -> None:
|
|
367
|
+
"""Close the gate and sign out every browser that logged in through the tunnel."""
|
|
368
|
+
if not _share:
|
|
369
|
+
return
|
|
370
|
+
gone = db.delete_auth_sessions_via(f"share:{_share['host']}")
|
|
371
|
+
_share.clear()
|
|
372
|
+
await _revoke(gone)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@app.get("/share/{token}")
|
|
376
|
+
async def share_open(request: Request, token: str):
|
|
377
|
+
"""The QR link: sets the tunnel cookie, then the usual password login takes over."""
|
|
378
|
+
if not (_via_share(request) and _share_key(token)):
|
|
379
|
+
return err("link_expired", 403)
|
|
380
|
+
res = RedirectResponse("/", status_code=303) # drops the token from the address bar
|
|
381
|
+
res.set_cookie(SHARE_COOKIE, token, httponly=True, samesite="lax", secure=True, path="/", max_age=24 * 3600)
|
|
382
|
+
return res
|
|
383
|
+
|
|
384
|
+
|
|
296
385
|
@app.get("/api/auth/link/{code}")
|
|
297
386
|
async def auth_use_link(request: Request, code: str):
|
|
298
387
|
expiry = _login_links.pop(code, 0)
|
|
299
388
|
if expiry < time.time() or not auth.has_password():
|
|
300
389
|
return err("link_expired", 403)
|
|
301
|
-
return _login_response(request, {}, RedirectResponse("/", status_code=303))
|
|
390
|
+
return await _login_response(request, {}, RedirectResponse("/", status_code=303))
|
|
302
391
|
|
|
303
392
|
|
|
304
393
|
@app.post("/api/auth/logout")
|
|
@@ -313,6 +402,44 @@ async def auth_logout(request: Request):
|
|
|
313
402
|
return res
|
|
314
403
|
|
|
315
404
|
|
|
405
|
+
@app.get("/api/devices")
|
|
406
|
+
async def devices(request: Request):
|
|
407
|
+
"""Browsers signed in: where from, when, and which one is in use."""
|
|
408
|
+
online: dict[str, int] = {}
|
|
409
|
+
for owner in socket_owner.values():
|
|
410
|
+
if owner:
|
|
411
|
+
online[owner] = online.get(owner, 0) + 1
|
|
412
|
+
rows = []
|
|
413
|
+
for r in db.list_auth_sessions():
|
|
414
|
+
h = r["token_hash"]
|
|
415
|
+
if not auth.alive(h):
|
|
416
|
+
continue
|
|
417
|
+
ip, _, agent = (r["client"] or "").partition(" ")
|
|
418
|
+
rows.append({
|
|
419
|
+
"id": h, "ip": ip, "agent": agent, "via": r["via"] or "local",
|
|
420
|
+
"created_at": r["created_at"], "last_seen": r["last_seen"],
|
|
421
|
+
"active": h == _active["h"], "current": h == request.state.session, "sockets": online.get(h, 0),
|
|
422
|
+
})
|
|
423
|
+
return {"devices": rows, "share": _share.get("host")}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.delete("/api/devices/{device_id}")
|
|
427
|
+
async def revoke_device(request: Request, device_id: str):
|
|
428
|
+
"""Sign one browser out, or with id "others" every browser but this one."""
|
|
429
|
+
if device_id == "others":
|
|
430
|
+
return await _revoke([d["token_hash"] for d in db.list_auth_sessions() if d["token_hash"] != request.state.session])
|
|
431
|
+
return await _revoke([device_id])
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def _revoke(hashes: list[str]) -> dict:
|
|
435
|
+
for h in hashes:
|
|
436
|
+
auth.end_session(h)
|
|
437
|
+
if _active["h"] == h:
|
|
438
|
+
_active["h"] = None
|
|
439
|
+
await _close_session_sockets(lambda owner: owner in hashes)
|
|
440
|
+
return {"revoked": len(hashes)}
|
|
441
|
+
|
|
442
|
+
|
|
316
443
|
@app.put("/api/auth/password")
|
|
317
444
|
async def auth_password(request: Request, payload: dict):
|
|
318
445
|
"""Change the password (logged-in browser only). Signs out every other browser."""
|
|
@@ -679,11 +806,14 @@ async def _close(ws: WebSocket, code: int = 1000) -> None:
|
|
|
679
806
|
pass
|
|
680
807
|
|
|
681
808
|
|
|
682
|
-
|
|
809
|
+
IN_USE = 4423 # socket close code: another device took over
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
async def _close_session_sockets(match, code: int = 1008) -> None:
|
|
683
813
|
"""Close sockets whose login (session hash; None for the CLI) matches."""
|
|
684
814
|
for ws, owner in list(socket_owner.items()):
|
|
685
815
|
if owner and match(owner):
|
|
686
|
-
await _close(ws,
|
|
816
|
+
await _close(ws, code)
|
|
687
817
|
|
|
688
818
|
|
|
689
819
|
# ---------------------------------------------------------------------------
|
|
@@ -825,10 +955,13 @@ def _attach(session: dict, rows: int, cols: int) -> None:
|
|
|
825
955
|
|
|
826
956
|
@app.websocket("/ws/alarms")
|
|
827
957
|
async def alarms_ws(ws: WebSocket):
|
|
828
|
-
who = _who(ws) if _trusted(ws
|
|
958
|
+
who = _who(ws) if _trusted(ws) else None
|
|
829
959
|
if not who:
|
|
830
960
|
await ws.close(code=1008)
|
|
831
961
|
return
|
|
962
|
+
if who[1] and not _claim(who[1]):
|
|
963
|
+
await ws.close(code=IN_USE)
|
|
964
|
+
return
|
|
832
965
|
await ws.accept()
|
|
833
966
|
alarm_sockets.add(ws)
|
|
834
967
|
socket_owner[ws] = who[1]
|
|
@@ -846,10 +979,13 @@ async def alarms_ws(ws: WebSocket):
|
|
|
846
979
|
|
|
847
980
|
@app.websocket("/ws/{session_id}")
|
|
848
981
|
async def terminal_ws(ws: WebSocket, session_id: str):
|
|
849
|
-
who = _who(ws) if _trusted(ws
|
|
982
|
+
who = _who(ws) if _trusted(ws) else None
|
|
850
983
|
if not who:
|
|
851
984
|
await ws.close(code=1008)
|
|
852
985
|
return
|
|
986
|
+
if who[1] and not _claim(who[1]):
|
|
987
|
+
await ws.close(code=IN_USE)
|
|
988
|
+
return
|
|
853
989
|
login = who[1]
|
|
854
990
|
session = db.get_session(session_id)
|
|
855
991
|
if not session:
|