From 12ce48125ab7d7183230ab157b2561bb2a10dda4 Mon Sep 17 00:00:00 2001 From: Zero Two Date: Fri, 28 Aug 2026 13:57:33 -0400 Subject: [PATCH 1/9] fix(server): harden backup storage and streaming uploads --- pyproject.toml | 6 +- requirements-test.txt | 2 + scripts/live-http-test.py | 102 ++++++++++++++++ src/server/__init__.py | 0 src/server/runtime_status.py | 73 +++++++++++ src/server/server.py | 229 +++++++++++++++++++++++++---------- src/server/storage.py | 41 +++++++ tests/test_api.py | 143 ++++++++++++++++++++++ tests/test_storage.py | 23 ++++ 9 files changed, 551 insertions(+), 68 deletions(-) create mode 100644 requirements-test.txt create mode 100755 scripts/live-http-test.py create mode 100644 src/server/__init__.py create mode 100644 src/server/runtime_status.py create mode 100644 src/server/storage.py create mode 100644 tests/test_api.py create mode 100644 tests/test_storage.py diff --git a/pyproject.toml b/pyproject.toml index dc029ca..6cdb7aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,14 @@ distribution = false [tool.pdm.scripts] server = { call = "src.server.server:main" } build = "pyinstaller main.spec" -start = "flask --app src.server.server run --debug" +start = "python -m src.server.server" gui = "python main.py" [tool.pyright] pythonVersion = "3.10" pythonPlatform = "All" typeCheckingMode = "basic" + +[tool.pytest.ini_options] +addopts = "-ra -vv" +testpaths = ["tests"] diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..f162d39 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,2 @@ +pytest==9.1.1 +PyYAML==6.0.3 diff --git a/scripts/live-http-test.py b/scripts/live-http-test.py new file mode 100755 index 0000000..2e7d033 --- /dev/null +++ b/scripts/live-http-test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Start the service on localhost and exercise the real HTTP interface.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def fetch(url: str, *, data: bytes | None = None, method: str = "GET"): + request = urllib.request.Request(url, data=data, method=method) + return urllib.request.urlopen(request, timeout=2) + + +def main() -> int: + port = free_port() + with tempfile.TemporaryDirectory(prefix="lnreader-http-test-") as tmp: + env = os.environ.copy() + env.update( + { + "PYTHONPATH": str(ROOT), + "LNREADER_STORAGE_DIR": tmp, + "LNREADER_RUNTIME_DIR": str(Path(tmp) / "run"), + "HOST": "127.0.0.1", + "PORT": str(port), + } + ) + process = subprocess.Popen( + [sys.executable, "-m", "src.server.server"], + cwd=ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + base = f"http://127.0.0.1:{port}" + try: + for _ in range(60): + if process.poll() is not None: + raise RuntimeError("server exited before becoming healthy") + try: + with fetch(base + "/healthz") as response: + if response.status == 200: + print(f"PASS: healthz on port {port}") + break + except OSError: + time.sleep(0.05) + else: + raise RuntimeError("server did not become healthy") + + payload = b"live-http-integration-test-12345" + with fetch(base + "/upload/live.backup&&nested/data.zip", data=payload, method="POST") as response: + result = json.loads(response.read()) + assert result["size"] == len(payload) + print(f"PASS: upload {len(payload)} bytes") + + with fetch(base + "/list") as response: + result = json.loads(response.read()) + assert result == ["live.backup"] + print("PASS: list backup") + + with fetch(base + "/download/live.backup&&nested/data.zip") as response: + assert response.read() == payload + print("PASS: download round trip") + + try: + fetch(base + "/upload/safe.backup&&../../escape.zip", data=b"bad", method="POST") + raise AssertionError("traversal request unexpectedly succeeded") + except urllib.error.HTTPError as exc: + assert exc.code == 400 + print("PASS: live traversal rejection") + + return 0 + finally: + process.terminate() + try: + output, _ = process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + output, _ = process.communicate() + print("\n--- server output ---") + print(output.rstrip()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/runtime_status.py b/src/server/runtime_status.py new file mode 100644 index 0000000..2409c5b --- /dev/null +++ b/src/server/runtime_status.py @@ -0,0 +1,73 @@ +"""Concurrent runtime upload status shared with the read-only web UI.""" + +from __future__ import annotations + +import fcntl +import json +import os +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +def runtime_dir() -> Path: + path = Path(os.environ.get("LNREADER_RUNTIME_DIR", "/run/lnreader")) + path.mkdir(parents=True, exist_ok=True) + return path + + +def status_path() -> Path: + return runtime_dir() / "uploads.json" + + +def lock_path() -> Path: + return runtime_dir() / "uploads.lock" + + +@contextmanager +def _locked_state() -> Iterator[dict]: + lock = lock_path() + with lock.open("a+", encoding="utf-8") as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + path = status_path() + try: + state = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"uploads": {}} + except (json.JSONDecodeError, OSError): + state = {"uploads": {}} + if not isinstance(state, dict) or not isinstance(state.get("uploads"), dict): + state = {"uploads": {}} + yield state + tmp = path.with_suffix(f".tmp.{os.getpid()}") + tmp.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8") + os.replace(tmp, path) + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + + +def reset_status() -> None: + with _locked_state() as state: + state["uploads"] = {} + + +def update_upload(upload_id: str, **fields: object) -> None: + now = time.time() + with _locked_state() as state: + uploads = state["uploads"] + # Remove abandoned entries after 6 hours. This only cleans status data, + # never backup files. + stale_before = now - 6 * 60 * 60 + for key in list(uploads): + try: + updated = float(uploads[key].get("updated_at", 0)) + except (TypeError, ValueError, AttributeError): + updated = 0 + if updated < stale_before: + uploads.pop(key, None) + entry = uploads.setdefault(upload_id, {}) + entry.update(fields) + entry["updated_at"] = now + + +def remove_upload(upload_id: str) -> None: + with _locked_state() as state: + state["uploads"].pop(upload_id, None) diff --git a/src/server/server.py b/src/server/server.py index 8c71c26..514ef35 100644 --- a/src/server/server.py +++ b/src/server/server.py @@ -1,75 +1,170 @@ -""" -POST: /upload/ -> write data to -GET: /download/ -> read .zip file from -also get_workspace(): is the folder path which includes backup folders .backup -an example for this url : /upload/nyagami.backup&&data.zip or /upload/nyagami.backup&&download.zip +"""LNReader Remote Service WSGI API. + +This keeps the upstream HTTP contract while using only Python's standard +library at runtime behind Gunicorn. """ + +from __future__ import annotations + import json +import mimetypes +import os import sys +import time +import uuid from pathlib import Path -from typing import Any - -from flask import Flask, request, send_file - -app = Flask(__name__) - - -def get_workspace() -> Path: - config_path = Path.home() / ".LNReader" / "config.json" - with config_path.open("r", encoding="utf-8") as f: - config = json.load(f) - return Path(config["workspace"]) - - -@app.route("/") -def root() -> dict[str, str]: - return {"name": "LNReader"} - - -@app.post("/upload/&&") -def upload(backup_name: str, filename: str) -> dict[str, Any]: - file_path = Path(get_workspace()) / backup_name / filename - file_path.parent.mkdir(parents=True, exist_ok=True) - - file = request.get_data() - with file_path.open("wb") as f: - f.write(file) - - return {"backup_name": backup_name, "filename": filename, "size": len(file)} - - -@app.get("/download/&&") -def download(backup_name: str, filename: str): - file_path = Path(get_workspace()) / backup_name / filename - if not file_path.exists(): - raise Exception("File not found") - - return send_file(file_path) - - -@app.get("/list") -def list() -> list[str]: - """list all backups""" - workspace = Path(get_workspace()) - return [ - str(folder.name) - for folder in workspace.iterdir() - if folder.is_dir() and folder.name.endswith(".backup") - ] - - -def main(): +from typing import Iterable +from urllib.parse import unquote +from wsgiref.simple_server import make_server +from wsgiref.util import FileWrapper + +from .runtime_status import remove_upload, update_upload +from .storage import get_workspace, safe_backup_path + +JSON_HEADERS = [("Content-Type", "application/json; charset=utf-8")] +UPLOAD_CHUNK_SIZE = 1024 * 1024 + + +def _json_response(start_response, status: str, payload: object) -> list[bytes]: + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + start_response(status, JSON_HEADERS + [("Content-Length", str(len(body)))]) + return [body] + + +def _route_parts(path: str, prefix: str) -> tuple[str, str] | None: + if not path.startswith(prefix): + return None + value = unquote(path[len(prefix):]) + if "&&" not in value: + return None + backup_name, filename = value.split("&&", 1) + if not backup_name or not filename: + return None + return backup_name, filename + + +def _stream_upload(environ: dict, file_path: Path, backup_name: str, filename: str, total: int) -> int: + upload_id = uuid.uuid4().hex + started_at = time.time() + temp_path = file_path.with_name(f".{file_path.name}.upload-{upload_id}.part") + received = 0 + update_upload( + upload_id, + backup_name=backup_name, + filename=filename, + bytes_received=0, + total_bytes=total, + started_at=started_at, + ) try: - if len(sys.argv) == 1: - host = "localhost" - port = 8000 - else: - host, port = sys.argv[1], sys.argv[2] - port = int(port) - print(f"Start server - {host}:{port}") - app.run(host=host, port=port) - except Exception: - print("python server.py [host] [port]") + file_path.parent.mkdir(parents=True, exist_ok=True) + with temp_path.open("wb") as handle: + remaining = total + while remaining > 0: + chunk = environ["wsgi.input"].read(min(UPLOAD_CHUNK_SIZE, remaining)) + if not chunk: + raise IOError("request body ended before Content-Length") + handle.write(chunk) + received += len(chunk) + remaining -= len(chunk) + update_upload(upload_id, bytes_received=received) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, file_path) + return received + finally: + try: + temp_path.unlink(missing_ok=True) + finally: + remove_upload(upload_id) + + +def app(environ: dict, start_response) -> Iterable[bytes]: + method = str(environ.get("REQUEST_METHOD", "GET")).upper() + path = str(environ.get("PATH_INFO", "/")) + + if method == "GET" and path == "/": + return _json_response(start_response, "200 OK", {"name": "LNReader"}) + + if method == "GET" and path == "/healthz": + workspace = get_workspace() + if workspace.exists() and workspace.is_dir(): + return _json_response(start_response, "200 OK", {"status": "ok"}) + return _json_response(start_response, "503 Service Unavailable", {"error": "workspace unavailable"}) + + if method == "GET" and path == "/list": + workspace = Path(get_workspace()) + backups = [] if not workspace.exists() else sorted( + folder.name + for folder in workspace.iterdir() + if folder.is_dir() and folder.name.endswith(".backup") + ) + return _json_response(start_response, "200 OK", backups) + + upload_parts = _route_parts(path, "/upload/") if method == "POST" else None + if upload_parts is not None: + backup_name, filename = upload_parts + try: + file_path = safe_backup_path(get_workspace(), backup_name, filename) + except ValueError: + return _json_response(start_response, "400 Bad Request", {"error": "invalid backup path"}) + + try: + content_length = int(environ.get("CONTENT_LENGTH") or "0") + except ValueError: + return _json_response(start_response, "400 Bad Request", {"error": "invalid content length"}) + if content_length < 0: + return _json_response(start_response, "400 Bad Request", {"error": "invalid content length"}) + + try: + received = _stream_upload(environ, file_path, backup_name, filename, content_length) + except (OSError, IOError) as exc: + return _json_response(start_response, "400 Bad Request", {"error": str(exc)}) + + return _json_response( + start_response, + "200 OK", + {"backup_name": backup_name, "filename": filename, "size": received}, + ) + + download_parts = _route_parts(path, "/download/") if method == "GET" else None + if download_parts is not None: + backup_name, filename = download_parts + try: + file_path = safe_backup_path(get_workspace(), backup_name, filename) + except ValueError: + return _json_response(start_response, "400 Bad Request", {"error": "invalid backup path"}) + + if not file_path.is_file(): + return _json_response(start_response, "404 Not Found", {"error": "file not found"}) + + size = file_path.stat().st_size + content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + download_name = file_path.name.replace('"', "_").replace("\r", "").replace("\n", "") + headers = [ + ("Content-Type", content_type), + ("Content-Length", str(size)), + ("Content-Disposition", f'attachment; filename="{download_name}"'), + ] + start_response("200 OK", headers) + handle = file_path.open("rb") + wrapper = environ.get("wsgi.file_wrapper", FileWrapper) + return wrapper(handle, 64 * 1024) + + return _json_response(start_response, "404 Not Found", {"error": "not found"}) + + +def main() -> None: + if len(sys.argv) == 1: + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "8000")) + else: + host, port_arg = sys.argv[1], sys.argv[2] + port = int(port_arg) + + print(f"Start server - {host}:{port}") + with make_server(host, port, app) as server: + server.serve_forever() if __name__ == "__main__": diff --git a/src/server/storage.py b/src/server/storage.py new file mode 100644 index 0000000..e706203 --- /dev/null +++ b/src/server/storage.py @@ -0,0 +1,41 @@ +"""Storage helpers shared by the LNReader remote-service API.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +DEFAULT_APP_DIR = Path.home() / ".LNReader" + + +def get_workspace() -> Path: + """Return the configured LNReader backup workspace. + + Containers may set LNREADER_STORAGE_DIR directly. The desktop/CLI behavior + remains compatible with upstream by falling back to ~/.LNReader/config.json. + """ + override = os.environ.get("LNREADER_STORAGE_DIR") + if override: + return Path(override).expanduser().resolve() + + config_path = DEFAULT_APP_DIR / "config.json" + with config_path.open("r", encoding="utf-8") as handle: + config = json.load(handle) + return Path(config["workspace"]).expanduser().resolve() + + +def safe_backup_path(workspace: Path, backup_name: str, filename: str | None = None) -> Path: + """Resolve a backup path while preventing escape from the workspace.""" + root = workspace.resolve() + target = root / backup_name + if filename is not None: + target = target / filename + target = target.resolve() + + try: + target.relative_to(root) + except ValueError as exc: + raise ValueError("Backup path escapes the configured workspace") from exc + + return target diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2bebdc0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from urllib.parse import quote + +import pytest + +from src.server.server import app + + +def request(method: str, path: str, body: bytes = b"", *, stream=None, content_length: int | None = None): + captured: dict[str, object] = {} + + def start_response(status, headers): + captured["status"] = status + captured["headers"] = dict(headers) + + environ = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "CONTENT_LENGTH": str(len(body) if content_length is None else content_length), + "wsgi.input": stream if stream is not None else io.BytesIO(body), + } + response = app(environ, start_response) + try: + payload = b"".join(response) + finally: + close = getattr(response, "close", None) + if close: + close() + return int(str(captured["status"]).split()[0]), captured["headers"], payload + + +@pytest.fixture() +def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("LNREADER_STORAGE_DIR", str(tmp_path / "storage")) + monkeypatch.setenv("LNREADER_RUNTIME_DIR", str(tmp_path / "runtime")) + workspace = tmp_path / "storage" + workspace.mkdir() + return workspace + + +def test_root(workspace: Path): + status, _, body = request("GET", "/") + assert status == 200 + assert json.loads(body) == {"name": "LNReader"} + + +def test_health(workspace: Path): + status, _, body = request("GET", "/healthz") + assert status == 200 + assert json.loads(body) == {"status": "ok"} + + +def test_upload_list_download_round_trip(workspace: Path): + payload = b"LNReader backup payload\x00\x01" + status, _, body = request("POST", "/upload/test.backup&&data.zip", payload) + assert status == 200 + assert json.loads(body)["size"] == len(payload) + assert (workspace / "test.backup" / "data.zip").read_bytes() == payload + + status, _, body = request("GET", "/list") + assert status == 200 + assert json.loads(body) == ["test.backup"] + + status, headers, body = request("GET", "/download/test.backup&&data.zip") + assert status == 200 + assert headers["Content-Length"] == str(len(payload)) + assert body == payload + + +def test_nested_filename_round_trip(workspace: Path): + payload = b"nested" + path = "/upload/novels.backup&&nested/chapter/data.zip" + assert request("POST", path, payload)[0] == 200 + assert request("GET", "/download/novels.backup&&nested/chapter/data.zip")[2] == payload + + +def test_missing_download_is_404(workspace: Path): + assert request("GET", "/download/missing.backup&&data.zip")[0] == 404 + + +@pytest.mark.parametrize( + "path", + [ + "/upload/../outside.backup&&data.zip", + "/upload/safe.backup&&../../outside.zip", + "/upload/" + quote("../outside.backup&&data.zip", safe="&"), + ], +) +def test_traversal_attempt_is_rejected(workspace: Path, path: str): + assert request("POST", path, b"bad")[0] == 400 + + +def test_short_request_body_is_rejected_without_partial_backup(workspace: Path): + status, _, body = request( + "POST", + "/upload/incomplete.backup&&data.zip", + stream=io.BytesIO(b"short"), + content_length=100, + ) + assert status == 400 + assert b"Content-Length" in body + assert not (workspace / "incomplete.backup" / "data.zip").exists() + assert not list(workspace.rglob("*.part")) + + +def test_runtime_upload_status_is_visible_during_stream_and_cleaned_after_success(workspace: Path, tmp_path: Path): + payload = b"x" * (2 * 1024 * 1024 + 25) + observed: list[dict] = [] + + class ObservingStream(io.BytesIO): + reads = 0 + + def read(self, size: int = -1) -> bytes: + self.reads += 1 + if self.reads == 2: + status_file = tmp_path / "runtime" / "uploads.json" + if status_file.exists(): + observed.append(json.loads(status_file.read_text())) + return super().read(size) + + stream = ObservingStream(payload) + status, _, _ = request( + "POST", + "/upload/status.backup&&big.bin", + stream=stream, + content_length=len(payload), + ) + assert status == 200 + assert observed and observed[0]["uploads"] + active = next(iter(observed[0]["uploads"].values())) + assert active["backup_name"] == "status.backup" + assert active["filename"] == "big.bin" + assert active["bytes_received"] == 1024 * 1024 + state = json.loads((tmp_path / "runtime" / "uploads.json").read_text()) + assert state == {"uploads": {}} + + +def test_unknown_route_is_404(workspace: Path): + assert request("GET", "/nope")[0] == 404 diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..1df7e25 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import pytest + +from src.server.storage import safe_backup_path + + +def test_safe_backup_path_accepts_nested_backup(tmp_path: Path): + actual = safe_backup_path(tmp_path, "novels.backup", "nested/data.zip") + assert actual == (tmp_path / "novels.backup" / "nested/data.zip").resolve() + + +@pytest.mark.parametrize( + ("backup_name", "filename"), + [ + ("../outside.backup", "data.zip"), + ("safe.backup", "../../outside.zip"), + ("/tmp/outside.backup", "data.zip"), + ], +) +def test_safe_backup_path_rejects_escape(tmp_path: Path, backup_name: str, filename: str): + with pytest.raises(ValueError): + safe_backup_path(tmp_path, backup_name, filename) From 2323a5071d56b42af584697e6a7559c39a3e54bc Mon Sep 17 00:00:00 2001 From: Zero Two Date: Fri, 28 Aug 2026 13:57:33 -0400 Subject: [PATCH 2/9] feat(container): add production-ready self-hosted deployment --- .dockerignore | 23 +++ .env.example | 35 ++++- .gitignore | 6 + Dockerfile | 85 +++++++---- docker-compose.yml | 34 +++-- docker/docker-entrypoint.sh | 124 +++++++++++++++ docker/gunicorn.conf.py | 24 +-- docker/init-webui-auth.sh | 88 +++++++++++ docker/nginx.conf.template | 85 +++++++++++ docker/php-fpm.conf | 28 ++++ docker/supervisord.conf | 57 +++++++ requirements-docker.txt | 2 + scripts/container-smoke-test.sh | 153 +++++++++++++++++++ scripts/webui-auth-test.sh | 60 ++++++++ scripts/webui-test.sh | 79 ++++++++++ web/index.php | 258 ++++++++++++++++++++++++++++++++ 16 files changed, 1078 insertions(+), 63 deletions(-) create mode 100644 .dockerignore create mode 100755 docker/docker-entrypoint.sh create mode 100755 docker/init-webui-auth.sh create mode 100644 docker/nginx.conf.template create mode 100644 docker/php-fpm.conf create mode 100644 docker/supervisord.conf create mode 100644 requirements-docker.txt create mode 100755 scripts/container-smoke-test.sh create mode 100755 scripts/webui-auth-test.sh create mode 100755 scripts/webui-test.sh create mode 100644 web/index.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ae965e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.github +.pytest_cache +.venv +venv +__pycache__ +*.py[cod] +*.log +.env +.env.* +!.env.example +htmlcov +coverage.xml +*.zip +*.tar.gz +tests +scripts +README.md +DEVELOPMENT.md +SECURITY.md +data +secrets +.webui-auth diff --git a/.env.example b/.env.example index 462e933..997d64f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,32 @@ -# Environment Variables Configuration Example -# Copy this file to .env and adjust the values as needed +# No .env file is required for the default deployment. -# Required Settings (Defaults shown) -PORT=8000 # Port where Gunicorn will run and be exposed -STORAGE_PATH=$HOME/.LNReader # Local path to store LNReader data +# Published image. The default Compose file uses the upstream published image. +LNREADER_IMAGE=ghcr.io/lnreader/remote-service:latest +# Host settings. +HOST_PORT=8000 +STORAGE_PATH=./data + +# Optional Linux ownership overrides. Leave blank on most systems. +# When blank, the container uses a safe non-root default and will reuse a +# non-root owner already present on the mounted storage directory when possible. +PUID= +PGID= + +# Runtime tuning. +WORKERS=2 +THREADS=2 +LOG_LEVEL=info +FIX_PERMISSIONS=true +MAX_UPLOAD_SIZE=20g + +# Web UI. +WEB_UI_SLUG=lnr-vault-7f3c9 +WEB_UI_USERNAME=admin + +# Leave blank to generate and persist a random password automatically. +WEB_UI_PASSWORD= + +# Optional explicit public URL shown by the status page. +# Example: https://lnreader.example.com +PUBLIC_URL= diff --git a/.gitignore b/.gitignore index 6e1eb07..e60ac58 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,9 @@ temp.py # Pyannotate generated stubs type_info.json + +# LNReader container runtime +data/ +.webui-auth/ +test-results/ +*.htpasswd diff --git a/Dockerfile b/Dockerfile index 5d3e1bf..180146a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,67 @@ -FROM python:3.10-slim +# syntax=docker/dockerfile:1.7.0@sha256:dbbd5e059e8a07ff7ea6233b213b36aa516b4c53c645f1817a4dd18b83cbea56 +FROM python:3.13.15-slim-bookworm -WORKDIR /app +ARG VERSION=dev +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG SOURCE_URL=https://github.com/lnreader/remote-service + +ARG APACHE2_UTILS_VERSION=2.4.68-1~deb12u1 +ARG GOSU_VERSION=1.14-1+b10 +ARG NGINX_VERSION=1.22.1-9+deb12u9 +ARG PHP_FPM_VERSION=8.2.33-1~deb12u1 +ARG SUPERVISOR_VERSION=4.2.5-1 -# Install system dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - gcc \ - python3-dev \ - libc6-dev \ - libx11-dev \ - libxext-dev \ - && rm -rf /var/lib/apt/lists/* +LABEL org.opencontainers.image.title="LNReader Remote Service" \ + org.opencontainers.image.description="Prebuilt LNReader backup server with a secured read-only status console" \ + org.opencontainers.image.source="$SOURCE_URL" \ + org.opencontainers.image.documentation="$SOURCE_URL#readme" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="$VERSION" \ + org.opencontainers.image.revision="$VCS_REF" \ + org.opencontainers.image.created="$BUILD_DATE" -# Install PDM -RUN pip install --no-cache-dir pdm +ENV APP_VERSION=${VERSION} \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + HOME=/home/lnreader \ + LNREADER_STORAGE_DIR=/home/lnreader/.LNReader \ + LNREADER_RUNTIME_DIR=/run/lnreader \ + INTERNAL_API_PORT=8001 \ + PORT=8000 \ + WEB_UI_SLUG=lnr-vault-7f3c9 \ + MAX_UPLOAD_SIZE=20g + +WORKDIR /app -# Copy dependency files -COPY pyproject.toml pdm.lock ./ +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apache2-utils="${APACHE2_UTILS_VERSION}" \ + gosu="${GOSU_VERSION}" \ + nginx="${NGINX_VERSION}" \ + php8.2-fpm="${PHP_FPM_VERSION}" \ + supervisor="${SUPERVISOR_VERSION}" \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 1000 lnreader \ + && useradd --uid 1000 --gid 1000 --create-home --shell /usr/sbin/nologin lnreader -# Install dependencies -RUN pdm install --prod +COPY requirements-docker.txt ./ +RUN python -m pip install --no-cache-dir --requirement requirements-docker.txt -# Copy application code -COPY . . +COPY src ./src +COPY docker ./docker +COPY web ./web -# Install Gunicorn -RUN pdm add gunicorn +RUN chmod 0755 /app/docker/docker-entrypoint.sh /app/docker/init-webui-auth.sh \ + && mkdir -p /home/lnreader/.LNReader /run/lnreader \ + && chown -R lnreader:lnreader /home/lnreader /run/lnreader /app -# Create non-root user -RUN useradd -m lnreader && \ - chown -R lnreader:lnreader /app +EXPOSE 8000 +VOLUME ["/home/lnreader/.LNReader"] -USER lnreader +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).read()" || exit 1 -# Set environment variables -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 \ No newline at end of file +ENTRYPOINT ["/app/docker/docker-entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index c1c399c..a625048 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,24 @@ services: - web: - build: . - container_name: lnreader-backup-server - volumes: - - ${STORAGE_PATH:-~/.LNReader}:/home/lnreader/.LNReader + lnreader: + image: ${LNREADER_IMAGE:-ghcr.io/lnreader/remote-service:latest} environment: - - PORT=${PORT:-8000} + PUID: "${PUID:-}" + PGID: "${PGID:-}" + WORKERS: "${WORKERS:-2}" + THREADS: "${THREADS:-2}" + LOG_LEVEL: "${LOG_LEVEL:-info}" + FIX_PERMISSIONS: "${FIX_PERMISSIONS:-true}" + WEB_UI_SLUG: "${WEB_UI_SLUG:-lnr-vault-7f3c9}" + WEB_UI_USERNAME: "${WEB_UI_USERNAME:-admin}" + WEB_UI_PASSWORD: "${WEB_UI_PASSWORD:-}" + PUBLIC_URL: "${PUBLIC_URL:-}" + MAX_UPLOAD_SIZE: "${MAX_UPLOAD_SIZE:-20g}" + volumes: + - type: bind + source: ${STORAGE_PATH:-./data} + target: /home/lnreader/.LNReader ports: - - "${PORT:-8000}:${PORT:-8000}" + - "${HOST_PORT:-8000}:8000" restart: unless-stopped - user: root - command: > - /bin/sh -c " - mkdir -p /home/lnreader/.LNReader && - echo '{\"workspace\": \"/home/lnreader/.LNReader\"}' > /home/lnreader/.LNReader/config.json && - chown -R lnreader:lnreader /home/lnreader/.LNReader && - su -c 'pdm run gunicorn --config docker/gunicorn.conf.py \"src.server.server:app\"' lnreader" + security_opt: + - no-new-privileges:true diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100755 index 0000000..b4f496a --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,124 @@ +#!/bin/sh +set -eu + +STORAGE_DIR="${LNREADER_STORAGE_DIR:-/home/lnreader/.LNReader}" +RUNTIME_DIR="${LNREADER_RUNTIME_DIR:-/run/lnreader}" +FIX_PERMISSIONS="${FIX_PERMISSIONS:-true}" +WEB_UI_SLUG="${WEB_UI_SLUG:-lnr-vault-7f3c9}" +PORT="${PORT:-8000}" +MAX_UPLOAD_SIZE="${MAX_UPLOAD_SIZE:-20g}" +PUBLIC_URL="${PUBLIC_URL:-}" +PUID="${PUID:-}" +PGID="${PGID:-}" + +case "$PORT" in + *[!0-9]*|'') echo "ERROR: PORT must be numeric" >&2; exit 64 ;; +esac + +if [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then + echo "ERROR: PORT must be between 1 and 65535" >&2 + exit 64 +fi + +case "$WEB_UI_SLUG" in + *[!A-Za-z0-9._-]*|'') + echo "ERROR: WEB_UI_SLUG may contain only letters, digits, dot, underscore and dash" >&2 + exit 64 + ;; +esac + +if ! printf '%s' "$MAX_UPLOAD_SIZE" | grep -Eq '^[0-9]+[kKmMgG]?$'; then + echo "ERROR: MAX_UPLOAD_SIZE must look like 512m, 2g, etc." >&2 + exit 64 +fi + +mkdir -p "$STORAGE_DIR" "$RUNTIME_DIR" \ + "$RUNTIME_DIR/client_temp" \ + "$RUNTIME_DIR/proxy_temp" \ + "$RUNTIME_DIR/fastcgi_temp" \ + "$RUNTIME_DIR/uwsgi_temp" \ + "$RUNTIME_DIR/scgi_temp" + +DEFAULT_UID="$(id -u lnreader)" +DEFAULT_GID="$(id -g lnreader)" + +if [ -z "$PUID" ]; then + STORAGE_UID="$(stat -c '%u' "$STORAGE_DIR" 2>/dev/null || printf '%s' "$DEFAULT_UID")" + if [ "$STORAGE_UID" -gt 0 ] 2>/dev/null; then + PUID="$STORAGE_UID" + else + PUID="$DEFAULT_UID" + fi +fi + +if [ -z "$PGID" ]; then + STORAGE_GID="$(stat -c '%g' "$STORAGE_DIR" 2>/dev/null || printf '%s' "$DEFAULT_GID")" + if [ "$STORAGE_GID" -gt 0 ] 2>/dev/null; then + PGID="$STORAGE_GID" + else + PGID="$DEFAULT_GID" + fi +fi + +case "$PUID:$PGID" in + *[!0-9:]*|:*|*:) + echo "ERROR: PUID and PGID must be blank or positive numeric IDs" >&2 + exit 64 + ;; +esac + +if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then + echo "ERROR: PUID and PGID may not be 0; the service must run non-root" >&2 + exit 64 +fi + +if [ "$(id -g lnreader)" != "$PGID" ]; then + groupmod -o -g "$PGID" lnreader +fi +if [ "$(id -u lnreader)" != "$PUID" ]; then + usermod -o -u "$PUID" lnreader +fi + +printf '{"workspace":"%s"}\n' "$STORAGE_DIR" > "$STORAGE_DIR/config.json" +printf '{"uploads":{}}\n' > "$RUNTIME_DIR/uploads.json" +date +%s > "$RUNTIME_DIR/started_at" + +LNREADER_STORAGE_DIR="$STORAGE_DIR" \ +LNREADER_RUNTIME_DIR="$RUNTIME_DIR" \ +WEB_UI_SLUG="$WEB_UI_SLUG" \ +WEB_UI_USERNAME="${WEB_UI_USERNAME:-admin}" \ +WEB_UI_PASSWORD="${WEB_UI_PASSWORD:-}" \ + /bin/sh /app/docker/init-webui-auth.sh + +case "$FIX_PERMISSIONS" in + true|TRUE|1|yes|YES) + chown -R lnreader:lnreader "$STORAGE_DIR" + ;; + false|FALSE|0|no|NO) + ;; + *) + echo "ERROR: FIX_PERMISSIONS must be true or false" >&2 + exit 64 + ;; +esac + +chown -R lnreader:lnreader "$RUNTIME_DIR" + +sed \ + -e "s/__PORT__/$PORT/g" \ + -e "s/__WEB_UI_SLUG__/$WEB_UI_SLUG/g" \ + -e "s/__MAX_UPLOAD_SIZE__/$MAX_UPLOAD_SIZE/g" \ + /app/docker/nginx.conf.template > /etc/nginx/nginx.conf + +export HOME=/home/lnreader +export LNREADER_STORAGE_DIR="$STORAGE_DIR" +export LNREADER_RUNTIME_DIR="$RUNTIME_DIR" +export WEB_UI_SLUG +export PUBLIC_URL +export PORT + +if [ "$#" -gt 0 ]; then + exec gosu lnreader "$@" +fi + +exec gosu lnreader /usr/bin/supervisord -c /app/docker/supervisord.conf diff --git a/docker/gunicorn.conf.py b/docker/gunicorn.conf.py index 5397acf..87f3e5e 100644 --- a/docker/gunicorn.conf.py +++ b/docker/gunicorn.conf.py @@ -1,22 +1,14 @@ import os -# Use PORT environment variable with default -port = int(os.environ.get("PORT", "8000")) -bind = f"0.0.0.0:{port}" - -# Worker configuration -workers = 4 -worker_class = "sync" +port = int(os.environ.get("INTERNAL_API_PORT", "8001")) +bind = f"127.0.0.1:{port}" +workers = int(os.environ.get("WORKERS", "2")) +threads = int(os.environ.get("THREADS", "2")) +worker_class = "gthread" keepalive = 30 - -# Timeout settings -timeout = 120 +timeout = int(os.environ.get("TIMEOUT", "300")) graceful_timeout = 30 - -# Logging accesslog = "-" errorlog = "-" -loglevel = "info" - -# Protect against slowloris DOS attack -worker_connections = 1000 +loglevel = os.environ.get("LOG_LEVEL", "info") +capture_output = True diff --git a/docker/init-webui-auth.sh b/docker/init-webui-auth.sh new file mode 100755 index 0000000..a58470d --- /dev/null +++ b/docker/init-webui-auth.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu + +STORAGE_DIR="${LNREADER_STORAGE_DIR:-/home/lnreader/.LNReader}" +RUNTIME_DIR="${LNREADER_RUNTIME_DIR:-/run/lnreader}" +WEB_UI_SLUG="${WEB_UI_SLUG:-lnr-vault-7f3c9}" +REQUESTED_USERNAME="${WEB_UI_USERNAME:-admin}" +REQUESTED_PASSWORD="${WEB_UI_PASSWORD:-}" +AUTH_DIR="${WEB_UI_AUTH_DIR:-$STORAGE_DIR/.webui-auth}" +USER_FILE="$AUTH_DIR/username" +PASSWORD_FILE="$AUTH_DIR/password" +HTPASSWD_FILE="$RUNTIME_DIR/.htpasswd" +GENERATED=0 +CUSTOM=0 + +case "$REQUESTED_USERNAME" in + *[!A-Za-z0-9._-]*|'') + echo "ERROR: WEB_UI_USERNAME may contain only letters, digits, dot, underscore and dash" >&2 + exit 64 + ;; +esac + +umask 077 +mkdir -p "$AUTH_DIR" "$RUNTIME_DIR" +chmod 0700 "$AUTH_DIR" + +if [ -n "$REQUESTED_PASSWORD" ]; then + AUTH_USERNAME="$REQUESTED_USERNAME" + AUTH_PASSWORD="$REQUESTED_PASSWORD" + CUSTOM=1 + printf '%s' "$AUTH_USERNAME" > "$USER_FILE" + printf '%s' "$AUTH_PASSWORD" > "$PASSWORD_FILE" +elif [ -s "$USER_FILE" ] && [ -s "$PASSWORD_FILE" ]; then + AUTH_USERNAME="$(cat "$USER_FILE")" + AUTH_PASSWORD="$(cat "$PASSWORD_FILE")" +else + AUTH_USERNAME="$REQUESTED_USERNAME" + AUTH_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" + GENERATED=1 + printf '%s' "$AUTH_USERNAME" > "$USER_FILE" + printf '%s' "$AUTH_PASSWORD" > "$PASSWORD_FILE" +fi + +case "$AUTH_USERNAME" in + *[!A-Za-z0-9._-]*|'') + echo "ERROR: persisted Web UI username is invalid" >&2 + exit 78 + ;; +esac + +if [ -z "$AUTH_PASSWORD" ]; then + echo "ERROR: Web UI password may not be empty" >&2 + exit 78 +fi + +# -i reads the password from stdin so it never appears in the process argv. +printf '%s\n' "$AUTH_PASSWORD" | htpasswd -Bni "$AUTH_USERNAME" > "$HTPASSWD_FILE" +chmod 0600 "$USER_FILE" "$PASSWORD_FILE" "$HTPASSWD_FILE" + +if ! grep -Eq '^[^:#[:space:]]+:\$2[aby]\$' "$HTPASSWD_FILE"; then + echo "ERROR: failed to create bcrypt Web UI credentials" >&2 + exit 78 +fi + +if [ "$GENERATED" -eq 1 ]; then + cat </dev/null 2>&1 || true + rm -rf "$TMP" +} +trap cleanup EXIT + +banner() { printf '\n\n========== %s ==========\n' "$1"; } +fail() { + echo "FAIL: $*" >&2 + echo + echo "===== docker logs =====" + docker logs "$NAME" 2>&1 || true + echo + echo "===== runtime service logs =====" + docker exec "$NAME" sh -c ' + for f in /run/lnreader/*.log; do + [ -f "$f" ] || continue + echo + echo "----- $f -----" + tail -n 200 "$f" + done + ' 2>&1 || true + exit 1 +} + +wait_healthy() { + for i in {1..60}; do + if curl --fail --silent --show-error "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then + echo "PASS: service healthy after $i polls" + return 0 + fi + sleep 1 + done + fail "healthz never became ready" +} + +start_generated() { + docker run -d \ + --name "$NAME" \ + --security-opt no-new-privileges:true \ + -p "$PORT:8000" \ + -e PUBLIC_URL="https://lnreader.example.test" \ + -e WEB_UI_SLUG="$SLUG" \ + -v "$TMP/storage:/home/lnreader/.LNReader" \ + "$IMAGE" +} + +mkdir -p "$TMP/storage" +banner "Start with automatic Web UI credentials" +start_generated +wait_healthy + +AUTH_USER="$(cat "$TMP/storage/.webui-auth/username")" +AUTH_PASS="$(cat "$TMP/storage/.webui-auth/password")" +[[ "$AUTH_USER" == admin ]] || fail "generated username expected admin, got $AUTH_USER" +[[ ${#AUTH_PASS} -ge 30 ]] || fail "generated password is unexpectedly short" +docker logs "$NAME" 2>&1 | grep -Fq "Password: $AUTH_PASS" || fail "first-start password was not shown in docker logs" +echo "PASS: generated admin credential is persisted and printed on first start" + +banner "Container health and configuration" +docker inspect --format='Health={{json .State.Health}}' "$NAME" +docker exec "$NAME" nginx -t +docker exec "$NAME" sh -c 'printf "PID1 "; grep "^Uid:" /proc/1/status' +docker exec "$NAME" sh -c 'uid=$(sed -n "s/^Uid:[[:space:]]*\([0-9][0-9]*\).*/\1/p" /proc/1/status); [ "$uid" -ne 0 ]' +echo "PASS: PID 1 is not root" + +banner "API compatibility" +curl --fail --silent --show-error "http://127.0.0.1:$PORT/" | tee "$TMP/root.json" +grep -Fq 'LNReader' "$TMP/root.json" || fail "root API response changed" +printf 'smoke-payload' > "$TMP/payload.bin" +curl --fail --silent --show-error --data-binary @"$TMP/payload.bin" \ + "http://127.0.0.1:$PORT/upload/smoke.backup&&data.bin" | tee "$TMP/upload.json" +curl --fail --silent --show-error "http://127.0.0.1:$PORT/list" | tee "$TMP/list.json" +grep -Fq 'smoke.backup' "$TMP/list.json" || fail "backup absent from /list" +curl --fail --silent --show-error "http://127.0.0.1:$PORT/download/smoke.backup&&data.bin" > "$TMP/download.bin" +cmp "$TMP/payload.bin" "$TMP/download.bin" +echo "PASS: upload/list/download round trip" + +banner "Web UI authentication" +status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/$SLUG/")" +[[ "$status" == 401 ]] || fail "unauthenticated dashboard expected 401, got $status" +status="$(curl -sS -u "$AUTH_USER:wrong-password" -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/$SLUG/")" +[[ "$status" == 401 ]] || fail "wrong password expected 401, got $status" +curl --fail --silent --show-error -u "$AUTH_USER:$AUTH_PASS" \ + -D "$TMP/headers.txt" "http://127.0.0.1:$PORT/$SLUG/" > "$TMP/dashboard.html" +grep -Fq 'https://lnreader.example.test' "$TMP/dashboard.html" || fail "public app URL not shown" +grep -Fqi 'Content-Security-Policy:' "$TMP/headers.txt" || fail "CSP header missing" +grep -Fqi 'Cache-Control: no-store' "$TMP/headers.txt" || fail "no-store header missing" +grep -Fqi 'X-Robots-Tag:' "$TMP/headers.txt" || fail "robots header missing" +echo "PASS: generated credential protects dashboard" + +banner "Current upload visibility" +dd if=/dev/zero of="$TMP/slow.bin" bs=1M count=4 status=none +curl --silent --show-error --limit-rate 128k --data-binary @"$TMP/slow.bin" \ + "http://127.0.0.1:$PORT/upload/live.backup&&slow.bin" > "$TMP/slow-upload.json" & +upload_pid=$! +visible=0 +for i in {1..30}; do + curl --fail --silent --show-error -u "$AUTH_USER:$AUTH_PASS" \ + "http://127.0.0.1:$PORT/$SLUG/" > "$TMP/live-dashboard.html" + if grep -Fq 'live.backup' "$TMP/live-dashboard.html" && grep -Fq 'slow.bin' "$TMP/live-dashboard.html"; then + visible=1 + echo "PASS: running upload visible on dashboard after $i polls" + break + fi + sleep 0.5 +done +[[ "$visible" == 1 ]] || fail "running upload never appeared in dashboard" +wait "$upload_pid" + +banner "Generated credential persistence" +ORIGINAL_PASS="$AUTH_PASS" +docker rm -f "$NAME" >/dev/null +start_generated >/dev/null +wait_healthy +AUTH_PASS="$(cat "$TMP/storage/.webui-auth/password")" +[[ "$AUTH_PASS" == "$ORIGINAL_PASS" ]] || fail "generated password changed after recreation" +if docker logs "$NAME" 2>&1 | grep -Fq "Password: $AUTH_PASS"; then + fail "persisted password was printed again during recreation" +fi +curl --fail --silent --show-error -u "admin:$AUTH_PASS" "http://127.0.0.1:$PORT/$SLUG/" >/dev/null +echo "PASS: generated credential survives container recreation" + +banner ".env-style credential override" +docker rm -f "$NAME" >/dev/null +docker run -d \ + --name "$NAME" \ + --security-opt no-new-privileges:true \ + -p "$PORT:8000" \ + -e PUBLIC_URL="https://lnreader.example.test" \ + -e WEB_UI_SLUG="$SLUG" \ + -e WEB_UI_USERNAME="smoke-admin" \ + -e WEB_UI_PASSWORD="verbose-test-password-123456" \ + -v "$TMP/storage:/home/lnreader/.LNReader" \ + "$IMAGE" >/dev/null +wait_healthy +curl --fail --silent --show-error -u 'smoke-admin:verbose-test-password-123456' \ + "http://127.0.0.1:$PORT/$SLUG/" >/dev/null +[[ "$(cat "$TMP/storage/.webui-auth/username")" == 'smoke-admin' ]] || fail "custom username not persisted" +[[ "$(cat "$TMP/storage/.webui-auth/password")" == 'verbose-test-password-123456' ]] || fail "custom password not persisted" +echo "PASS: WEB_UI_USERNAME/WEB_UI_PASSWORD replace generated credentials" + +banner "Success" +echo "All container/UI smoke checks passed." diff --git a/scripts/webui-auth-test.sh b/scripts/webui-auth-test.sh new file mode 100755 index 0000000..81463c6 --- /dev/null +++ b/scripts/webui-auth-test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "$0")/.." + +fail() { echo "FAIL: $*" >&2; exit 1; } +command -v htpasswd >/dev/null 2>&1 || { echo 'SKIP: htpasswd unavailable'; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo 'SKIP: python3 unavailable'; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/storage" "$TMP/runtime" + +run_auth() { + LNREADER_STORAGE_DIR="$TMP/storage" \ + LNREADER_RUNTIME_DIR="$TMP/runtime" \ + WEB_UI_SLUG="lnr-vault-7f3c9" \ + WEB_UI_USERNAME="${WEB_UI_USERNAME:-admin}" \ + WEB_UI_PASSWORD="${WEB_UI_PASSWORD:-}" \ + sh docker/init-webui-auth.sh +} + +echo '=== automatic credential generation ===' +unset WEB_UI_PASSWORD WEB_UI_USERNAME || true +output="$(run_auth)" +user="$(cat "$TMP/storage/.webui-auth/username")" +pass="$(cat "$TMP/storage/.webui-auth/password")" +[[ "$user" == admin ]] || fail "default username is not admin" +[[ ${#pass} -ge 30 ]] || fail "generated password is unexpectedly short" +grep -Fq "Password: $pass" <<<"$output" || fail "generated password was not printed on first start" +htpasswd -vb "$TMP/runtime/.htpasswd" "$user" "$pass" >/dev/null || fail "generated bcrypt credential did not verify" +chmod_mode="$(stat -c '%a' "$TMP/storage/.webui-auth/password")" +[[ "$chmod_mode" == 600 ]] || fail "password file mode is $chmod_mode, expected 600" +echo 'PASS: random admin password generated, persisted, printed, and bcrypt-verified' + +echo '=== credential persistence ===' +rm -rf "$TMP/runtime" +mkdir -p "$TMP/runtime" +output2="$(run_auth)" +pass2="$(cat "$TMP/storage/.webui-auth/password")" +[[ "$pass2" == "$pass" ]] || fail "password changed between starts" +if grep -Fq "Password: $pass" <<<"$output2"; then + fail "persisted password was printed again on restart" +fi +htpasswd -vb "$TMP/runtime/.htpasswd" admin "$pass2" >/dev/null || fail "persisted credential did not verify" +echo 'PASS: password survives recreation and is not reprinted' + +echo '=== .env-style override ===' +rm -rf "$TMP/runtime" +mkdir -p "$TMP/runtime" +WEB_UI_USERNAME='smoke-admin' WEB_UI_PASSWORD='custom-test-password-123456789' run_auth >/dev/null +[[ "$(cat "$TMP/storage/.webui-auth/username")" == 'smoke-admin' ]] || fail "custom username was not persisted" +[[ "$(cat "$TMP/storage/.webui-auth/password")" == 'custom-test-password-123456789' ]] || fail "custom password was not persisted" +htpasswd -vb "$TMP/runtime/.htpasswd" smoke-admin 'custom-test-password-123456789' >/dev/null || fail "custom bcrypt credential did not verify" +echo 'PASS: WEB_UI_USERNAME/WEB_UI_PASSWORD override persisted credentials' + +echo '=== invalid username ===' +if WEB_UI_USERNAME='bad:user' WEB_UI_PASSWORD='a-long-test-password' run_auth >/dev/null 2>&1; then + fail "invalid username unexpectedly accepted" +fi +echo 'PASS: invalid username rejected' diff --git a/scripts/webui-test.sh b/scripts/webui-test.sh new file mode 100755 index 0000000..aea4e63 --- /dev/null +++ b/scripts/webui-test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "$0")/.." + +banner() { printf '\n========== %s ==========\n' "$1"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +banner "PHP syntax" +if command -v php >/dev/null 2>&1; then + php -l web/index.php +else + echo "SKIP: php CLI unavailable; container smoke test performs the runtime PHP check." +fi + +banner "bcrypt htpasswd" +if command -v htpasswd >/dev/null 2>&1; then + tmp_auth="$(mktemp)" + trap 'rm -f "$tmp_auth"' EXIT + htpasswd -Bbn test-user 'test-password-only' > "$tmp_auth" + grep -Eq '^test-user:\$2[aby]\$' "$tmp_auth" || fail "htpasswd output is not bcrypt" + htpasswd -vb "$tmp_auth" test-user 'test-password-only' + if htpasswd -vb "$tmp_auth" test-user 'wrong-password' >/dev/null 2>&1; then + fail "wrong htpasswd password unexpectedly verified" + fi + echo "PASS: bcrypt creation and verification" +else + echo "SKIP: htpasswd unavailable; it is installed in the container image." +fi + +banner "Rendered read-only dashboard" +if command -v php >/dev/null 2>&1; then + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp" ${tmp_auth:-}' EXIT + mkdir -p "$tmp/storage/books.backup" "$tmp/storage/evil