tinyjukebox 0.47.0__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 (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,373 @@
1
+ """Access control for the rest api: password hashes, users file, sessions and login throttling."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import logging
7
+ import os
8
+ import secrets
9
+ import stat
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable
13
+ from typing import Any, TypeGuard
14
+
15
+ import yaml
16
+
17
+ __all__ = [
18
+ "MAX_USERNAME_LENGTH",
19
+ "MIN_PASSWORD_LENGTH",
20
+ "ROLES",
21
+ "LoginThrottle",
22
+ "Session",
23
+ "Sessions",
24
+ "UsersFile",
25
+ "has_role",
26
+ "hash_password",
27
+ "valid_username",
28
+ "verify_password",
29
+ ]
30
+
31
+ ROLES = ("user", "admin") # admin may do everything a user may do
32
+ MIN_PASSWORD_LENGTH = 8
33
+ MAX_USERNAME_LENGTH = 64
34
+
35
+
36
+ def valid_username(name: object) -> TypeGuard[str]:
37
+ """A name for a user of the page - the same rule on the page and for ``tinyjukebox-users``: not empty, no blanks
38
+ around it, no ``:``, at most :data:`MAX_USERNAME_LENGTH` characters."""
39
+ return (
40
+ isinstance(name, str)
41
+ and bool(name)
42
+ and name == name.strip()
43
+ and ":" not in name
44
+ and len(name) <= MAX_USERNAME_LENGTH
45
+ and name.isprintable()
46
+ )
47
+
48
+
49
+ # scrypt parameters - n=2**14 needs 16 MB and ~50 ms on a Raspberry Pi 3
50
+ _SCRYPT_N = 2**14
51
+ _SCRYPT_R = 8
52
+ _SCRYPT_P = 1
53
+
54
+ Clock = Callable[[], float]
55
+
56
+
57
+ def _b64(data: bytes) -> str:
58
+ return base64.b64encode(data).decode("ascii")
59
+
60
+
61
+ def hash_password(password: str) -> str:
62
+ """'scrypt$n$r$p$salt$hash' (salt and hash base64 encoded)."""
63
+ salt = secrets.token_bytes(16)
64
+ digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32)
65
+ return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${_b64(salt)}${_b64(digest)}"
66
+
67
+
68
+ def verify_password(password: str, password_hash: object) -> bool:
69
+ """Constant time comparison; malformed hashes never match."""
70
+ try:
71
+ algorithm, n, r, p, salt, digest = str(password_hash).split("$")
72
+ if algorithm != "scrypt":
73
+ return False
74
+ expected = base64.b64decode(digest)
75
+ candidate = hashlib.scrypt(
76
+ password.encode("utf-8"),
77
+ salt=base64.b64decode(salt),
78
+ n=int(n),
79
+ r=int(r),
80
+ p=int(p),
81
+ dklen=len(expected),
82
+ maxmem=128 * 1024 * 1024,
83
+ )
84
+ except (ValueError, TypeError):
85
+ return False
86
+ return hmac.compare_digest(candidate, expected)
87
+
88
+
89
+ # compared against if the user is unknown - login takes as long as for a known user (no user enumeration)
90
+ _DUMMY_HASH = hash_password(secrets.token_hex(16))
91
+
92
+ # one password check at a time: each one needs 16 MB, and parallel logins (one thread per connection) would multiply
93
+ # that - and pass the login throttle before the first failure has been counted
94
+ _VERIFY_LOCK = threading.Lock()
95
+
96
+
97
+ def has_role(role: str, required_role: str) -> bool:
98
+ return ROLES.index(role) >= ROLES.index(required_role)
99
+
100
+
101
+ class UsersFile:
102
+ """Users are stored in a yaml file (managed with ``tinyjukebox-users``)::
103
+
104
+ users:
105
+ mama:
106
+ password-hash: scrypt$16384$8$1$...
107
+ role: admin
108
+
109
+ The file is reloaded if it has been changed - adding or removing users needs no restart. The web page changes
110
+ it with :meth:`change`. A session ends when its user has a new password, wherever it was set
111
+ (:meth:`session_role`).
112
+ """
113
+
114
+ def __init__(self, filename: str, logger: logging.Logger) -> None:
115
+ self.filename = filename
116
+ self._logger = logger
117
+ self._lock = threading.Lock()
118
+ self._mtime: int | None = None
119
+ self._users: dict[str, dict[str, Any]] = {}
120
+ self._reload()
121
+
122
+ @staticmethod
123
+ def read(filename: str) -> dict[str, dict[str, Any]]:
124
+ """:raises ValueError: if the file is not a valid users file."""
125
+ try:
126
+ with open(filename, encoding="utf-8") as f:
127
+ data = yaml.safe_load(f) or {}
128
+ except FileNotFoundError:
129
+ return {}
130
+ users = data.get("users") or {} if isinstance(data, dict) else None
131
+ if not isinstance(users, dict):
132
+ raise ValueError(f"'users' in '{filename}' must be a mapping.")
133
+ for name, user in users.items():
134
+ if not isinstance(user, dict) or "password-hash" not in user or user.get("role") not in ROLES:
135
+ raise ValueError(f"user '{name}' in '{filename}' needs a password-hash and a role ({'/'.join(ROLES)}).")
136
+ return users
137
+
138
+ @staticmethod
139
+ def write(filename: str, users: dict[str, dict[str, Any]]) -> None:
140
+ """Write the users file (mode 600, atomically)."""
141
+ os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True)
142
+ temp = filename + ".tmp"
143
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
144
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
145
+ yaml.safe_dump({"users": users}, f, default_flow_style=False, allow_unicode=True)
146
+ os.replace(temp, filename)
147
+
148
+ def _reload(self) -> None:
149
+ try:
150
+ st = os.stat(self.filename)
151
+ except FileNotFoundError:
152
+ if self._mtime is not None or not self._users:
153
+ self._logger.warning(
154
+ "users file '%s' not found - nobody can log in. "
155
+ "create users with 'tinyjukebox-users -f %s add NAME'.",
156
+ self.filename,
157
+ self.filename,
158
+ )
159
+ self._users = {}
160
+ self._mtime = None
161
+ return
162
+ if st.st_mtime_ns == self._mtime:
163
+ return
164
+ if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
165
+ self._logger.warning("users file '%s' is readable by other users - use 'chmod 600'.", self.filename)
166
+ try:
167
+ self._users = self.read(self.filename)
168
+ except (ValueError, yaml.YAMLError) as e:
169
+ self._logger.error("cannot read users file: %s - keeping the previous users.", e)
170
+ self._mtime = st.st_mtime_ns
171
+
172
+ def roles(self) -> dict[str, str]:
173
+ """The role of every user."""
174
+ with self._lock:
175
+ self._reload()
176
+ return {name: str(user["role"]) for name, user in self._users.items()}
177
+
178
+ def change(self, change: Callable[[dict[str, dict[str, Any]]], None]) -> None:
179
+ """Apply ``change`` to the users of the file and write it back. Whatever ``change`` raises leaves the file
180
+ as it is.
181
+
182
+ :raises ValueError: if the file is not a valid users file."""
183
+ with self._lock:
184
+ users = self.read(self.filename)
185
+ change(users)
186
+ self.write(self.filename, users)
187
+ # not via _reload: on a coarse file system the new file may have the mtime of the old one
188
+ self._users = users
189
+ self._mtime = os.stat(self.filename).st_mtime_ns
190
+
191
+ def authenticate(self, username: str, password: str) -> str | None:
192
+ """The role of the user - None for unknown users or wrong passwords."""
193
+ with self._lock:
194
+ self._reload()
195
+ user = self._users.get(username)
196
+ with _VERIFY_LOCK:
197
+ if user is None:
198
+ verify_password(password, _DUMMY_HASH)
199
+ return None
200
+ return str(user["role"]) if verify_password(password, user["password-hash"]) else None
201
+
202
+ def get_role(self, username: str) -> str | None:
203
+ """Current role of the user - None if the user has been removed in the meantime."""
204
+ with self._lock:
205
+ self._reload()
206
+ user = self._users.get(username)
207
+ return None if user is None else str(user["role"])
208
+
209
+ def credential(self, username: str) -> str | None:
210
+ """What a session remembers of the password of its user: it changes with every new password, set on the page
211
+ or with ``tinyjukebox-users``. None for an unknown user."""
212
+ with self._lock:
213
+ self._reload()
214
+ user = self._users.get(username)
215
+ return None if user is None else _credential(str(user["password-hash"]))
216
+
217
+ def session_role(self, session: "Session") -> str | None:
218
+ """The current role of the user of the session - None if the user has been removed or has a new password
219
+ since the session began: then the session has ended."""
220
+ with self._lock:
221
+ self._reload()
222
+ user = self._users.get(session.username)
223
+ if user is None or _credential(str(user["password-hash"])) != session.credential:
224
+ return None
225
+ return str(user["role"])
226
+
227
+
228
+ def _credential(password_hash: str) -> str:
229
+ """A fingerprint of a password hash - a session keeps this, not the hash."""
230
+ return hashlib.sha256(password_hash.encode("utf-8")).hexdigest()
231
+
232
+
233
+ class Session:
234
+ def __init__(self, username: str, role: str, expires: float, active: float, credential: str = "") -> None:
235
+ self.username = username
236
+ self.role = role
237
+ self.expires = expires
238
+ self.active = active # when the user did something last
239
+ self.credential = credential # of the password the session began with (UsersFile.credential)
240
+
241
+
242
+ class Sessions:
243
+ """Random session tokens; only their sha256 hash is kept. Sessions expire after ``timeout`` seconds without
244
+ use, and after ``idle_timeout`` seconds in which the user did nothing (0: never) - an open page asks all the time,
245
+ so only what it reports as done by the user counts for that."""
246
+
247
+ def __init__(self, timeout: float, clock: Clock = time.monotonic, idle_timeout: float = 0) -> None:
248
+ self._timeout = timeout
249
+ self.idle_timeout = idle_timeout
250
+ self._clock = clock
251
+ self._sessions: dict[str, Session] = {}
252
+ self._lock = threading.Lock()
253
+
254
+ @staticmethod
255
+ def _key(token: str) -> str:
256
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
257
+
258
+ def create(self, username: str, role: str, credential: str = "") -> str:
259
+ token = secrets.token_urlsafe(32)
260
+ with self._lock:
261
+ self._purge()
262
+ now = self._clock()
263
+ self._sessions[self._key(token)] = Session(username, role, now + self._timeout, now, credential)
264
+ return token
265
+
266
+ def get(self, token: str | None, idle: float | None = 0) -> Session | None:
267
+ """The session of the token (its expiry is extended) - None for unknown or expired tokens.
268
+
269
+ ``idle``: the user did something last this many seconds ago - 0 for a request that is an action itself, None
270
+ for one that says nothing about the user (the page asking by itself)."""
271
+ if not token:
272
+ return None
273
+ key = self._key(token)
274
+ with self._lock:
275
+ session = self._sessions.get(key)
276
+ if session is None:
277
+ return None
278
+ now = self._clock()
279
+ if idle is not None:
280
+ session.active = max(session.active, now - max(idle, 0))
281
+ if session.expires < now or (self.idle_timeout > 0 and now - session.active > self.idle_timeout):
282
+ del self._sessions[key]
283
+ return None
284
+ session.expires = now + self._timeout
285
+ return session
286
+
287
+ def remove(self, token: str) -> None:
288
+ with self._lock:
289
+ self._sessions.pop(self._key(token), None)
290
+
291
+ def remove_user(self, username: str, keep: str | None = None, credential: str | None = None) -> None:
292
+ """Ends all sessions of the user - except the one of the token ``keep``, which gets ``credential`` if given:
293
+ the user who has just set a new password stays logged in."""
294
+ keep_key = self._key(keep) if keep else None
295
+ with self._lock:
296
+ for key in [k for k, s in self._sessions.items() if s.username == username and k != keep_key]:
297
+ del self._sessions[key]
298
+ kept = self._sessions.get(keep_key) if keep_key else None
299
+ if kept is not None and kept.username == username and credential is not None:
300
+ kept.credential = credential
301
+
302
+ def _purge(self) -> None:
303
+ """Forget the sessions that have expired. Whether a session in which the user did nothing for too long ends is
304
+ decided when it is used (:meth:`get`): a page that is opened again counts as doing something - not whether
305
+ somebody else logged in meanwhile."""
306
+ now = self._clock()
307
+ for key in [k for k, s in self._sessions.items() if s.expires < now]:
308
+ del self._sessions[key]
309
+
310
+
311
+ class LoginThrottle:
312
+ """After ``max_failures`` failed logins from one address, every further failed attempt blocks the address for
313
+ ``lockout`` seconds. Failures are forgotten ``reset_after`` seconds after the last failure or with a successful
314
+ login.
315
+
316
+ At most ``max_addresses`` addresses are tracked. If there are more, addresses that are not blocked are forgotten
317
+ first (oldest failure first) - flooding the table from many addresses must not unblock an attacked address.
318
+ """
319
+
320
+ def __init__(
321
+ self,
322
+ max_failures: int = 5,
323
+ lockout: float = 30,
324
+ reset_after: float = 900,
325
+ clock: Clock = time.monotonic,
326
+ max_addresses: int = 10000,
327
+ ) -> None:
328
+ self._max_failures = max_failures
329
+ self._lockout = lockout
330
+ self._reset_after = reset_after
331
+ self._clock = clock
332
+ self._max_addresses = max_addresses
333
+ self._failures: dict[str, list[float]] = {} # address -> [count, time of last failure, blocked until]
334
+ self._lock = threading.Lock()
335
+
336
+ def _entry(self, address: str) -> list[float] | None:
337
+ entry = self._failures.get(address)
338
+ if entry is not None and self._clock() - entry[1] > self._reset_after:
339
+ del self._failures[address]
340
+ entry = None
341
+ return entry
342
+
343
+ def retry_after(self, address: str) -> int:
344
+ """Seconds until the next login attempt is allowed (0: allowed now)."""
345
+ with self._lock:
346
+ entry = self._entry(address)
347
+ if entry is None:
348
+ return 0
349
+ return max(0, int(entry[2] - self._clock() + 0.999))
350
+
351
+ def failed(self, address: str) -> None:
352
+ with self._lock:
353
+ now = self._clock()
354
+ entry = self._entry(address)
355
+ if entry is None:
356
+ entry = self._failures[address] = [0, now, 0]
357
+ entry[0] += 1
358
+ entry[1] = now
359
+ if entry[0] >= self._max_failures:
360
+ entry[2] = now + self._lockout
361
+ if len(self._failures) > self._max_addresses:
362
+ self._shrink(now)
363
+
364
+ def _shrink(self, now: float) -> None:
365
+ excess = len(self._failures) - self._max_addresses
366
+ # not blocked before blocked, then oldest failure first
367
+ order = sorted(self._failures.items(), key=lambda item: (item[1][2] > now, item[1][1]))
368
+ for address, _ in order[:excess]:
369
+ del self._failures[address]
370
+
371
+ def succeeded(self, address: str) -> None:
372
+ with self._lock:
373
+ self._failures.pop(address, None)
@@ -0,0 +1,111 @@
1
+ """Self signed certificate for the rest api - created with the openssl command line tool on first start."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import socket
7
+ import ssl
8
+ import subprocess
9
+ from collections.abc import Iterable
10
+
11
+ __all__ = ["create_self_signed_certificate", "create_ssl_context", "ensure_certificate", "fingerprint"]
12
+
13
+
14
+ def _is_ip(name: str) -> bool:
15
+ for family in (socket.AF_INET, socket.AF_INET6):
16
+ try:
17
+ socket.inet_pton(family, name)
18
+ except OSError:
19
+ continue
20
+ return True
21
+ return False
22
+
23
+
24
+ def _subject_alt_names(hostnames: Iterable[str] | None) -> str:
25
+ names = ["localhost", socket.gethostname(), socket.gethostname() + ".local", *(hostnames or [])]
26
+ entries: list[str] = []
27
+ for name in [*names, "127.0.0.1"]:
28
+ entry = f"{'IP' if _is_ip(name) else 'DNS'}:{name}"
29
+ if entry not in entries:
30
+ entries.append(entry)
31
+ return ",".join(entries)
32
+
33
+
34
+ def _create_private_file(filename: str) -> None:
35
+ """Empty file with mode 600 - openssl writes the key into it, so the key is never readable by others.
36
+
37
+ (The previous version changed the umask of the whole process, which affected files created by other threads.)
38
+ """
39
+ if os.path.exists(filename):
40
+ os.chmod(filename, 0o600)
41
+ else:
42
+ os.close(os.open(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
43
+
44
+
45
+ def create_self_signed_certificate(
46
+ certificate_file: str, key_file: str, hostnames: Iterable[str] | None = None, days: int = 3650
47
+ ) -> None:
48
+ """Create an ec (prime256v1) key and a self signed certificate for this host (valid for ``days`` days).
49
+
50
+ :raises RuntimeError: if openssl is missing or fails.
51
+ """
52
+ for filename in (certificate_file, key_file):
53
+ os.makedirs(os.path.dirname(os.path.abspath(filename)), mode=0o700, exist_ok=True)
54
+ _create_private_file(key_file)
55
+ try:
56
+ result = subprocess.run(
57
+ [
58
+ "openssl",
59
+ "req",
60
+ "-x509",
61
+ "-newkey",
62
+ "ec",
63
+ "-pkeyopt",
64
+ "ec_paramgen_curve:prime256v1",
65
+ "-nodes",
66
+ "-sha256",
67
+ "-days",
68
+ str(days),
69
+ "-subj",
70
+ f"/CN={socket.gethostname()}/O=Tiny Jukebox",
71
+ "-addext",
72
+ f"subjectAltName={_subject_alt_names(hostnames)}",
73
+ "-keyout",
74
+ key_file,
75
+ "-out",
76
+ certificate_file,
77
+ ],
78
+ capture_output=True,
79
+ text=True,
80
+ check=False,
81
+ )
82
+ except FileNotFoundError:
83
+ raise RuntimeError("openssl not found - install openssl or provide certificate and private key.") from None
84
+ if result.returncode != 0:
85
+ raise RuntimeError(f"creating the certificate failed: {result.stderr.strip()}")
86
+ os.chmod(key_file, 0o600)
87
+
88
+
89
+ def fingerprint(certificate_file: str) -> str:
90
+ """SHA-256 fingerprint as shown by browsers (AA:BB:...)."""
91
+ with open(certificate_file, encoding="ascii") as f:
92
+ der = ssl.PEM_cert_to_DER_cert(f.read())
93
+ digest = hashlib.sha256(der).hexdigest().upper()
94
+ return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
95
+
96
+
97
+ def ensure_certificate(
98
+ certificate_file: str, key_file: str, logger: logging.Logger, hostnames: Iterable[str] | None = None
99
+ ) -> str:
100
+ """Create certificate and key if one of them is missing. Returns the fingerprint of the certificate."""
101
+ if not (os.path.exists(certificate_file) and os.path.exists(key_file)):
102
+ logger.info("creating self signed certificate '%s'.", certificate_file)
103
+ create_self_signed_certificate(certificate_file, key_file, hostnames)
104
+ return fingerprint(certificate_file)
105
+
106
+
107
+ def create_ssl_context(certificate_file: str, key_file: str) -> ssl.SSLContext:
108
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
109
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
110
+ context.load_cert_chain(certificate_file, key_file)
111
+ return context
@@ -0,0 +1,62 @@
1
+ """Keeps the latest log records in memory - shown on the web page."""
2
+
3
+ import collections
4
+ import itertools
5
+ import logging
6
+ import threading
7
+ from typing import Any
8
+
9
+ __all__ = ["LEVELS", "LogBuffer"]
10
+
11
+ LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
12
+
13
+
14
+ class LogBuffer(logging.Handler):
15
+ """Log handler with the newest ``capacity`` records; every record gets an increasing id."""
16
+
17
+ def __init__(self, capacity: int = 5000) -> None:
18
+ super().__init__(logging.DEBUG)
19
+ self._records: collections.deque[dict[str, Any]] = collections.deque(maxlen=capacity)
20
+ self._counter = itertools.count(1)
21
+ self._lock_records = threading.Lock()
22
+
23
+ def emit(self, record: logging.LogRecord) -> None:
24
+ try:
25
+ message = record.getMessage()
26
+ if record.exc_info:
27
+ message += "\n" + logging.Formatter().formatException(record.exc_info)
28
+ except Exception: # a broken log call must not break the logging
29
+ message = str(record.msg)
30
+ with self._lock_records:
31
+ self._records.append(
32
+ {
33
+ "id": next(self._counter),
34
+ "time": record.created,
35
+ "level": record.levelname,
36
+ "logger": record.name,
37
+ "thread": record.threadName,
38
+ "message": message,
39
+ }
40
+ )
41
+
42
+ @property
43
+ def last_id(self) -> int:
44
+ """Id of the newest record (0: none yet)."""
45
+ with self._lock_records:
46
+ return self._records[-1]["id"] if self._records else 0
47
+
48
+ def get(self, after: int = 0, level: str = "DEBUG", text: str | None = None, limit: int = 500) -> dict[str, Any]:
49
+ """Records with id > after, at least ``level``, containing ``text`` (case insensitive) - the newest
50
+ ``limit``."""
51
+ minimum = LEVELS.index(level) if level in LEVELS else 0
52
+ needle = text.lower() if text else None
53
+ with self._lock_records:
54
+ records = list(self._records)
55
+ result = [
56
+ r
57
+ for r in records
58
+ if r["id"] > after
59
+ and (r["level"] not in LEVELS or LEVELS.index(r["level"]) >= minimum)
60
+ and (needle is None or needle in r["message"].lower() or needle in r["logger"].lower())
61
+ ]
62
+ return {"entries": result[-limit:], "last_id": records[-1]["id"] if records else after}