librelad 7ed8539af7 setup: stop calling the app-data drive "System disk" when it is not
The wizard's Storage step builds its first entry from primaryRoot() — the
app-data root — and labelled it "System disk". On a default install those are
the same drive and the name is honest. Installed with --containers-dir on its
own disk they are not, and the step then showed the DATA drive's size under the
system disk's name while the actual system disk never appeared in the list.

Seen on a matrix case-2 install (apps on a 29.4G test disk, system on a 912G
root): "System disk — 26.7G free of 29.4G".

The generator now reports whether that root is really on the OS disk
(is_os_disk, by st_dev against /), and the wizard labels it from that: "System
disk" when they coincide, otherwise the mount point. The "system" badge stays —
it marks the default location, which is still what it is.

Also add lp-shot --token / --cookie-js. A screenshot answers "does it render";
"does this wizard step work" needs clicking, which needs a real browser, which
needs the session lp-shot already knows how to mint from the stored jwtSecret.
This bug was found that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:35:16 +01:00

529 lines
21 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)
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)
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)
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()