better-rtplot 0.4.2__tar.gz → 0.4.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: better-rtplot
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary:
5
5
  License: GPL V3.0
6
6
  Author: jmontp
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -160,12 +160,26 @@ parser.add_argument(
160
160
  default=1000,
161
161
  )
162
162
 
163
+ parser.add_argument(
164
+ "--password",
165
+ help=(
166
+ "Gate the whole UI behind a shared password (HTTP Basic). Any"
167
+ " username is accepted. Can also be set via RTPLOT_PASSWORD env"
168
+ " var; the flag wins if both are given. Leave unset to serve"
169
+ " without auth."
170
+ ),
171
+ action="store",
172
+ type=str,
173
+ default=None,
174
+ )
175
+
163
176
  args = parser.parse_args()
164
177
 
165
178
  NEW_SUBPLOT_IN_ROW = args.column
166
179
  DEBUG_TEXT_ENABLED = args.debug
167
180
  SKIP_PLOT_DATAPOINTS = args.skip
168
181
  ADAPT_SKIP_PLOT_DATAPOINTS = args.adaptable
182
+ AUTH_PASSWORD = args.password if args.password is not None else os.environ.get("RTPLOT_PASSWORD")
169
183
 
170
184
 
171
185
  ###############################
@@ -1310,9 +1324,140 @@ async def on_cleanup(app):
1310
1324
  zmq_ctx.term()
1311
1325
 
1312
1326
 
1327
+ ###############################
1328
+ # Cookie-based password auth #
1329
+ ###############################
1330
+
1331
+ # A single shared password unlocks the UI for every browser. We deliberately
1332
+ # don't model users: the browser's native Basic-Auth popup forces a username
1333
+ # field, which was confusing — one password is enough for LAN deployments.
1334
+ # On successful POST /login, the server mints a random session token, stashes
1335
+ # it in an in-memory set, and drops it as an HttpOnly cookie. All other
1336
+ # routes (including the WebSocket upgrade) require that cookie.
1337
+ import hmac as _hmac
1338
+ import secrets as _secrets
1339
+
1340
+ SESSION_COOKIE = "rtplot_session"
1341
+ _AUTH_REQUIRED = AUTH_PASSWORD is not None
1342
+ _AUTH_EXPECTED = AUTH_PASSWORD.encode("utf-8") if AUTH_PASSWORD else None
1343
+ _valid_tokens: set = set()
1344
+
1345
+
1346
+ _LOGIN_HTML = """<!doctype html>
1347
+ <html lang=\"en\">
1348
+ <head>
1349
+ <meta charset=\"utf-8\" />
1350
+ <title>rtplot \u2014 sign in</title>
1351
+ <style>
1352
+ html, body { margin: 0; padding: 0; min-height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #eef1f5; color: #222; }
1353
+ body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
1354
+ form { background: #fff; border: 1px solid #d2d8e0; border-radius: 8px; padding: 28px 32px; box-shadow: 0 6px 24px rgba(30,40,60,0.08); width: 320px; }
1355
+ h1 { margin: 0 0 6px 0; font-size: 20px; color: #1d3566; }
1356
+ p { margin: 0 0 18px 0; color: #666; font-size: 13px; }
1357
+ label { display: block; font-size: 12px; color: #555; margin-bottom: 6px; }
1358
+ input[type=password] { width: 100%; box-sizing: border-box; padding: 9px 10px; font-size: 14px; border: 1px solid #bfc7d2; border-radius: 4px; font-family: inherit; }
1359
+ input[type=password]:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42,93,176,0.15); }
1360
+ button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; border: none; border-radius: 4px; background: #2a5db0; color: #fff; cursor: pointer; font-weight: 600; }
1361
+ button:hover { background: #244f95; }
1362
+ .err { background: #fce8e8; color: #a11; border: 1px solid #f0c2c2; padding: 8px 10px; border-radius: 4px; font-size: 13px; margin-bottom: 14px; }
1363
+ </style>
1364
+ </head>
1365
+ <body>
1366
+ <form method=\"post\" action=\"/login\" autocomplete=\"on\">
1367
+ <h1>rtplot</h1>
1368
+ <p>Enter the shared password to continue.</p>
1369
+ __ERROR_BLOCK__
1370
+ <label for=\"pw\">Password</label>
1371
+ <input id=\"pw\" type=\"password\" name=\"password\" autofocus required />
1372
+ <button type=\"submit\">Sign in</button>
1373
+ </form>
1374
+ </body>
1375
+ </html>
1376
+ """
1377
+
1378
+
1379
+ def _render_login_page(error: bool = False) -> str:
1380
+ err = '<div class="err">Wrong password. Try again.</div>' if error else ""
1381
+ return _LOGIN_HTML.replace("__ERROR_BLOCK__", err)
1382
+
1383
+
1384
+ def _has_valid_session(request) -> bool:
1385
+ tok = request.cookies.get(SESSION_COOKIE)
1386
+ return bool(tok) and tok in _valid_tokens
1387
+
1388
+
1389
+ async def handle_login(request):
1390
+ # GET /login -> render form
1391
+ # POST /login -> check password, set cookie on success
1392
+ if request.method == "GET":
1393
+ if _has_valid_session(request):
1394
+ raise web.HTTPFound("/")
1395
+ return web.Response(
1396
+ text=_render_login_page(),
1397
+ content_type="text/html",
1398
+ headers={"Cache-Control": "no-store"},
1399
+ )
1400
+
1401
+ if not _AUTH_REQUIRED:
1402
+ raise web.HTTPFound("/")
1403
+ data = await request.post()
1404
+ supplied = (data.get("password") or "").encode("utf-8")
1405
+ if _hmac.compare_digest(supplied, _AUTH_EXPECTED or b""):
1406
+ token = _secrets.token_urlsafe(32)
1407
+ _valid_tokens.add(token)
1408
+ resp = web.HTTPFound("/")
1409
+ resp.set_cookie(
1410
+ SESSION_COOKIE,
1411
+ token,
1412
+ httponly=True,
1413
+ samesite="Lax",
1414
+ max_age=30 * 24 * 3600, # 30 days
1415
+ path="/",
1416
+ )
1417
+ raise resp
1418
+ return web.Response(
1419
+ text=_render_login_page(error=True),
1420
+ content_type="text/html",
1421
+ status=401,
1422
+ headers={"Cache-Control": "no-store"},
1423
+ )
1424
+
1425
+
1426
+ async def handle_logout(request):
1427
+ tok = request.cookies.get(SESSION_COOKIE)
1428
+ if tok:
1429
+ _valid_tokens.discard(tok)
1430
+ resp = web.HTTPFound("/login")
1431
+ resp.del_cookie(SESSION_COOKIE, path="/")
1432
+ raise resp
1433
+
1434
+
1435
+ @web.middleware
1436
+ async def session_auth_middleware(request, handler):
1437
+ if not _AUTH_REQUIRED:
1438
+ return await handler(request)
1439
+ path = request.path
1440
+ # Login page and its form post must stay open; the uPlot assets that
1441
+ # the main page pulls in are gated too, which is consistent with the
1442
+ # rest of the UI.
1443
+ if path == "/login" or path == "/logout":
1444
+ return await handler(request)
1445
+ if _has_valid_session(request):
1446
+ return await handler(request)
1447
+ # WebSocket upgrade can't follow a redirect — respond with 401 so the
1448
+ # browser-side reconnect loop surfaces a clear error.
1449
+ if path == "/ws":
1450
+ return web.Response(status=401, text="Not signed in.\n")
1451
+ raise web.HTTPFound("/login")
1452
+
1453
+
1313
1454
  def build_app():
1314
- app = web.Application()
1455
+ middlewares = [session_auth_middleware] if _AUTH_REQUIRED else []
1456
+ app = web.Application(middlewares=middlewares)
1315
1457
  app.router.add_get("/", handle_index)
1458
+ app.router.add_get("/login", handle_login)
1459
+ app.router.add_post("/login", handle_login)
1460
+ app.router.add_get("/logout", handle_logout)
1316
1461
  app.router.add_get("/ws", handle_ws)
1317
1462
  app.router.add_get("/snapshot.html", handle_snapshot)
1318
1463
  static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
@@ -1341,6 +1486,10 @@ def _detect_lan_ips():
1341
1486
  def main():
1342
1487
  app = build_app()
1343
1488
  print(f"rtplot browser server listening on http://localhost:{args.port}")
1489
+ if _AUTH_REQUIRED:
1490
+ print(" auth: shared password required (browsers see a login page)")
1491
+ else:
1492
+ print(" auth: none (set --password or RTPLOT_PASSWORD to gate the UI)")
1344
1493
  for ip in _detect_lan_ips():
1345
1494
  print(f" also reachable at http://{ip}:{args.port}")
1346
1495
  is_wsl = "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ
@@ -68,6 +68,10 @@ def _settings_path():
68
68
  DEFAULT_SETTINGS = {
69
69
  # Most recent demo-sender targets, newest first, capped at 8.
70
70
  "recent_hosts": [],
71
+ # Optional shared password that gates the browser UI. Empty string
72
+ # means "no auth". Stored in plaintext alongside the exe — the
73
+ # threat model is LAN convenience, not cryptographic secrecy.
74
+ "password": "",
71
75
  }
72
76
 
73
77
  MAX_RECENT_HOSTS = 8
@@ -222,10 +226,31 @@ def _parse_wrapper_args(argv):
222
226
  "machine that only has the exe (no Python install)."
223
227
  ),
224
228
  )
229
+ parser.add_argument(
230
+ "--password",
231
+ default=None,
232
+ help=(
233
+ "Gate the UI behind a shared password (HTTP login page). "
234
+ "Overrides the password saved by the GUI's settings file. "
235
+ "Leave unset to use the saved password (or no auth if none "
236
+ "has been set)."
237
+ ),
238
+ )
225
239
  args, rest = parser.parse_known_args(argv)
226
240
  return args, rest
227
241
 
228
242
 
243
+ def _resolve_password(args) -> str:
244
+ """Return the effective password: CLI flag wins, else saved setting.
245
+
246
+ Empty string means "no auth"; anything else gates the UI.
247
+ """
248
+ if args.password is not None:
249
+ return args.password
250
+ settings = _load_settings()
251
+ return settings.get("password", "") or ""
252
+
253
+
229
254
  def _run_headless(args, rest):
230
255
  """Delegate straight to rtplot.server_browser's __main__."""
231
256
  import runpy
@@ -233,6 +258,9 @@ def _run_headless(args, rest):
233
258
  forwarded = [sys.argv[0], "--port", str(args.port), "--host", args.host]
234
259
  if args.pi_ip:
235
260
  forwarded += ["-p", args.pi_ip]
261
+ pw = _resolve_password(args)
262
+ if pw:
263
+ forwarded += ["--password", pw]
236
264
  forwarded += rest
237
265
  sys.argv = forwarded
238
266
  runpy.run_module("rtplot.server_browser", run_name="__main__")
@@ -397,6 +425,11 @@ def _run_with_gui(args, rest):
397
425
 
398
426
  settings = _load_settings()
399
427
 
428
+ # Password resolution order: CLI flag > saved setting > none. Whatever
429
+ # wins is forwarded to server_browser via --password so the server's
430
+ # auth middleware picks it up at import time.
431
+ effective_password = _resolve_password(args)
432
+
400
433
  # Seed sys.argv so server_browser's module-level argparse sees what we
401
434
  # want. --no-browser is always forced in GUI mode because the window
402
435
  # has its own "Open in browser" button.
@@ -410,6 +443,8 @@ def _run_with_gui(args, rest):
410
443
  ]
411
444
  if args.pi_ip:
412
445
  forwarded += ["-p", args.pi_ip]
446
+ if effective_password:
447
+ forwarded += ["--password", effective_password]
413
448
  forwarded += rest
414
449
  sys.argv = forwarded
415
450
 
@@ -590,6 +625,13 @@ def _run_with_gui(args, rest):
590
625
  style="Subhead.TLabel",
591
626
  ).pack(anchor="w", pady=(4, 10))
592
627
 
628
+ if effective_password:
629
+ ttk.Label(
630
+ main,
631
+ text="\U0001F512 Password protection is on \u2014 viewers need the shared password.",
632
+ style="Muted.TLabel",
633
+ ).pack(anchor="w", pady=(0, 8))
634
+
593
635
  # ---- Big URL display (clickable, copy-able) ----
594
636
  url = f"http://localhost:{args.port}"
595
637
  url_var = tk.StringVar(value=url)
@@ -855,6 +897,124 @@ def _run_with_gui(args, rest):
855
897
 
856
898
  poll_demo_stats()
857
899
 
900
+ # --- Access control sub-section ---
901
+ ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
902
+ ttk.Label(
903
+ advanced_frame, text="Access control", style="Section.TLabel"
904
+ ).pack(anchor="w")
905
+ ttk.Label(
906
+ advanced_frame,
907
+ text=(
908
+ "Set a shared password so the browser UI asks anyone on the\n"
909
+ "network to sign in before they can see your plots."
910
+ ),
911
+ style="Help.TLabel",
912
+ ).pack(anchor="w", pady=(2, 6))
913
+
914
+ pw_state_var = tk.StringVar()
915
+ pw_restart_note_var = tk.StringVar(value="")
916
+
917
+ def _refresh_pw_state():
918
+ if effective_password:
919
+ pw_state_var.set("\U0001F512 Password is set")
920
+ else:
921
+ pw_state_var.set("No password (UI is open to anyone who can reach it)")
922
+
923
+ _refresh_pw_state()
924
+ ttk.Label(advanced_frame, textvariable=pw_state_var, style="Muted.TLabel").pack(
925
+ anchor="w"
926
+ )
927
+
928
+ def _open_password_dialog():
929
+ dlg = tk.Toplevel(root)
930
+ dlg.title("Set password")
931
+ dlg.transient(root)
932
+ dlg.grab_set()
933
+ dlg.resizable(False, False)
934
+ try:
935
+ dlg.configure(bg=BG)
936
+ except tk.TclError:
937
+ pass
938
+
939
+ body = ttk.Frame(dlg, padding=18)
940
+ body.pack(fill="both", expand=True)
941
+
942
+ ttk.Label(
943
+ body,
944
+ text="Shared password",
945
+ style="Section.TLabel",
946
+ ).pack(anchor="w")
947
+ ttk.Label(
948
+ body,
949
+ text=(
950
+ "Everyone who opens the rtplot page will be asked for this\n"
951
+ "password. Leave blank + Save to remove password protection."
952
+ ),
953
+ style="Help.TLabel",
954
+ ).pack(anchor="w", pady=(2, 8))
955
+
956
+ pw_var = tk.StringVar(value=settings.get("password", ""))
957
+ show_var = tk.BooleanVar(value=False)
958
+
959
+ entry = ttk.Entry(body, textvariable=pw_var, show="\u2022", width=32)
960
+ entry.pack(anchor="w", fill="x")
961
+ entry.focus_set()
962
+
963
+ def _toggle_show():
964
+ entry.configure(show="" if show_var.get() else "\u2022")
965
+
966
+ ttk.Checkbutton(
967
+ body, text="Show password", variable=show_var, command=_toggle_show
968
+ ).pack(anchor="w", pady=(6, 10))
969
+
970
+ err_var = tk.StringVar(value="")
971
+ ttk.Label(body, textvariable=err_var, foreground="#a11", background=BG).pack(
972
+ anchor="w"
973
+ )
974
+
975
+ def _save_and_close():
976
+ new_pw = pw_var.get()
977
+ settings["password"] = new_pw
978
+ _save_settings(settings)
979
+ if new_pw == effective_password:
980
+ pw_restart_note_var.set("Password unchanged.")
981
+ else:
982
+ pw_restart_note_var.set(
983
+ "Saved. Close and reopen rtplot-server.exe to apply."
984
+ )
985
+ _refresh_pw_state()
986
+ dlg.destroy()
987
+
988
+ def _cancel():
989
+ dlg.destroy()
990
+
991
+ btn_row = ttk.Frame(body)
992
+ btn_row.pack(fill="x", pady=(10, 0))
993
+ ttk.Button(btn_row, text="Save", command=_save_and_close).pack(side="right")
994
+ ttk.Button(btn_row, text="Cancel", command=_cancel).pack(
995
+ side="right", padx=(0, 6)
996
+ )
997
+ # Enter saves, Escape cancels — matches typical form UX.
998
+ dlg.bind("<Return>", lambda _e: _save_and_close())
999
+ dlg.bind("<Escape>", lambda _e: _cancel())
1000
+ # Center over the main window.
1001
+ dlg.update_idletasks()
1002
+ rx, ry = root.winfo_rootx(), root.winfo_rooty()
1003
+ rw, rh = root.winfo_width(), root.winfo_height()
1004
+ dw, dh = dlg.winfo_reqwidth(), dlg.winfo_reqheight()
1005
+ dlg.geometry(f"+{rx + (rw - dw) // 2}+{ry + (rh - dh) // 2}")
1006
+
1007
+ pw_btn_row = ttk.Frame(advanced_frame)
1008
+ pw_btn_row.pack(anchor="w", pady=(6, 0))
1009
+ ttk.Button(
1010
+ pw_btn_row,
1011
+ text=("Change password" if effective_password else "Set password"),
1012
+ command=_open_password_dialog,
1013
+ ).pack(side="left")
1014
+ ttk.Label(
1015
+ pw_btn_row, textvariable=pw_restart_note_var, style="Muted.TLabel"
1016
+ ).pack(side="left", padx=(8, 0))
1017
+
858
1018
  # --- Log sub-section (further collapsable inside advanced) ---
859
1019
  ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
860
1020
  log_state = {"expanded": False}
File without changes
File without changes