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.
Files changed (45) hide show
  1. shelldeck/__init__.py +0 -0
  2. shelldeck/__main__.py +4 -0
  3. shelldeck/addons/__init__.py +1 -0
  4. shelldeck/addons/fast_context.py +1346 -0
  5. shelldeck/auth.py +207 -0
  6. shelldeck/cli.py +587 -0
  7. shelldeck/db.py +794 -0
  8. shelldeck/gitgraph.py +150 -0
  9. shelldeck/integration/bash.sh +27 -0
  10. shelldeck/integration/shelldeck.fish +5 -0
  11. shelldeck/integration/shelldeck.ps1 +35 -0
  12. shelldeck/integration/zsh/.zshenv +6 -0
  13. shelldeck/integration/zsh/.zshrc +10 -0
  14. shelldeck/pty.py +352 -0
  15. shelldeck/runner.py +75 -0
  16. shelldeck/scheduler.py +283 -0
  17. shelldeck/server.py +1216 -0
  18. shelldeck/shells.py +174 -0
  19. shelldeck/static/app.css +2288 -0
  20. shelldeck/static/app.js +1863 -0
  21. shelldeck/static/gitgraph.js +142 -0
  22. shelldeck/static/history.js +172 -0
  23. shelldeck/static/icon-192.png +0 -0
  24. shelldeck/static/icon-32.png +0 -0
  25. shelldeck/static/icon-512.png +0 -0
  26. shelldeck/static/icon.svg +1 -0
  27. shelldeck/static/index.html +102 -0
  28. shelldeck/static/manifest.webmanifest +1 -0
  29. shelldeck/static/monitor.js +143 -0
  30. shelldeck/static/ui.js +316 -0
  31. shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
  32. shelldeck/static/vendor/addon-fit.js +2 -0
  33. shelldeck/static/vendor/addon-search.js +2 -0
  34. shelldeck/static/vendor/addon-serialize.js +2 -0
  35. shelldeck/static/vendor/addon-web-links.js +2 -0
  36. shelldeck/static/vendor/addon-webgl.js +2 -0
  37. shelldeck/static/vendor/xterm.css +218 -0
  38. shelldeck/static/vendor/xterm.js +2 -0
  39. shelldeck/static/views.js +658 -0
  40. shelldeck/stats.py +128 -0
  41. shelldeck-0.0.1.dist-info/METADATA +254 -0
  42. shelldeck-0.0.1.dist-info/RECORD +45 -0
  43. shelldeck-0.0.1.dist-info/WHEEL +4 -0
  44. shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
  45. 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)