shelldeck 0.0.1__py3-none-any.whl
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/__init__.py +0 -0
- shelldeck/__main__.py +4 -0
- shelldeck/addons/__init__.py +1 -0
- shelldeck/addons/fast_context.py +1346 -0
- shelldeck/auth.py +207 -0
- shelldeck/cli.py +587 -0
- shelldeck/db.py +794 -0
- shelldeck/gitgraph.py +150 -0
- shelldeck/integration/bash.sh +27 -0
- shelldeck/integration/shelldeck.fish +5 -0
- shelldeck/integration/shelldeck.ps1 +35 -0
- shelldeck/integration/zsh/.zshenv +6 -0
- shelldeck/integration/zsh/.zshrc +10 -0
- shelldeck/pty.py +352 -0
- shelldeck/runner.py +75 -0
- shelldeck/scheduler.py +283 -0
- shelldeck/server.py +1216 -0
- shelldeck/shells.py +174 -0
- shelldeck/static/app.css +2288 -0
- shelldeck/static/app.js +1863 -0
- shelldeck/static/gitgraph.js +142 -0
- shelldeck/static/history.js +172 -0
- shelldeck/static/icon-192.png +0 -0
- shelldeck/static/icon-32.png +0 -0
- shelldeck/static/icon-512.png +0 -0
- shelldeck/static/icon.svg +1 -0
- shelldeck/static/index.html +102 -0
- shelldeck/static/manifest.webmanifest +1 -0
- shelldeck/static/monitor.js +143 -0
- shelldeck/static/ui.js +316 -0
- shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
- shelldeck/static/vendor/addon-fit.js +2 -0
- shelldeck/static/vendor/addon-search.js +2 -0
- shelldeck/static/vendor/addon-serialize.js +2 -0
- shelldeck/static/vendor/addon-web-links.js +2 -0
- shelldeck/static/vendor/addon-webgl.js +2 -0
- shelldeck/static/vendor/xterm.css +218 -0
- shelldeck/static/vendor/xterm.js +2 -0
- shelldeck/static/views.js +658 -0
- shelldeck/stats.py +128 -0
- shelldeck-0.0.1.dist-info/METADATA +254 -0
- shelldeck-0.0.1.dist-info/RECORD +45 -0
- shelldeck-0.0.1.dist-info/WHEEL +4 -0
- shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
- shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/auth.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Login for shelldeck: one password, per-browser sessions, a host-only CLI token.
|
|
2
|
+
|
|
3
|
+
- No password yet: the first visit creates one. From anywhere but the host itself that also
|
|
4
|
+
needs the setup code printed on the host (and kept in <config>/setup-code), so nobody else
|
|
5
|
+
can claim a fresh remote server first.
|
|
6
|
+
- Each browser login is a random token in an HttpOnly cookie. Only its sha256 is stored
|
|
7
|
+
(auth_sessions table), so logins survive restarts and a stolen database holds no tokens.
|
|
8
|
+
A session idle for LOCK_TIMEOUT_SECONDS is dropped: that browser is locked.
|
|
9
|
+
- The CLI on the host reads <config>/cli-token (owner-only) and sends it as a header.
|
|
10
|
+
- Emergency (forgotten password): `shelldeck reset-password` on the host. See README.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import db
|
|
20
|
+
|
|
21
|
+
LOCK_TIMEOUT_SECONDS = 30 * 60
|
|
22
|
+
MIN_PASSWORD = 8
|
|
23
|
+
COOKIE = "sd_session"
|
|
24
|
+
TOKEN_HEADER = "x-shelldeck-token"
|
|
25
|
+
_PASSWORD_KEY = "auth_password_hash"
|
|
26
|
+
_TOUCH_EVERY = 60 # seconds; last_seen writes are throttled
|
|
27
|
+
_CACHE_TTL = 5 # seconds a session lookup is trusted before re-reading the db
|
|
28
|
+
|
|
29
|
+
_cache: dict[str, tuple[float, dict | None]] = {}
|
|
30
|
+
_failures: dict[str, tuple[int, float]] = {} # client -> (count, blocked until)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------ password
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hash_password(password: str) -> str:
|
|
37
|
+
salt = os.urandom(32)
|
|
38
|
+
iterations = 600_000
|
|
39
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
|
|
40
|
+
return f"{salt.hex()}:{iterations}:{digest.hex()}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _verify_password(password: str, stored: str) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
salt_hex, iterations_s, hash_hex = stored.split(":")
|
|
46
|
+
salt = bytes.fromhex(salt_hex)
|
|
47
|
+
iterations = int(iterations_s)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return False
|
|
50
|
+
computed = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
|
|
51
|
+
return hmac.compare_digest(computed.hex(), hash_hex)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def has_password() -> bool:
|
|
55
|
+
return bool(db.get_setting(_PASSWORD_KEY))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_password(password: str) -> bool:
|
|
59
|
+
stored = db.get_setting(_PASSWORD_KEY)
|
|
60
|
+
return bool(stored) and _verify_password(password, stored)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_new(password: str, confirm: str) -> str | None:
|
|
64
|
+
"""Error code for an unacceptable new password, else None."""
|
|
65
|
+
if len(password) < MIN_PASSWORD:
|
|
66
|
+
return "password_too_short"
|
|
67
|
+
if password != confirm:
|
|
68
|
+
return "passwords_do_not_match"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_password(password: str) -> None:
|
|
73
|
+
db.set_setting(_PASSWORD_KEY, _hash_password(password))
|
|
74
|
+
_file("setup-code").unlink(missing_ok=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ host files
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _file(name: str):
|
|
81
|
+
return db.config_dir() / name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write_private(name: str, text: str) -> None:
|
|
85
|
+
path = _file(name)
|
|
86
|
+
path.write_text(text, encoding="utf-8")
|
|
87
|
+
try:
|
|
88
|
+
os.chmod(path, 0o600) # ponytail: on Windows the user profile folder is already private
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def setup_code() -> str | None:
|
|
94
|
+
"""The one-time code remote setup needs; created while no password exists."""
|
|
95
|
+
if has_password():
|
|
96
|
+
return None
|
|
97
|
+
path = _file("setup-code")
|
|
98
|
+
if path.exists():
|
|
99
|
+
return path.read_text(encoding="utf-8").strip()
|
|
100
|
+
code = "-".join(secrets.token_hex(2) for _ in range(3))
|
|
101
|
+
_write_private("setup-code", code)
|
|
102
|
+
return code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cli_token() -> str:
|
|
106
|
+
"""Token the local CLI sends; rotated on every server start."""
|
|
107
|
+
token = secrets.token_urlsafe(32)
|
|
108
|
+
_write_private("cli-token", token)
|
|
109
|
+
return token
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_cli_token() -> str:
|
|
113
|
+
try:
|
|
114
|
+
return _file("cli-token").read_text(encoding="utf-8").strip()
|
|
115
|
+
except OSError:
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def init_auth() -> str | None:
|
|
120
|
+
"""On server start: drop termy's seeded password, rotate the CLI token. Returns the setup code, if any."""
|
|
121
|
+
stored = db.get_setting(_PASSWORD_KEY)
|
|
122
|
+
if stored and _verify_password("nopassword", stored):
|
|
123
|
+
db.delete_setting(_PASSWORD_KEY)
|
|
124
|
+
cli_token()
|
|
125
|
+
return setup_code()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reset() -> str:
|
|
129
|
+
"""Emergency, host-only: forget the password and every login. Returns the new setup code."""
|
|
130
|
+
db.delete_setting(_PASSWORD_KEY)
|
|
131
|
+
db.delete_auth_sessions()
|
|
132
|
+
_file("setup-code").unlink(missing_ok=True)
|
|
133
|
+
_cache.clear()
|
|
134
|
+
return setup_code() or ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ sessions
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _h(token: str) -> str:
|
|
141
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def new_session(client: str) -> str:
|
|
145
|
+
token = secrets.token_urlsafe(32)
|
|
146
|
+
db.add_auth_session(_h(token), time.time(), client)
|
|
147
|
+
return token
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def session_hash(token: str | None) -> str | None:
|
|
151
|
+
"""sha256 of a live session token, else None. Idle sessions are removed here."""
|
|
152
|
+
if not token:
|
|
153
|
+
return None
|
|
154
|
+
h = _h(token)
|
|
155
|
+
now = time.time()
|
|
156
|
+
hit = _cache.get(h)
|
|
157
|
+
row = hit[1] if hit and now - hit[0] < _CACHE_TTL else db.get_auth_session(h)
|
|
158
|
+
_cache[h] = (now, row)
|
|
159
|
+
if not row:
|
|
160
|
+
return None
|
|
161
|
+
if now - row["last_seen"] > LOCK_TIMEOUT_SECONDS:
|
|
162
|
+
end_session(h)
|
|
163
|
+
return None
|
|
164
|
+
return h
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def touch(h: str) -> None:
|
|
168
|
+
"""User activity on a session (keeps it from locking)."""
|
|
169
|
+
now = time.time()
|
|
170
|
+
row = _cache.get(h, (0, None))[1]
|
|
171
|
+
if row and now - row["last_seen"] > _TOUCH_EVERY:
|
|
172
|
+
row["last_seen"] = now
|
|
173
|
+
db.touch_auth_session(h, now)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def end_session(h: str) -> None:
|
|
177
|
+
db.delete_auth_sessions(only=h)
|
|
178
|
+
_cache.pop(h, None)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def end_other_sessions(keep: str) -> None:
|
|
182
|
+
db.delete_auth_sessions(keep=keep)
|
|
183
|
+
_cache.clear()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cli_ok(token: str | None) -> bool:
|
|
187
|
+
expected = read_cli_token()
|
|
188
|
+
return bool(token and expected) and hmac.compare_digest(token, expected)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ brute force
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def blocked_for(client: str) -> int:
|
|
195
|
+
"""Seconds this client must wait before trying a password again."""
|
|
196
|
+
count, until = _failures.get(client, (0, 0.0))
|
|
197
|
+
return max(0, int(until - time.time() + 0.999))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def record_attempt(client: str, ok: bool) -> None:
|
|
201
|
+
if ok:
|
|
202
|
+
_failures.pop(client, None)
|
|
203
|
+
return
|
|
204
|
+
count = _failures.get(client, (0, 0.0))[0] + 1
|
|
205
|
+
# 5 free tries, then 2, 4, 8 … seconds, capped at 5 minutes
|
|
206
|
+
wait = 0 if count < 5 else min(300, 2 ** (count - 4))
|
|
207
|
+
_failures[client] = (count, time.time() + wait)
|