chore(dev): vendor lp-shot, excluded from release tarballs

The WebUI screenshot helper CLAUDE.md already tells agents to use only
ever existed on the maintainer's box. Vendoring it means it survives a
machine rebuild and the setup steps are written down.

It does NOT ship: make_release.sh builds with `git archive`, which honours
export-ignore, so scripts/dev joins scripts/release and docs on that list.
Verified — the staged tarball has 1666 files and none under scripts/dev.

Keeping it out of releases is deliberate, not incidental. lp-shot signs
itself a session from the jwtSecret in frontend/.auth.json, which is fine
on a box where you already own that file, and has no business sitting in
a user's install where it would read as a backdoor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 23:43:35 +01:00
parent 5c7372b8c2
commit 84b027feed
2 changed files with 487 additions and 0 deletions

1
.gitattributes vendored
View File

@ -3,6 +3,7 @@
# trees never ship in libreportal-<ver>.tar.gz.
scripts/unused export-ignore
scripts/release export-ignore
scripts/dev export-ignore
site export-ignore
docs export-ignore
.claude export-ignore

486
scripts/dev/lp-shot Executable file
View File

@ -0,0 +1,486 @@
#!/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"
COMPOSE = "/libreportal-containers/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"),
"/libreportal-containers/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():
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()