librelad 00114a6ce2 setup: fix the Backups dialog, and make dialogs testable at all
Reported after looking at the step: the add button unstyled, the dialog missing
the fields a backup location actually has, and its dropdowns not working. Three
real faults, and one reason all three shipped.

  * "+ Add destination" carried class .setup-add-domain, which I invented. The
    real one is .setup-domain-add, so no rule matched and it rendered as a bare
    browser button in the middle of a styled form.
  * The dialog asked for name / type / host / user / path / password. A backup
    location has SSH port and auth method (key or password — key is the default
    and needs nothing typed), S3 access and secret keys, B2 account id and key,
    and a path mode. It now asks for what each backend needs, with the wording
    taken from the location config so the wizard and the Backup page describe
    the same thing the same way.
  * .setup-field styled input[type=text] and [type=email] but not [type=password]
    or [type=number], so a credential field and the SSH port rendered unstyled
    even inside a correct container.

Only the credentials go through the secret channel — SSH password, S3 secret
key, B2 account key. The rest is ordinary configuration and travels as itself.

The reason all three shipped is that I checked the step by querying the DOM and
never looked at it. Structural checks cannot see an unstyled control, and a
dialog is behind a click so a screenshot cannot reach it either. So:

  lp-shot --eval <route> <js>   run JS in the page and print the result
  LP_SHOT_EVAL=<js>             run JS before a capture — open a dialog, then shoot

and scripts/dev/lp-backup-dialog-test drives the whole thing in a real browser:
opens it, swaps every backend and asserts only that backend's fields show,
toggles SSH auth and asserts the password field follows, submits, and asserts
the credential is not left in the DOM.

Its styling check needed two attempts, which is the point of mutation-testing
it: "is the background transparent" passes for an unstyled button, because a
native button is grey rather than transparent. It now compares the control
against a bare <button> in the same parent, so "no rule matched" is what fails.
Verified: reintroducing the wrong class fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:48:39 +01:00

570 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""lp-shot — headless screenshot of a LibrePortal WebUI route.
lp-shot /admin/system # whole route -> /tmp/webui-shot.png
lp-shot /admin/system /tmp/x.png 12 ".sys-strip" # one element, 12px padding, crisp
Arguments (all optional after the route):
route WebUI path, e.g. /apps/overview (a full http:// URL also works)
out output PNG (default /tmp/webui-shot.png)
pad padding in CSS px around the element clip (default 0)
selector CSS selector — capture just that element (default: full page)
Environment:
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
--eval ROUTE JS run JS in the page and print the result; no screenshot
LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
tab, expand a row. Awaited, so async handlers finish.
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
LP_SHOT_SETTLE extra seconds after load (default 1.5)
LP_SHOT_CHROME chromium binary to use (default: first found on PATH)
The WebUI is behind a login, so every route except / needs a session. On the host
that needs no setup: lp-shot signs one itself from the jwtSecret the backend keeps
in frontend/.auth.json, exactly as /api/auth/login would, and it expires in an hour.
No password is involved — the stored one is a bcrypt hash and is never touched.
Override that when running off-host, or against another instance:
LP_SHOT_TOKEN an existing `libreportal_token` cookie value
LP_SHOT_USER username, and
LP_SHOT_PASS password — posted once to /api/auth/login for a cookie
LP_SHOT_AUTH_FILE path to a .auth.json to sign from
LP_SHOT_VERBOSE say which .auth.json the session was signed from
Drives a headless Chromium over the DevTools protocol: navigate, wait for the SPA
to paint, then capture. Element captures are clipped by the element's real box, so
they stay 1:1 with the page rather than being cropped out of a scaled screenshot.
Page console errors are echoed to stderr — a blank shot is usually a JS error.
DEV TOOL — not part of a release. scripts/dev is `export-ignore`d in
.gitattributes, so this never lands in a user's tarball. It reads the host's
.auth.json to sign itself a session, which is fine on a maintainer's box (you
already own that file) and has no business in a shipped install.
Requires: a chromium/chrome binary, and python3-websockets. On Ubuntu:
sudo snap install chromium && sudo apt install python3-websockets
Install with:
sudo install -m 755 scripts/dev/lp-shot /usr/local/bin/lp-shot
"""
import base64
import hashlib
import hmac
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.parse
import urllib.request
from websockets.sync.client import connect
DEFAULT_OUT = "/tmp/webui-shot.png"
def containers_root():
"""Where app data lives on THIS install, not where it lives by default.
The containers root is relocatable (--containers-dir), and init.sh bakes the
resolved value into the root-owned CLI wrapper. Reading it back from there
is the only way to find the WebUI on an install whose data sits on another
disk; hardcoding /libreportal-containers meant lp-shot silently fell back to
a default port and a missing .auth.json, which looks exactly like a WebUI
that failed to boot.
"""
if os.environ.get("LP_CONTAINERS_DIR"):
return os.environ["LP_CONTAINERS_DIR"].rstrip("/")
try:
with open("/usr/local/lib/libreportal/libreportal") as fh:
m = re.search(r'^LP_CONTAINERS_DIR="([^"]+)"', fh.read(), re.M)
if m and "__" not in m.group(1):
return m.group(1).rstrip("/")
except OSError:
pass
return "/libreportal-containers"
COMPOSE = containers_root() + "/libreportal/docker-compose.yml"
def die(msg, code=1):
print(f"lp-shot: {msg}", file=sys.stderr)
sys.exit(code)
def base_url():
"""Where the WebUI lives: env wins, else the live compose's published port."""
if os.environ.get("LP_SHOT_URL"):
return os.environ["LP_SHOT_URL"].rstrip("/")
try:
with open(COMPOSE) as fh:
m = re.search(r'^\s*-\s*"(\d+):\d+"', fh.read(), re.M)
if m:
return f"http://localhost:{m.group(1)}"
except OSError:
pass
return "http://localhost:3179"
COOKIE = "libreportal_token"
AUTH_FILES = [
os.environ.get("LP_SHOT_AUTH_FILE"),
containers_root() + "/libreportal/frontend/.auth.json",
os.path.expanduser(
"~/Documents/LibrePortal/LibrePortal/containers/libreportal/frontend/.auth.json"),
]
def jwt_hs256(payload, secret):
"""Sign a JWT the way the backend's jsonwebtoken does (HS256, compact form)."""
def seg(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=")
head = seg(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
body = seg(json.dumps(payload, separators=(",", ":")).encode())
signed = head + b"." + body
sig = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
return (signed + b"." + seg(sig)).decode()
def mint_token():
"""Sign our own session from the host's own JWT secret — no password needed.
The backend keeps {username, passwordHash, jwtSecret} in frontend/.auth.json
and mints session cookies as jwt.sign({sub: username}, jwtSecret). The
password is bcrypt-hashed and unrecoverable, but the secret is right there in
plaintext, so a tool running on the host can issue itself the same cookie the
login endpoint would hand out. That's what makes lp-shot zero-config here.
Deliberately short-lived: this token exists for one screenshot run, not as a
standing credential.
"""
for path in filter(None, AUTH_FILES):
try:
with open(path) as fh:
data = json.load(fh)
except (OSError, ValueError):
continue
secret, user = data.get("jwtSecret"), data.get("username")
if not (secret and user):
continue
now = int(time.time())
return jwt_hs256({"sub": user, "iat": now, "exp": now + 3600}, secret), path
return None, None
def session_token(base):
"""A `libreportal_token` value, or None if we could not get one.
Three ways, in order: handed to us (LP_SHOT_TOKEN), exchanged for one at the
login endpoint using credentials in the environment, or — the usual case on
the host itself — signed locally from .auth.json's jwtSecret. Nothing is
cached: the cookie is injected for this run and the profile is thrown away.
"""
tok = os.environ.get("LP_SHOT_TOKEN")
if tok:
return tok.strip()
user, pw = os.environ.get("LP_SHOT_USER"), os.environ.get("LP_SHOT_PASS")
if not (user and pw):
tok, src = mint_token()
if tok:
if os.environ.get("LP_SHOT_VERBOSE"):
print(f"lp-shot: signed a session from {src}", file=sys.stderr)
return tok
return None
body = json.dumps({"username": user, "password": pw}).encode()
req = urllib.request.Request(f"{base}/api/auth/login", data=body,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
for header, value in resp.getheaders():
if header.lower() == "set-cookie" and value.startswith(COOKIE + "="):
return value.split("=", 1)[1].split(";")[0]
except urllib.error.HTTPError as e:
detail = "invalid credentials" if e.code == 401 else \
"rate-limited, wait it out" if e.code == 429 else f"HTTP {e.code}"
die(f"login as {user!r} failed: {detail}")
except urllib.error.URLError as e:
die(f"cannot reach {base}: {e.reason}")
die("login succeeded but returned no session cookie")
def check_token(base, token):
req = urllib.request.Request(f"{base}/api/auth/status",
headers={"Cookie": f"{COOKIE}={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return bool(json.loads(resp.read()).get("authenticated"))
except (urllib.error.URLError, ValueError):
return False
LOGIN_PROBE = "!!document.getElementById('login-form')"
AUTH_HELP = (
"the WebUI needs a session, and none could be signed locally.\n"
" - on the host: check .auth.json is readable (tried: %s)\n"
" - elsewhere: export LP_SHOT_TOKEN=... (the libreportal_token cookie)\n"
" or LP_SHOT_USER=admin LP_SHOT_PASS=...\n"
"See `lp-shot --help`." % ", ".join(p for p in AUTH_FILES if p)
)
def find_chrome():
env = os.environ.get("LP_SHOT_CHROME")
if env:
return env
for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"):
path = shutil.which(name)
if path:
return path
die("no chromium found — install one (`sudo snap install chromium`) or set LP_SHOT_CHROME")
def profile_dir(chrome):
"""A throwaway user-data-dir the browser can actually write to.
The snap build is confined: it cannot see /tmp or dot-directories in $HOME,
so park the profile under its own SNAP_USER_COMMON. Everything else gets a
normal temp dir.
One profile per run, never a shared path: chromium refuses to start on a
directory another instance still holds, and a snap-confined browser can't be
killed from outside (AppArmor drops the signal even for root), so a single
wedged run would otherwise brick the tool until someone rebooted.
"""
if "/snap/" in os.path.realpath(chrome) or chrome.startswith("/snap/"):
home = os.path.expanduser("~/snap/chromium/common")
os.makedirs(home, exist_ok=True)
sweep_stale(home)
return tempfile.mkdtemp(prefix="lp-shot-", dir=home)
return tempfile.mkdtemp(prefix="lp-shot-")
def sweep_stale(home):
"""Drop lp-shot profiles left behind by runs that died mid-flight."""
cutoff = time.time() - 3600
try:
for name in os.listdir(home):
path = os.path.join(home, name)
if name.startswith("lp-shot-") and os.path.isdir(path) \
and os.path.getmtime(path) < cutoff:
shutil.rmtree(path, ignore_errors=True)
except OSError:
pass
def launch(chrome, width, height):
prof = profile_dir(chrome)
port_file = os.path.join(prof, "DevToolsActivePort")
try:
os.remove(port_file)
except OSError:
pass
proc = subprocess.Popen(
[
chrome,
"--headless=new",
"--remote-debugging-port=0", # port 0 -> the real one lands in DevToolsActivePort
f"--user-data-dir={prof}",
f"--window-size={width},{height}",
"--disable-gpu",
"--hide-scrollbars",
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-background-networking",
"--disable-features=Translate,MediaRouter",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(300): # up to 30s — a cold snap start is slow
if proc.poll() is not None:
die("chromium exited before it opened a debugging port")
try:
with open(port_file) as fh:
lines = fh.read().split("\n")
if lines and lines[0].strip():
return proc, prof, int(lines[0].strip())
except OSError:
pass
time.sleep(0.1)
proc.kill()
die("timed out waiting for chromium's debugging port")
def page_socket(port):
for _ in range(100):
try:
raw = urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2).read()
for t in json.loads(raw):
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
return t["webSocketDebuggerUrl"]
except (urllib.error.URLError, socket.timeout, ValueError):
pass
time.sleep(0.1)
die("chromium never exposed a page target")
class CDP:
def __init__(self, ws_url):
# max_size=None: a full-page screenshot is far past the 1MB default frame cap.
self.ws = connect(ws_url, max_size=None, open_timeout=20)
self.n = 0
self.events = []
def send(self, method, timeout=60, **params):
self.n += 1
self.ws.send(json.dumps({"id": self.n, "method": method, "params": params}))
deadline = time.time() + timeout
while time.time() < deadline:
msg = json.loads(self.ws.recv(timeout=max(1, deadline - time.time())))
if msg.get("id") == self.n:
if "error" in msg:
die(f"{method}: {msg['error'].get('message')}")
return msg.get("result", {})
if "method" in msg:
self.events.append(msg)
die(f"{method}: timed out")
def drain(self):
"""Collect any events that arrived while we weren't listening."""
while True:
try:
msg = json.loads(self.ws.recv(timeout=0.05))
except Exception:
return
if "method" in msg:
self.events.append(msg)
def eval(self, expr):
r = self.send("Runtime.evaluate", expression=expr, returnByValue=True, awaitPromise=True)
if r.get("exceptionDetails"):
return None
return r.get("result", {}).get("value")
def close(self):
try:
self.ws.close()
except Exception:
pass
def main():
# Print a session cookie and exit. A screenshot is enough for "does it
# render", but not for "does this wizard step work" — that needs clicking,
# which means driving a real browser, which needs the same session lp-shot
# already knows how to mint. Without this the only way in is typing the
# admin password into the login form.
#
# lp-shot --token -> the raw cookie VALUE
# lp-shot --cookie-js -> a document.cookie assignment to paste/eval
if len(sys.argv) > 1 and sys.argv[1] in ("--token", "--cookie-js"):
token, src = mint_token()
if os.environ.get("LP_SHOT_VERBOSE"):
print(f"signed from {src}", file=sys.stderr)
if sys.argv[1] == "--token":
print(token)
else:
print(f'document.cookie = "{COOKIE}={token}; path=/"')
sys.exit(0)
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print(__doc__.strip())
sys.exit(0 if len(sys.argv) > 1 else 2)
# --eval: drive the page and print what the expression returns, instead of
# taking a picture.
#
# A screenshot shows a route; it cannot assert anything about a dialog, and
# a dialog is state you reach by clicking. Without this, anything behind a
# click gets checked by reading the source and hoping — which is how a step
# ships with a button styled by a class that does not exist.
#
# lp-shot --eval /route 'document.querySelectorAll("x").length'
eval_mode = sys.argv[1] == "--eval"
if eval_mode:
if len(sys.argv) < 4:
die("usage: lp-shot --eval <route> <javascript>")
route = sys.argv[2]
eval_expr = sys.argv[3]
out, pad, selector = None, 0.0, None
else:
route = sys.argv[1]
out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_OUT
pad = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
selector = sys.argv[4] if len(sys.argv) > 4 else None
base = base_url()
url = route if re.match(r"^https?://", route) else base + "/" + route.lstrip("/")
vp = os.environ.get("LP_SHOT_VIEWPORT", "1440x900")
try:
width, height = (int(x) for x in vp.lower().split("x"))
except ValueError:
die(f"bad LP_SHOT_VIEWPORT {vp!r} — want WIDTHxHEIGHT")
scale = float(os.environ.get("LP_SHOT_SCALE", "2"))
settle = float(os.environ.get("LP_SHOT_SETTLE", "1.5"))
token = session_token(base)
if token and not check_token(base, token):
die("that session is not valid (expired token, or wrong LP_SHOT_URL)")
chrome = find_chrome()
proc, prof, port = launch(chrome, width, height)
cdp = None
try:
cdp = CDP(page_socket(port))
cdp.send("Page.enable")
cdp.send("Runtime.enable")
cdp.send("Log.enable")
cdp.send("Network.enable")
if token:
host = re.sub(r"^https?://", "", base).split(":")[0].split("/")[0]
cdp.send("Network.setCookie", name=COOKIE, value=token, domain=host,
path="/", httpOnly=True, sameSite="Strict")
# Pin the metrics rather than trusting --window-size: headless sizes the
# window including chrome-less padding, and we want an exact CSS viewport.
cdp.send("Emulation.setDeviceMetricsOverride",
width=width, height=height, deviceScaleFactor=scale, mobile=False)
cdp.send("Page.navigate", url=url, timeout=60)
deadline = time.time() + 30
while time.time() < deadline:
if any(e["method"] == "Page.loadEventFired" for e in cdp.events):
break
cdp.drain()
time.sleep(0.1)
# The WebUI is an SPA: the document loads long before the route paints.
# Wait for the thing we're actually capturing (or for a non-empty body),
# then settle for late renders/fonts.
# The SPA shows a full-screen boot loader (#libreportal-loading-screen,
# removed from the DOM once it finishes) over an otherwise-ready page.
# Waiting only for "body has text" happily captures that splash at 9%,
# so every probe is gated on the loader being gone first.
want = f"!!document.querySelector({json.dumps(selector)})" if selector else \
"!!document.body && document.body.innerText.trim().length > 0"
probe = f"(!document.getElementById('libreportal-loading-screen')) && ({want})"
# An unauthenticated run lands on the sign-in box: without a check it
# would either quietly screenshot that, or sit here until the selector
# times out — and neither says what's actually wrong. But "login form on
# screen" isn't itself the failure (you may be shooting the login page,
# or an element inside it), so it only counts once what was ASKED for
# has failed to show: an unsatisfied probe on a route that isn't the
# login route, or a selector that never turned up.
root_route = urllib.parse.urlparse(url).path.rstrip("/") in ("", "/")
deadline = time.time() + 45 # cold SPA boot is slow
while time.time() < deadline:
if cdp.eval(probe):
break
if not selector and not root_route and cdp.eval(LOGIN_PROBE):
die(AUTH_HELP)
time.sleep(0.2)
else:
if selector and cdp.eval(LOGIN_PROBE):
die(AUTH_HELP)
if selector:
die(f"selector {selector!r} never appeared on {url}")
if not selector and not root_route and cdp.eval(LOGIN_PROBE):
die(AUTH_HELP)
time.sleep(settle)
# LP_SHOT_EVAL: run something in the page before capturing.
#
# A screenshot answers "does this route render". It cannot answer "does
# this dialog look right", because a dialog is state you reach by
# clicking — so verifying one meant driving a real browser by hand, and
# anything only reachable that way tends to get checked structurally
# and never actually looked at. This lets a capture open the thing
# first. Awaited, so an async handler finishes before the shutter.
if eval_mode:
# Awaited, so an async body finishes before the value is read.
print(cdp.eval(f"(async () => {{ {eval_expr} }})()"))
return
pre = os.environ.get("LP_SHOT_EVAL")
if pre:
try:
cdp.eval(f"(async () => {{ {pre} }})()")
except Exception as exc:
print(f" LP_SHOT_EVAL failed: {exc}", file=sys.stderr)
time.sleep(settle if settle else 0.6)
for e in cdp.events:
if e["method"] == "Log.entryAdded" and e["params"]["entry"].get("level") == "error":
print(f" page error: {e['params']['entry'].get('text')}", file=sys.stderr)
if selector:
box = cdp.eval(
"(() => { const el = document.querySelector(%s); if (!el) return null;"
" el.scrollIntoView({block:'center', inline:'center', behavior:'instant'});"
" const r = el.getBoundingClientRect();"
" return {x: r.left + window.scrollX, y: r.top + window.scrollY,"
" w: r.width, h: r.height}; })()" % json.dumps(selector)
)
if not box:
die(f"selector {selector!r} matched nothing on {url}")
if box["w"] < 1 or box["h"] < 1:
die(f"selector {selector!r} matched a zero-size element (hidden?)")
clip = {
"x": max(0.0, box["x"] - pad),
"y": max(0.0, box["y"] - pad),
"width": box["w"] + pad * 2,
"height": box["h"] + pad * 2,
"scale": scale,
}
else:
m = cdp.send("Page.getLayoutMetrics")
size = m.get("cssContentSize") or m.get("contentSize")
clip = {
"x": 0.0, "y": 0.0,
"width": float(size["width"]),
"height": min(float(size["height"]), 20000.0),
"scale": scale,
}
shot = cdp.send("Page.captureScreenshot", format="png", clip=clip,
captureBeyondViewport=True, fromSurface=True, timeout=90)
data = base64.b64decode(shot["data"])
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
with open(out, "wb") as fh:
fh.write(data)
print(f"{out} ({int(clip['width'])}x{int(clip['height'])} css @{scale:g}x, "
f"{len(data) // 1024} KB) {url}")
finally:
# Shut the browser down through the protocol, not with a signal: the snap
# launcher is setuid-root, so the PID we hold is root-owned and os.kill
# comes back EPERM. Browser.close is the only teardown that actually
# works there; the signals are just a backstop for an unconfined chrome.
if cdp:
try:
cdp.send("Browser.close", timeout=5)
except SystemExit:
pass
cdp.close()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
for stop in (proc.terminate, proc.kill):
try:
stop()
proc.wait(timeout=5)
break
except (PermissionError, subprocess.TimeoutExpired):
continue
shutil.rmtree(prof, ignore_errors=True)
if __name__ == "__main__":
main()