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/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..3ea4b0d --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,134 @@ +name: Test and publish container + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + schedule: + - cron: '17 5 * * 1' + +permissions: + contents: read + +concurrency: + group: container-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} tests + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python-version: ['3.10.11', '3.11.9', '3.12.10', '3.13.15', '3.14.7'] + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-python@v7.0.0 + with: + python-version: ${{ matrix.python-version }} + check-latest: false + cache: pip + cache-dependency-path: requirements-test.txt + - name: Install test dependencies + run: python -m pip install --requirement requirements-test.txt + - name: Verbose API/static validation + env: + RUN_DOCKER_TESTS: '0' + RUN_WEBUI_TESTS: '0' + run: ./scripts/test-verbose.sh + + webui: + name: PHP / htpasswd security tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install PHP and htpasswd tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends php-cli apache2-utils + - name: PHP, bcrypt, and automatic credential validation + run: ./scripts/webui-test.sh + + container-smoke: + name: Full container / Nginx / PHP smoke test + needs: [test, webui] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Validate Compose + run: docker compose -f docker-compose.yml config + - name: Build test image verbosely + run: docker build --progress=plain --tag lnreader-remote-service:test . + - name: Run complete container smoke test + run: ./scripts/container-smoke-test.sh lnreader-remote-service:test + + build: + name: Publish amd64 + arm64 image + needs: container-smoke + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7.0.1 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4.2.0 + with: + image: tonistiigi/binfmt:qemu-v10.2.3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.2.0 + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern=v{{version}} + type=sha,prefix=sha- + - name: Build and publish multi-arch image + uses: docker/build-push-action@v7.1.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + VCS_REF=${{ github.sha }} + BUILD_DATE=${{ github.event.repository.updated_at }} + SOURCE_URL=${{ github.server_url }}/${{ github.repository }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + - name: Prepare deployment bundle + if: github.event_name != 'pull_request' + shell: bash + run: | + set -euo pipefail + mkdir -p dist + cp docker-compose.yml .env.example README.md SECURITY.md LICENSE dist/ + tar -C dist -czf lnreader-remote-service-deploy.tar.gz . + - name: Upload deployment bundle + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v7.0.1 + with: + name: lnreader-remote-service-deploy + path: lnreader-remote-service-deploy.tar.gz + archive: false + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 6e1eb07..2be7d64 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,12 @@ temp.py # Pyannotate generated stubs type_info.json + +# LNReader container runtime +data/ +.webui-auth/ +test-results/ +*.htpasswd + +# Local Python virtual environments +.venv*/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..d755a6a --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,121 @@ +# Development + +This document covers development of LNReader Remote Service with emphasis on the self-hosted server and container path. + +# Requirements + +Python 3.10 or newer is required for the server test suite. + +Docker Engine or Docker Desktop with Docker Compose is required for full container validation. + +PHP CLI and apache2-utils are optional for local Web UI validation because the container smoke test also validates the runtime PHP and bcrypt configuration. + +# Python Test Environment + +Create an isolated environment and install the pinned test dependencies. + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-test.txt +``` + +Run the Python tests. + +```bash +python -m pytest -vv -ra +``` + +Run static repository validation. + +```bash +python scripts/validate.py +``` + +Run the live HTTP integration test. + +```bash +python scripts/live-http-test.py +``` + +# Web UI Tests + +Run the Web UI tests with: + +```bash +./scripts/webui-test.sh +``` + +Run the automatic credential lifecycle tests with: + +```bash +./scripts/webui-auth-test.sh +``` + +# Complete Validation + +Run all locally available checks with: + +```bash +./scripts/test-verbose.sh +``` + +When Docker is available this also builds the image and runs the complete container smoke test. + +# Container Development + +Build a local image with: + +```bash +docker build --progress=plain -t lnreader-remote-service:local . +``` + +Run the container smoke test with: + +```bash +./scripts/container-smoke-test.sh lnreader-remote-service:local +``` + +The smoke test verifies container health, non-root PID 1, Nginx configuration, API compatibility, dashboard authentication, active upload visibility, generated credential persistence, and explicit credential overrides. + +# Docker Compose + +Validate the Compose file with: + +```bash +docker compose config +``` + +Start the normal service with: + +```bash +docker compose up -d +``` + +Stop it with: + +```bash +docker compose down +``` + +# Continuous Integration + +The container workflow runs Python validation across the configured Python matrix, PHP and bcrypt tests, a full Docker smoke test, and a multi-architecture Buildx build. + +Pull requests build the container without publishing it. + +Pushes to the default branch and version tags can publish images to the repository GitHub Container Registry namespace when package write permission is available. + +# Compatibility + +Changes to the backup API should preserve the existing LNReader routes and response behavior unless a coordinated client change is planned. + +The desktop GUI imports the server WSGI application and should remain usable after server changes. + +The command-line entry point should continue to expose src.server.server:main. + +# Security Review + +Server changes should continue to reject path traversal, avoid buffering complete uploads in memory, clean incomplete temporary uploads, and avoid running container services as root. + +The dashboard authentication boundary is separate from the LNReader backup API. Changes that add authentication to the backup API require explicit client compatibility review. 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/README.md b/README.md index 10784d5..51bec83 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,162 @@ # LNReader Remote Service -- Required LNReader version: >= 2.0.0 -- `LAN network - Wifi and Windows PC` or `a web server` +LNReader Remote Service provides self-hosted backup and restore support for LNReader. -## Remote Backup with GUI +**Required LNReader version:** 2.0.0 or newer. -1. Start LNReaderRS.exe -2. Enter your machine address `:` -3. Open Android App -> Setting -> Backup -> Self Host Backup +The existing desktop GUI and command-line service remain supported. Docker users can also run the service as a hardened non-root container with a read-only status dashboard. -## Remote Backup with command line +# Docker Quick Start -1. Clone project -2. Run `pdm install` -3. Run `pdm run server ` -4. Open Android App -> Setting -> Backup -> Self Host Backup +Docker Engine or Docker Desktop with Docker Compose is required. -## Example +```bash +docker compose up -d +``` + +The default API address is: + +```text +http://HOST:8000 +``` + +Use that address in LNReader under Settings, Backup, Self Host Backup. + +The default status dashboard path is: + +```text +http://HOST:8000/lnr-vault-7f3c9/ +``` + +On first startup the container creates the default Web UI user **admin** and a random password. The password is persisted in the storage directory and is printed once in the initial container logs. + +```bash +docker compose logs lnreader +``` + +The generated credentials can also be read from: + +```text +./data/.webui-auth/ +``` + +# Configuration + +A .env file is optional. Copy .env.example only when changing defaults. + +```bash +cp .env.example .env +``` + +The default image is: + +```text +ghcr.io/lnreader/remote-service:latest +``` + +The default host port is: + +```text +8000 +``` + +The default storage path is: + +```text +./data +``` + +The default Web UI username is: + +```text +admin +``` + +Leave WEB_UI_PASSWORD empty to generate and persist a random password automatically. + +To choose a password explicitly, set the following values in .env and recreate the service. + +```text +WEB_UI_USERNAME=admin +WEB_UI_PASSWORD=replace-with-a-long-password +``` + +```bash +docker compose up -d --force-recreate +``` + +PUID and PGID are optional Linux ownership overrides. Leave them blank unless the host requires a specific numeric owner for the mounted storage directory. + +PUBLIC_URL is optional and is used only for the address displayed by the status dashboard. Include the URL scheme when setting it. + +```text +PUBLIC_URL=https://lnreader.example.com +``` + +# API Compatibility + +The LNReader backup API remains available without Web UI authentication so existing LNReader clients can continue using it. + +```text +GET / +GET /healthz +GET /list +POST /upload/&& +GET /download/&& +``` + +The status dashboard is protected separately with HTTP Basic Authentication. + +# Desktop GUI + +The existing desktop GUI remains available through the project development environment. + +```bash +pdm install +pdm run gui +``` -- [How to backup and restore (video)](https://youtu.be/-0H-0j8y9OI) +The GUI can be packaged with the existing PyInstaller configuration. -## Docker Deployment +```bash +pdm run build +``` + +# Command Line + +The existing command-line service remains available. + +```bash +pdm install +pdm run server 0.0.0.0 8000 +``` + +Without explicit host and port arguments the server uses its default host and port settings. + +# Security -1. Clone project -2. Configure environment variables (optional): - - `PORT`: Port where the service will run (default: 8000) - - `STORAGE_PATH`: Local path to store LNReader data (default: ~/.LNReader) -3. Run with Docker Compose: - ```bash - docker-compose up -d - ``` -4. Open Android App -> Setting -> Backup -> Self Host Backup with `:` +The container runs the managed services as a non-root user. -Example with custom configuration: +Uploaded files are streamed to temporary files and committed with an atomic replacement after the complete request body is received. + +Backup and filename paths are resolved beneath the configured storage directory and traversal attempts are rejected. + +The status dashboard is read-only, protected by bcrypt-backed HTTP Basic Authentication, rate limited, and served with restrictive security headers. + +The Docker socket is not mounted into the service. + +The LNReader API itself is intentionally not protected by the dashboard password because doing so would change client compatibility. Do not expose the raw API port directly to the public Internet without an appropriate VPN, firewall, or reverse-proxy access policy. + +# Development + +Developer setup, tests, container validation, and CI behavior are documented in DEVELOPMENT.md. + +Run the complete local validation suite with: ```bash -PORT=9000 STORAGE_PATH=/path/to/backup docker-compose up -d +./scripts/test-verbose.sh ``` + +# License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a71e6d7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security + +## Web UI Credentials + +A fresh deployment automatically generates a cryptographically random password for the default admin user. + +The runtime Nginx credential is stored as bcrypt. + +For one-command deployment and later recovery, the generated username and plaintext password are also persisted under /home/lnreader/.LNReader/.webui-auth inside the mounted storage directory. + +The credential directory uses mode 0700 and its credential files use mode 0600. + +Treat the persistent LNReader storage directory as sensitive data. + +WEB_UI_USERNAME and WEB_UI_PASSWORD can replace the generated credential. Protect any .env file containing a password and do not commit it. + +Passwords are supplied to htpasswd through standard input rather than as a command-line password argument. + +## Runtime Privileges + +The container entrypoint performs required initialization as root and then starts Supervisor as the non-root lnreader user. + +PUID and PGID overrides may not be 0. + +Nginx, PHP-FPM, Gunicorn, and Supervisor run as the non-root service identity after initialization. + +## Status Console + +The status console is read-only and contains no JavaScript. + +It uses HTTP Basic Auth, per-client rate limiting, a restrictive Content Security Policy, no-store and no-index headers, anti-frame protection, no-sniff protection, and no-referrer behavior. + +The uncommon status path reduces routine scanning noise but is not a security boundary. + +## Transport Security + +HTTP Basic Auth does not encrypt credentials or traffic. + +Use HTTPS or a trusted encrypted VPN on untrusted networks. + +## LNReader API + +The LNReader-compatible backup API remains unauthenticated for client compatibility. + +The status-console password does not protect the root API, health endpoint, list endpoint, upload endpoint, or download endpoint. + +Avoid directly exposing container port 8000 to the public Internet without appropriate external network controls. + +## Upload Safety + +Backup names and filenames are constrained beneath the configured storage root. + +Uploads use hidden temporary files, streaming writes, fsync, and atomic replacement. + +Active-upload state uses file locking and atomic replacement. 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..85b6228 100644 --- a/docker/gunicorn.conf.py +++ b/docker/gunicorn.conf.py @@ -1,22 +1,22 @@ 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" +loglevel = os.environ.get("LOG_LEVEL", "info") +capture_output = True -# Protect against slowloris DOS attack -worker_connections = 1000 +# Nginx is the only HTTP peer of Gunicorn inside the container. It +# normalizes proxy scheme information to X-Forwarded-Proto before passing +# requests here. +forwarded_allow_ips = "127.0.0.1" +secure_scheme_headers = { + "X-FORWARDED-PROTO": "https", +} 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 "Chunked streaming upload" + +dd if=/dev/urandom of="$TMP/chunked.bin" bs=1M count=8 status=none + +curl --http1.1 --fail --silent --show-error -H 'Content-Length:' -H 'Transfer-Encoding: chunked' --data-binary @"$TMP/chunked.bin" "http://127.0.0.1:$PORT/upload/chunked.backup&&data.bin" > "$TMP/chunked-upload.json" + +curl --fail --silent --show-error "http://127.0.0.1:$PORT/download/chunked.backup&&data.bin" > "$TMP/chunked-download.bin" + +cmp "$TMP/chunked.bin" "$TMP/chunked-download.bin" + +grep -Fq '"size":8388608' "$TMP/chunked-upload.json" || + fail "chunked upload returned unexpected size" + +echo "PASS: chunked request streamed and round-tripped without Content-Length" + +banner "Live chunked request streaming" + +dd if=/dev/zero of="$TMP/live-chunked.bin" bs=1M count=2 status=none + +curl --http1.1 --fail --silent --show-error --limit-rate 256k -H 'Content-Length:' -H 'Transfer-Encoding: chunked' --data-binary @"$TMP/live-chunked.bin" "http://127.0.0.1:$PORT/upload/live-chunked.backup&&slow.bin" > "$TMP/live-chunked-upload.json" & + +chunked_pid=$! + +visible=0 + +for _ in $(seq 1 20); do + if docker exec "$NAME" sh -c 'grep -Fq "live-chunked.backup" /run/lnreader/uploads.json 2>/dev/null' + then + visible=1 + break + fi + + sleep 0.25 +done + +if [[ "$visible" != 1 ]]; then + kill "$chunked_pid" 2>/dev/null || true + wait "$chunked_pid" 2>/dev/null || true + fail "Gunicorn did not see chunked upload before client completed" +fi + +echo "PASS: chunked body reaches Gunicorn before upload completes" + +wait "$chunked_pid" + +curl --fail --silent --show-error "http://127.0.0.1:$PORT/download/live-chunked.backup&&slow.bin" > "$TMP/live-chunked-download.bin" + +cmp "$TMP/live-chunked.bin" "$TMP/live-chunked-download.bin" + +echo "PASS: live chunked upload completed and round-tripped" + +banner "Reverse proxy scheme normalization" + +proxy_response="$( + curl --fail --silent --show-error \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Ssl: on' \ + -H 'X-Forwarded-Protocol: ssl' \ + "http://127.0.0.1:$PORT/healthz" +)" + +[[ "$proxy_response" == '{"status":"ok"}' ]] || + fail "conflicting reverse-proxy scheme headers were not normalized: $proxy_response" + +echo "PASS: conflicting reverse-proxy scheme headers are normalized" + +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/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/scripts/test-verbose.sh b/scripts/test-verbose.sh new file mode 100755 index 0000000..3eb6cc6 --- /dev/null +++ b/scripts/test-verbose.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "$0")/.." +export PYTHONPATH="${PYTHONPATH:-}:$(pwd)" + +banner() { printf '\n\n========== %s ==========\n' "$1"; } + +banner "Environment" +python3 --version +printf 'repo=%s\n' "$(pwd)" + +banner "Shell syntax" +for script in docker/*.sh scripts/*.sh; do + bash -n "$script" 2>/dev/null || sh -n "$script" + printf 'PASS: %s\n' "$script" +done + +banner "Python compileall" +python3 -m compileall -q -f src tests scripts +printf 'PASS: compileall\n' + +banner "Static repository validation" +python3 scripts/validate.py + +banner "Pytest" +if python3 -c 'import pytest' >/dev/null 2>&1; then + python3 -m pytest -vv -ra +else + echo 'SKIP: pytest is not installed in this environment.' + echo ' Install requirements-test.txt to run the Python suite locally.' +fi + +banner "Live HTTP integration" +python3 scripts/live-http-test.py + +banner "Web UI validation" +if [[ "${RUN_WEBUI_TESTS:-1}" == "1" ]]; then + ./scripts/webui-test.sh +else + echo 'SKIP: Web UI tests disabled for this test run.' +fi + +banner "Docker/Compose validation" +if [[ "${RUN_DOCKER_TESTS:-1}" == "1" ]] \ + && command -v docker >/dev/null 2>&1 \ + && docker compose version >/dev/null 2>&1 \ + && docker info >/dev/null 2>&1; then + docker compose -f docker-compose.yml config + docker build --progress=plain --tag lnreader-remote-service:test . + ./scripts/container-smoke-test.sh lnreader-remote-service:test +else + echo 'SKIP: a usable Docker Engine with Compose is unavailable or Docker tests are disabled.' +fi + +banner "Done" diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100755 index 0000000..c27665f --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Offline/static repository validation used locally and in CI.""" +from __future__ import annotations + +import ast +import pathlib +import re +import sys + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[1] +errors: list[str] = [] +passes: list[str] = [] + + +def check(condition: bool, message: str) -> None: + (passes if condition else errors).append(message) + + +for py_file in sorted(ROOT.rglob("*.py")): + relative_parts = py_file.relative_to(ROOT).parts + if any( + part == "venv" + or part.startswith(".venv") + or part in {".git", ".tox", ".nox", "__pycache__"} + for part in relative_parts + ): + continue + try: + ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + passes.append(f"python syntax: {py_file.relative_to(ROOT)}") + except SyntaxError as exc: + errors.append(f"python syntax: {py_file.relative_to(ROOT)}: {exc}") + +for yaml_name in ["docker-compose.yml", ".github/workflows/container.yml"]: + path = ROOT / yaml_name + try: + parsed = yaml.safe_load(path.read_text(encoding="utf-8")) + check(isinstance(parsed, dict), f"yaml parse: {yaml_name}") + except Exception as exc: + errors.append(f"yaml parse: {yaml_name}: {exc}") + +compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") +check("build:" not in compose, "compose uses prebuilt image only") +check("ghcr.io/lnreader/remote-service:latest" in compose, "compose defaults to the upstream GHCR image") +check("HOST_PORT" in compose and 'target: /home/lnreader/.LNReader' in compose, "compose uses portable host port and storage settings") +check("source: ${STORAGE_PATH:-./data}" in compose, "compose defaults to relative portable storage") +check('PUID: "${PUID:-}"' in compose and 'PGID: "${PGID:-}"' in compose, "compose does not assume host UID or GID") +check("container_name:" not in compose, "compose avoids a global fixed container name") +check("secrets:" not in compose and "HTPASSWD_PATH" not in compose, "compose needs no external htpasswd secret") +check("WEB_UI_PASSWORD" in compose and "WEB_UI_USERNAME" in compose, "compose exposes optional Web UI credential overrides") +check(not (ROOT / "docker-compose.omv.yml").exists(), "repository has no OS-specific Compose file") +check(not (ROOT / "scripts/create-htpasswd.sh").exists(), "obsolete external htpasswd helper removed") + +pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") +check('name = "remote-service"' in pyproject, "pyproject preserves upstream project identity") +check('gui = "python main.py"' in pyproject, "pyproject preserves desktop GUI entry point") +check('server = { call = "src.server.server:main" }' in pyproject, "pyproject preserves command-line server entry point") +for token in ["gunicorn==26.2.0", "packaging==26.3", "pytest==9.1.1", "PyYAML==6.0.3"]: + check(token in pyproject or token in (ROOT / "requirements-test.txt").read_text() or token in (ROOT / "requirements-docker.txt").read_text(), f"dependency pin present: {token}") + +dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") +for token in [ + "docker/dockerfile:1.7.0@sha256:dbbd5e059e8a07ff7ea6233b213b36aa516b4c53c645f1817a4dd18b83cbea56", + "python:3.13.15-slim-bookworm", + "apache2-utils=\"${APACHE2_UTILS_VERSION}\"", + "gosu=\"${GOSU_VERSION}\"", + "nginx=\"${NGINX_VERSION}\"", + "php8.2-fpm=\"${PHP_FPM_VERSION}\"", + "supervisor=\"${SUPERVISOR_VERSION}\"", + "HEALTHCHECK", + "ENTRYPOINT", +]: + check(token in dockerfile, f"Dockerfile pin/config contains {token}") +for version_pin in [ + "APACHE2_UTILS_VERSION=2.4.68-1~deb12u1", + "GOSU_VERSION=1.14-1+b10", + "NGINX_VERSION=1.22.1-9+deb12u9", + "PHP_FPM_VERSION=8.2.33-1~deb12u1", + "SUPERVISOR_VERSION=4.2.5-1", +]: + check(version_pin in dockerfile, f"Dockerfile package pin present: {version_pin}") + +workflow = (ROOT / ".github/workflows/container.yml").read_text(encoding="utf-8") +for token in [ + "actions/checkout@v7.0.1", + "actions/setup-python@v7.0.0", + "docker/setup-qemu-action@v4.2.0", + "tonistiigi/binfmt:qemu-v10.2.3", + "docker/setup-buildx-action@v4.2.0", + "docker/login-action@v4.6.0", + "docker/metadata-action@v6.2.0", + "docker/build-push-action@v7.1.0", + "actions/upload-artifact@v7.0.1", + "linux/amd64,linux/arm64", + "packages: write", +]: + check(token in workflow, f"workflow pin/config contains {token}") +for python_version in ["3.10.11", "3.11.9", "3.12.10", "3.13.15", "3.14.7"]: + check(python_version in workflow, f"CI Python version pinned: {python_version}") +check(not re.search(r"uses:\s+[^\n]+@v\d+\s*$", workflow, re.MULTILINE), "workflow avoids floating major-only action tags") + +entrypoint = (ROOT / "docker/docker-entrypoint.sh").read_text(encoding="utf-8") +auth_init = (ROOT / "docker/init-webui-auth.sh").read_text(encoding="utf-8") +check(bool(re.search(r"exec\s+gosu\s+lnreader", entrypoint)), "entrypoint drops services to lnreader") +check('PUID="${PUID:-}"' in entrypoint and 'PGID="${PGID:-}"' in entrypoint, "entrypoint accepts blank UID and GID") +check('stat -c \'%u\'' in entrypoint and 'stat -c \'%g\'' in entrypoint, "entrypoint can reuse mounted storage ownership") +check('PUID" -eq 0' in entrypoint and 'PGID" -eq 0' in entrypoint, "entrypoint rejects root UID and GID") +check("init-webui-auth.sh" in entrypoint, "entrypoint initializes Web UI auth automatically") +smoke = (ROOT / "scripts/container-smoke-test.sh").read_text(encoding="utf-8") +check("-e PUID=" not in smoke and "-e PGID=" not in smoke, "container smoke test exercises automatic non-root UID and GID defaults") +check("secrets.token_urlsafe" in auth_init, "auth initializer uses cryptographic random password generation") +check("htpasswd -Bni" in auth_init, "auth initializer creates bcrypt without password argv exposure") +check(".webui-auth" in auth_init, "generated credentials persist inside storage volume") + +nginx = (ROOT / "docker/nginx.conf.template").read_text(encoding="utf-8") +for token in ["auth_basic", "Content-Security-Policy", "limit_req", "limit_except GET POST", "X-Robots-Tag"]: + check(token in nginx, f"nginx UI protection contains {token}") +check( + "form-action 'self'" in nginx, + "nginx CSP permits same-origin forms", +) +check("/admin" not in nginx.lower(), "dashboard does not use common /admin path") +for temp_path in [ + "client_body_temp_path /run/lnreader/client_temp", + "proxy_temp_path /run/lnreader/proxy_temp", + "fastcgi_temp_path /run/lnreader/fastcgi_temp", + "uwsgi_temp_path /run/lnreader/uwsgi_temp", + "scgi_temp_path /run/lnreader/scgi_temp", +]: + check(temp_path in nginx, f"nginx non-root temp path: {temp_path.split()[0]}") +check("/var/lib/nginx" not in nginx, "nginx avoids root-owned runtime temp paths") + +check( + "map $http_x_forwarded_proto $lnreader_forwarded_proto" in nginx, + "nginx normalizes forwarded proxy scheme", +) +check( + "proxy_set_header X-Forwarded-Proto $lnreader_forwarded_proto;" in nginx, + "nginx sends normalized X-Forwarded-Proto", +) +check( + 'proxy_set_header X-Forwarded-Ssl "";' in nginx, + "nginx strips alternate X-Forwarded-Ssl", +) +check( + 'proxy_set_header X-Forwarded-Protocol "";' in nginx, + "nginx strips alternate X-Forwarded-Protocol", +) + +gunicorn_config = (ROOT / "docker/gunicorn.conf.py").read_text(encoding="utf-8") +check( + 'forwarded_allow_ips = "127.0.0.1"' in gunicorn_config, + "gunicorn trusts forwarded headers only from internal Nginx", +) +check( + '"X-FORWARDED-PROTO": "https"' in gunicorn_config, + "gunicorn uses one normalized secure scheme header", +) + +php = (ROOT / "web/index.php").read_text(encoding="utf-8") +for token in [ + "Current backup uploads", + "Stored backups", + "LNReader app server URL", + "Delete permanently", + "safe_backup_dir", + "csrf_token", + "delete_backup_tree", + "backup_has_active_upload", + "htmlspecialchars", +]: + check(token in php, f"PHP dashboard contains {token}") +check("&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..915a696 --- /dev/null +++ b/scripts/webui-test.sh @@ -0,0 +1,173 @@ +#!/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 backup management 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