shelldeck 0.0.2__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.2 → shelldeck-0.0.4}/CHANGELOG.md +37 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/PKG-INFO +14 -7
- {shelldeck-0.0.2 → shelldeck-0.0.4}/README.md +13 -6
- {shelldeck-0.0.2 → shelldeck-0.0.4}/pyproject.toml +1 -1
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/auth.py +29 -3
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/cli.py +54 -4
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/db.py +23 -4
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/server.py +244 -28
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/app.css +45 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/app.js +14 -6
- shelldeck-0.0.4/shelldeck/static/devices.js +76 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/index.html +2 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/ui.js +2 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/views.js +2 -1
- {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_cli.py +12 -3
- {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_shelldeck.py +127 -8
- {shelldeck-0.0.2 → shelldeck-0.0.4}/.gitignore +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/LICENSE +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/__init__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/__main__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/addons/__init__.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/addons/fast_context.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/gitgraph.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/bash.sh +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.fish +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/shelldeck.ps1 +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshenv +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/integration/zsh/.zshrc +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/pty.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/runner.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/scheduler.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/shells.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/gitgraph.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/history.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-192.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-32.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon-512.png +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/icon.svg +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/manifest.webmanifest +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/monitor.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/LICENSE-xterm.txt +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-fit.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-search.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-serialize.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-web-links.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/addon-webgl.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.css +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/static/vendor/xterm.js +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/shelldeck/stats.py +0 -0
- {shelldeck-0.0.2 → shelldeck-0.0.4}/tests/test_gitgraph.py +0 -0
|
@@ -2,6 +2,41 @@
|
|
|
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
|
+
|
|
24
|
+
## [0.0.3] - 2026-09-29
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Logging in again in the same browser replaces its old login instead of adding one.
|
|
34
|
+
- Share logins are stored with their share and removed when it stops, is replaced, or the server restarts.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Re-running the one-line installer now upgrades an existing install to the latest release.
|
|
39
|
+
|
|
5
40
|
## [0.0.2] - 2026-09-29
|
|
6
41
|
|
|
7
42
|
### Added
|
|
@@ -32,5 +67,7 @@ First public release.
|
|
|
32
67
|
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
|
|
33
68
|
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.
|
|
34
69
|
|
|
70
|
+
[0.0.4]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.4
|
|
71
|
+
[0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
|
|
35
72
|
[0.0.2]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.2
|
|
36
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
|
|
@@ -70,7 +70,7 @@ irm https://raw.githubusercontent.com/codejunction/shelldeck/main/install.ps1 |
|
|
|
70
70
|
curl -fsSL https://raw.githubusercontent.com/codejunction/shelldeck/main/install.sh | sh
|
|
71
71
|
```
|
|
72
72
|
|
|
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+. 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:
|
|
74
74
|
|
|
75
75
|
```sh
|
|
76
76
|
uv tool install shelldeck # or: pipx install shelldeck, or: pip install shelldeck
|
|
@@ -130,6 +130,7 @@ sd ~/code/api # add a folder as a project and open a terminal in it
|
|
|
130
130
|
### Secure by default
|
|
131
131
|
|
|
132
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.
|
|
133
134
|
- **Local only.** shelldeck listens on `127.0.0.1` and refuses requests from other websites.
|
|
134
135
|
- **Remote use** goes over an SSH tunnel or HTTPS. See [Remote access](#remote-access).
|
|
135
136
|
|
|
@@ -195,7 +196,7 @@ sd schedule list|add|run|toggle|delete|logs
|
|
|
195
196
|
sd task list|add|move|delete|alarms
|
|
196
197
|
sd serve [--host H] run the server in the foreground
|
|
197
198
|
sd stop stop the background server (closes all terminals)
|
|
198
|
-
sd share
|
|
199
|
+
sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
|
|
199
200
|
sd login-link emergency: one-time login URL (host only)
|
|
200
201
|
sd reset-password emergency: forget the password (host only)
|
|
201
202
|
```
|
|
@@ -220,13 +221,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
|
|
|
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.
|
|
221
222
|
|
|
222
223
|
```sh
|
|
223
|
-
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)
|
|
224
226
|
```
|
|
225
227
|
|
|
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
|
-
- **
|
|
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.
|
|
228
232
|
- **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.
|
|
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.
|
|
230
234
|
|
|
231
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:
|
|
232
236
|
|
|
@@ -242,6 +246,9 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
242
246
|
|
|
243
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.
|
|
244
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.
|
|
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).
|
|
245
252
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
246
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.
|
|
247
254
|
- **Your data** lives in `~/.config/shelldeck/` (database, log and saved terminal history). Set `SHELLDECK_HOME` to move it.
|
|
@@ -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,7 +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)
|
|
163
|
-
sd share
|
|
164
|
+
sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
|
|
164
165
|
sd login-link emergency: one-time login URL (host only)
|
|
165
166
|
sd reset-password emergency: forget the password (host only)
|
|
166
167
|
```
|
|
@@ -185,13 +186,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
|
|
|
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.
|
|
186
187
|
|
|
187
188
|
```sh
|
|
188
|
-
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)
|
|
189
191
|
```
|
|
190
192
|
|
|
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
|
-
- **
|
|
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.
|
|
193
197
|
- **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.
|
|
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.
|
|
195
199
|
|
|
196
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:
|
|
197
201
|
|
|
@@ -207,6 +211,9 @@ sd serve --host 0.0.0.0 --cert cert.pem --key key.pem
|
|
|
207
211
|
|
|
208
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.
|
|
209
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.
|
|
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).
|
|
210
217
|
- **The CLI** authenticates with a token file in the data folder, readable only by your account and rotated on every server start.
|
|
211
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.
|
|
212
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()
|
|
@@ -141,12 +158,21 @@ def _h(token: str) -> str:
|
|
|
141
158
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
142
159
|
|
|
143
160
|
|
|
144
|
-
def new_session(client: str) -> str:
|
|
161
|
+
def new_session(client: str, via: str = "local") -> str:
|
|
145
162
|
token = secrets.token_urlsafe(32)
|
|
146
|
-
db.add_auth_session(_h(token), time.time(), client)
|
|
163
|
+
db.add_auth_session(_h(token), time.time(), client, via)
|
|
147
164
|
return token
|
|
148
165
|
|
|
149
166
|
|
|
167
|
+
def alive(h: str | None) -> bool:
|
|
168
|
+
"""A session (by hash) that still exists and hasn't idled out."""
|
|
169
|
+
if not h:
|
|
170
|
+
return False
|
|
171
|
+
hit = _cache.get(h)
|
|
172
|
+
row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
|
|
173
|
+
return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS
|
|
174
|
+
|
|
175
|
+
|
|
150
176
|
def session_hash(token: str | None) -> str | None:
|
|
151
177
|
"""sha256 of a live session token, else None. Idle sessions are removed here."""
|
|
152
178
|
if not token:
|
|
@@ -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:
|
|
@@ -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
|