pyobs-core 2.0.0.dev91__py3-none-any.whl → 2.0.0.dev92__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.
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import hashlib
3
+ import hmac
2
4
  import io
3
5
  import json
4
6
  import logging
@@ -39,6 +41,12 @@ INDEX_HTML = """
39
41
 
40
42
  """
41
43
 
44
+ # session cookie for browser login (see _check_auth); only used when a token is configured
45
+ _COOKIE_NAME = "pyobs_video_session"
46
+ _COOKIE_LIFETIME = 24 * 60 * 60 # 24 h, in seconds
47
+ # delay on a wrong login password, to slow brute force (compare is constant-time either way)
48
+ _LOGIN_FAILURE_SLEEP = 1.0
49
+
42
50
 
43
51
  async def calc_expose_timeout(webcam: IExposureTime, *args: Any, **kwargs: Any) -> float:
44
52
  """Calculates timeout for grabe_image()."""
@@ -71,7 +79,14 @@ class LastImage(NamedTuple):
71
79
 
72
80
 
73
81
  class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCMeta):
74
- """Base class for all webcam modules."""
82
+ """Base class for all webcam modules.
83
+
84
+ The built-in HTTP server serves the MJPEG live view, the raw frame stream and cached FITS
85
+ images. Set the ``token`` parameter to protect all of them: machine clients send
86
+ ``Authorization: Bearer <token>`` (as :class:`pyobs.vfs.HttpFile` does), browsers log in once
87
+ at ``/login`` and get an HMAC-signed session cookie that rides the same-origin ``<img>``
88
+ requests. Without a token (the default), no auth is enforced.
89
+ """
75
90
 
76
91
  __module__ = "pyobs.modules.camera"
77
92
 
@@ -90,6 +105,7 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
90
105
  flip: bool = False,
91
106
  sleep_time: int = 60,
92
107
  fits_header_timeout: float = 15.0,
108
+ token: str | None = None,
93
109
  **kwargs: Any,
94
110
  ):
95
111
  """Creates a new BaseWebcam.
@@ -112,6 +128,8 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
112
128
  flip: Whether to flip around Y axis.
113
129
  sleep_time: Time in s with inactivity after which the camera should go to sleep.
114
130
  fits_header_timeout: Maximum seconds to wait for a peer's FITS headers before skipping them.
131
+ token: Shared secret required in the "Authorization: Bearer <token>" header (or a
132
+ login-page cookie) for stream and data access. If None (default), no auth is enforced.
115
133
  """
116
134
  super().__init__(
117
135
  fits_namespaces=fits_namespaces,
@@ -153,12 +171,23 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
153
171
  self._cache = DataCache(cache_size)
154
172
 
155
173
  # define web server
174
+ self._token = token
175
+ # serializes /login attempts so _LOGIN_FAILURE_SLEEP actually caps the guess rate;
176
+ # without it, concurrent connections each sleep independently and the rate scales
177
+ # with concurrency instead of being capped
178
+ self._login_lock = asyncio.Lock()
156
179
  self._app = web.Application()
157
180
  routes = [web.get("/ping", self.ping_handler), web.get("/{filename}", self.image_handler)]
158
181
  if self._video_path is not None:
159
182
  routes += [web.get("/", self.web_handler), web.get("/video.mjpg", self.video_handler)]
160
183
  if self._raw_path is not None:
161
184
  routes.append(web.get("/video.raw", self.raw_handler))
185
+ if self._token is not None:
186
+ routes += [
187
+ web.get("/login", self.login_handler),
188
+ web.post("/login", self.login_post_handler),
189
+ web.get("/logout", self.logout_handler),
190
+ ]
162
191
  self._app.add_routes(routes)
163
192
  self._runner = web.AppRunner(self._app)
164
193
  self._site: web.TCPSite | None = None
@@ -196,6 +225,165 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
196
225
  """Whether the server is started."""
197
226
  return self._is_listening
198
227
 
228
+ def _sign(self, expiry: int) -> str:
229
+ """HMAC-SHA256 of ``expiry``, keyed with the configured token, as a hex digest.
230
+
231
+ Shared by _make_session_value (signs) and _check_cookie (verifies), so the two can't
232
+ drift apart on hash algo/encoding.
233
+
234
+ Args:
235
+ expiry: Session expiry timestamp being signed.
236
+
237
+ Returns:
238
+ Hex-encoded HMAC signature.
239
+ """
240
+ token = self._token
241
+ if token is None:
242
+ raise ValueError("Cannot sign a session without a configured token.")
243
+ return hmac.new(token.encode(), str(expiry).encode(), hashlib.sha256).hexdigest()
244
+
245
+ def _make_session_value(self) -> str:
246
+ """Build the value for the login session cookie.
247
+
248
+ Stateless and HMAC-signed -- the cookie does not contain the token itself:
249
+
250
+ ``value = "{expiry_ts}.{hex}"`` where ``hex = HMAC-SHA256(key=token, msg=str(expiry_ts))``
251
+
252
+ Returns:
253
+ Cookie value.
254
+ """
255
+ expiry = int(time.time()) + _COOKIE_LIFETIME
256
+ return f"{expiry}.{self._sign(expiry)}"
257
+
258
+ def _check_bearer(self, request: web.Request) -> bool:
259
+ """Whether the request carries the token as "Authorization: Bearer <token>".
260
+
261
+ Only called (via _check_auth) once a token is configured.
262
+
263
+ Args:
264
+ request: Request to check.
265
+
266
+ Returns:
267
+ True if the header matches the configured token.
268
+ """
269
+ prefix = "Bearer "
270
+ header = request.headers.get("Authorization", "")
271
+ if not header.startswith(prefix):
272
+ return False
273
+ return hmac.compare_digest(header[len(prefix) :], self._token or "")
274
+
275
+ def _check_cookie(self, request: web.Request) -> bool:
276
+ """Whether the request carries a valid, unexpired session cookie.
277
+
278
+ Only called (via _check_auth) once a token is configured.
279
+
280
+ Args:
281
+ request: Request to check.
282
+
283
+ Returns:
284
+ True if the cookie's HMAC signature verifies and it has not expired.
285
+ """
286
+ value = request.cookies.get(_COOKIE_NAME)
287
+ if value is None:
288
+ return False
289
+ try:
290
+ expiry_str, signature = value.rsplit(".", 1)
291
+ expiry = int(expiry_str)
292
+ except ValueError:
293
+ return False
294
+ if expiry < time.time():
295
+ return False
296
+ return hmac.compare_digest(signature, self._sign(expiry))
297
+
298
+ def _check_auth(self, request: web.Request) -> None:
299
+ """Raises HTTPUnauthorized if a token is configured and the request doesn't carry it.
300
+
301
+ Accepts either a valid "Authorization: Bearer <token>" header or a valid session cookie.
302
+ No-op when no token is configured. Same contract as HttpFileCache._check_auth: the
303
+ exception type is identical regardless of which handler called it.
304
+
305
+ Args:
306
+ request: Request to check.
307
+ """
308
+ if self._token is None:
309
+ return
310
+ if not (self._check_bearer(request) or self._check_cookie(request)):
311
+ raise web.HTTPUnauthorized()
312
+
313
+ async def login_handler(self, request: web.Request) -> web.Response:
314
+ """Handles GET access to /login and returns the login form.
315
+
316
+ Served unauthenticated: this is the bootstrap a browser needs, since it cannot attach an
317
+ "Authorization" header to an <img> request. Only registered when a token is configured.
318
+
319
+ Args:
320
+ request: Request to respond to.
321
+
322
+ Returns:
323
+ Response containing the login form.
324
+ """
325
+ html = """<!DOCTYPE html>
326
+ <html lang="en">
327
+ <head>
328
+ <meta charset="UTF-8">
329
+ <title>Login</title>
330
+ </head>
331
+ <body>
332
+ <form method="post" action="/login">
333
+ <label>Password: <input type="password" name="token" autofocus></label>
334
+ <button type="submit">Login</button>
335
+ </form>
336
+ </body>
337
+ </html>
338
+ """
339
+ return web.Response(text=html, content_type="text/html")
340
+
341
+ async def login_post_handler(self, request: web.Request) -> web.Response:
342
+ """Handles POST access to /login, verifying the password and issuing the session cookie.
343
+
344
+ Args:
345
+ request: Request to respond to.
346
+
347
+ Returns:
348
+ 303 redirect to / on success, 401 on wrong password.
349
+ """
350
+ token = self._token
351
+ if token is None:
352
+ # login routes are only registered when a token is configured -- defensive
353
+ raise web.HTTPUnauthorized()
354
+ data = await request.post()
355
+ password = str(data.get("token", ""))
356
+ async with self._login_lock:
357
+ # serialized: caps the guess rate at 1/_LOGIN_FAILURE_SLEEP regardless of how many
358
+ # connections attempt concurrently
359
+ if not hmac.compare_digest(password, token):
360
+ # the compare above is constant-time; this sleep additionally slows brute force
361
+ await asyncio.sleep(_LOGIN_FAILURE_SLEEP)
362
+ raise web.HTTPUnauthorized()
363
+ response = web.HTTPSeeOther("/")
364
+ response.set_cookie(
365
+ _COOKIE_NAME,
366
+ self._make_session_value(),
367
+ max_age=_COOKIE_LIFETIME,
368
+ path="/",
369
+ httponly=True,
370
+ samesite="Lax",
371
+ )
372
+ return response
373
+
374
+ async def logout_handler(self, request: web.Request) -> web.Response:
375
+ """Handles GET access to /logout and clears the session cookie.
376
+
377
+ Args:
378
+ request: Request to respond to.
379
+
380
+ Returns:
381
+ 303 redirect to /login.
382
+ """
383
+ response = web.HTTPSeeOther("/login")
384
+ response.del_cookie(_COOKIE_NAME, path="/")
385
+ return response
386
+
199
387
  async def web_handler(self, request: web.Request) -> web.Response:
200
388
  """Handles access to / and returns HTML page.
201
389
 
@@ -205,6 +393,11 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
205
393
  Returns:
206
394
  Response containing web page.
207
395
  """
396
+ # a browser landing on / should be taken to the login form, not shown a bare 401
397
+ try:
398
+ self._check_auth(request)
399
+ except web.HTTPUnauthorized:
400
+ raise web.HTTPSeeOther("/login")
208
401
  return web.Response(text=INDEX_HTML, content_type="text/html")
209
402
 
210
403
  async def ping_handler(self, request: web.Request) -> web.Response:
@@ -227,6 +420,7 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
227
420
  Returns:
228
421
  Response containing video stream.
229
422
  """
423
+ self._check_auth(request)
230
424
 
231
425
  # create response
232
426
  response = web.StreamResponse()
@@ -273,6 +467,8 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
273
467
  Returns:
274
468
  Response containing raw frame stream.
275
469
  """
470
+ # auth first: an unauthenticated request must not wake the camera
471
+ self._check_auth(request)
276
472
 
277
473
  # activate camera
278
474
  await self.activate_camera()
@@ -368,6 +564,7 @@ class BaseVideo(Module, ImageFitsHeaderMixin, IVideo, IImageType, metaclass=ABCM
368
564
  Returns:
369
565
  Response containing image.
370
566
  """
567
+ self._check_auth(request)
371
568
 
372
569
  # get filename
373
570
  filename = request.match_info["filename"]
pyobs/vfs/httpfile.py CHANGED
@@ -78,6 +78,14 @@ class HttpFile(BufferedFile):
78
78
  raise ValueError("No download URL given.")
79
79
  return urljoin(self._download_path, self.filename)
80
80
 
81
+ @property
82
+ def headers(self) -> dict[str, str]:
83
+ """Headers sent with GET/POST requests, e.g. "Authorization: Bearer <token>".
84
+
85
+ Returns a copy; mutating it does not affect this instance's headers.
86
+ """
87
+ return dict(self._headers)
88
+
81
89
  async def _download(self) -> None:
82
90
  """For read access, download the file into a local buffer.
83
91
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-core
3
- Version: 2.0.0.dev91
3
+ Version: 2.0.0.dev92
4
4
  Summary: robotic telescope software
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -204,7 +204,7 @@ pyobs/modules/camera/__init__.py,sha256=ZlkLRWssKCPi6jIFEw275jXcpv9ORjVCjsT8Ag3f
204
204
  pyobs/modules/camera/adaptive.py,sha256=o06lkJSNnLPT19Abql0npi0RtIARKgn_CROwr7556qU,14013
205
205
  pyobs/modules/camera/basecamera.py,sha256=RSmSTOcMJWlra9q8NA9Azs3tH7nj3brgqhkjkBtG37k,23810
206
206
  pyobs/modules/camera/basespectrograph.py,sha256=49Vyig3NDnw0TFRmj9RmitIbCQ-KRrEaHQqXEyx08AM,7344
207
- pyobs/modules/camera/basevideo.py,sha256=UecEm1C3AbdZl9GukdNfuRpLKar1nFxKgMXSpTPRdSY,20922
207
+ pyobs/modules/camera/basevideo.py,sha256=d6ZW5vEen9HDAbYX3-1KbA1ocAyaWsl45tTxLie27lk,28508
208
208
  pyobs/modules/camera/dummycamera.py,sha256=X46-zuDhxsROyEadNcxgL1qJCvW_DwgaTg8nLyuWlHw,15380
209
209
  pyobs/modules/camera/dummyspectrograph.py,sha256=jQfwQqAfoFhhYC8RahfQT4WvPqtY5gph_r1yqTkjkPs,2400
210
210
  pyobs/modules/camera/dummyvideo.py,sha256=35iabldXQ1ayGooY9Hy2xwZHGQmy9O3I18JDcyqC94k,2367
@@ -432,7 +432,7 @@ pyobs/vfs/__init__.py,sha256=8tf4iRPdHe2QUWEahOT-UuhbjfNGsw88UQSRisJizMY,3985
432
432
  pyobs/vfs/archivefile.py,sha256=qn1RHN61zKVFe1-f26-QCXOS_M4h3b8kuaPvzPzDk44,2462
433
433
  pyobs/vfs/bufferedfile.py,sha256=87X5fwmiqgmbASje1ogo2Z2uwYie3vIoAIq_s21D_nQ,1388
434
434
  pyobs/vfs/file.py,sha256=OQLuP8I5KtNUixPTlntXx-qHZSMtBm_dbZisAyws2tI,2014
435
- pyobs/vfs/httpfile.py,sha256=lO7BtWSgm9BQsBxxq7ZSQRVU2bxvEizaFZwWwvAjo64,5598
435
+ pyobs/vfs/httpfile.py,sha256=0jKS961ZkoToO6u4xz8EHJ4I5wHKicWFXg4G2XaS2Mc,5865
436
436
  pyobs/vfs/localfile.py,sha256=GLel8I_9RrUU5hJMfepBBEvHqgM0HB7sv0ZEgArbv-c,4646
437
437
  pyobs/vfs/memfile.py,sha256=ZSO2K8wT_Nk1rRWNy7KeDB2u3RWzsrT4s8oZAbcCyAI,1794
438
438
  pyobs/vfs/sftpfile.py,sha256=QwRPwdqRVT9uVSov_NKMCrkl4W5SP5I06hjVqEAjq74,3632
@@ -443,8 +443,8 @@ pyobs/vfs/vfs.py,sha256=3cXpRGbWh6m_lm0R7uZUNJcjppBaXSpQVMTRgHnv6Tk,11025
443
443
  pyobs/vfs/filelists/__init__.py,sha256=NaTGFjLGh043j_F7kWEx_cXaII7t3CbYguRk2g5hEqw,111
444
444
  pyobs/vfs/filelists/filelist.py,sha256=kCco_5G2gLH-nxWik-wcs6lGuc_-3LeeyR9L4_srf30,201
445
445
  pyobs/vfs/filelists/testing.py,sha256=M8f2BULdjRCc7JFOKhAH7Kwre_uVQC0xLcv9xGTfNFw,425
446
- pyobs_core-2.0.0.dev91.dist-info/METADATA,sha256=ZL-XfE8ZbL5_QWXEatPWdGKlqHZ_GPpRoyIoPvcH4Dg,1990
447
- pyobs_core-2.0.0.dev91.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
448
- pyobs_core-2.0.0.dev91.dist-info/entry_points.txt,sha256=0ceIn3dTDwWdGdJ5VdTvYim7vKkdTe6aqqpoaG065UY,109
449
- pyobs_core-2.0.0.dev91.dist-info/licenses/LICENSE,sha256=gC8djBliCENDmuRvzCoejpWbsn8eqKx61Hc8bmDZ2LI,1099
450
- pyobs_core-2.0.0.dev91.dist-info/RECORD,,
446
+ pyobs_core-2.0.0.dev92.dist-info/METADATA,sha256=cBK8wb5FClsU5sVDMaLljMKQ8RBFR0_I6SojS5_ZYVA,1990
447
+ pyobs_core-2.0.0.dev92.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
448
+ pyobs_core-2.0.0.dev92.dist-info/entry_points.txt,sha256=0ceIn3dTDwWdGdJ5VdTvYim7vKkdTe6aqqpoaG065UY,109
449
+ pyobs_core-2.0.0.dev92.dist-info/licenses/LICENSE,sha256=gC8djBliCENDmuRvzCoejpWbsn8eqKx61Hc8bmDZ2LI,1099
450
+ pyobs_core-2.0.0.dev92.dist-info/RECORD,,