From c831e86bfe3bfd9305486fc93289a221e3001d9b Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:18:55 +0200 Subject: [PATCH] docker,docs: resync the root Dockerfile and add a security policy (#907) --- .github/workflows/python.yml | 3 + CHANGELOG.md | 2 + Dockerfile | 52 ++++++++++- SECURITY.md | 21 +++++ tests/test_dockerfile_sync.py | 161 ++++++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 SECURITY.md create mode 100644 tests/test_dockerfile_sync.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index ae2637c1..4f06bb10 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -23,6 +23,9 @@ on: - '.github/**' - 'data/**' - 'tests/**' + # tests/test_dockerfile_sync.py compares these two files. + - 'Dockerfile' + - 'web/Dockerfile' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c91a7a..3a00355d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * Web: the federation table renders a Reticulum zero count as a dash instead of a tile (SPEC RD8) ### Fixes +* Docker: the root `Dockerfile` builds the same image as `web/Dockerfile`, including Chromium for Open Graph previews (skip it with `--build-arg WITH_OG_IMAGE=0`), the custom pages directory, and the `APP_VERSION` build arg (SPEC DF1) +* Docs: `SECURITY.md` says how to report a vulnerability privately (SPEC SEC1) * Data/Web: Reticulum reports a frequency and LoRa preset, read from the RNS config (SPEC RL1-RL3) * Web: federation node counts distinguish zero from not-reported uniformly across all three protocols (SPEC RL4) * Web: a destination's identifier links to its identity page (SPEC RL5) diff --git a/Dockerfile b/Dockerfile index b3077528..e746c034 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,12 +51,26 @@ RUN python3 -m venv /opt/meshtastic-venv && \ # Production stage FROM ruby:3.3-alpine AS production -# Install runtime dependencies +# Build-time toggle controlling whether Chromium is bundled into the image +# for runtime Open Graph preview rendering. Operators on size-constrained +# hosts can build with `--build-arg WITH_OG_IMAGE=0` to skip Chromium and +# its font/library payload (~150 MB). The web app falls back to the +# packaged default PNG when Chromium is missing, and operators can point +# `OG_IMAGE_URL` at a CDN-hosted preview instead. +ARG WITH_OG_IMAGE=1 +ENV WITH_OG_IMAGE=${WITH_OG_IMAGE} + +# Install runtime dependencies. Chromium powers the runtime Open Graph +# preview generator; the accompanying font and library packages are the +# minimum set required to render the dashboard headlessly on Alpine. RUN apk add --no-cache \ python3 \ sqlite \ tzdata \ - curl + curl \ + && if [ "$WITH_OG_IMAGE" = "1" ]; then \ + apk add --no-cache chromium nss freetype harfbuzz ttf-freefont; \ + fi # Create non-root user RUN addgroup -g 1000 -S potatomesh && \ @@ -79,6 +93,7 @@ COPY --chown=potatomesh:potatomesh web/spec ./spec COPY --chown=potatomesh:potatomesh web/public ./public COPY --chown=potatomesh:potatomesh web/views ./views COPY --chown=potatomesh:potatomesh web/scripts ./scripts +COPY --chown=potatomesh:potatomesh web/pages ./pages # Copy SQL schema files from data directory COPY --chown=potatomesh:potatomesh data/*.sql /data/ @@ -87,7 +102,8 @@ COPY --chown=potatomesh:potatomesh data/mesh_ingestor/decode_payload.py /app/dat # Create data and configuration directories with correct ownership RUN mkdir -p /app/.local/share/potato-mesh \ && mkdir -p /app/.config/potato-mesh/well-known \ - && chown -R potatomesh:potatomesh /app/.local/share /app/.config + && mkdir -p /app/pages \ + && chown -R potatomesh:potatomesh /app/.local/share /app/.config /app/pages # Switch to non-root user USER potatomesh @@ -95,6 +111,19 @@ USER potatomesh # Expose port EXPOSE 41447 +# Baked application version (SPEC AV1). The image ships without `.git` +# (`.dockerignore` excludes it), so `determine_app_version`'s in-image `git +# describe` would fall back to the constant `Config.version_fallback` and the +# `?v=` asset cache-buster would be identical across every build between version +# bumps. CI (`.github/workflows/docker.yml`) computes `git describe` from its +# full checkout and passes it as `--build-arg APP_VERSION=…`; the app prefers +# this ENV, making the buster unique per build so `AssetCacheControl` can serve +# versioned JS/CSS `immutable`. Declared late so changing it only rebuilds the +# final cheap layers. Empty by default: a plain `docker build` with no build-arg +# keeps today's git-lookup / fallback behavior unchanged. +ARG APP_VERSION="" +ENV APP_VERSION=${APP_VERSION} + # Default environment variables (can be overridden by host) ENV RACK_ENV=production \ APP_ENV=production \ @@ -108,6 +137,23 @@ ENV RACK_ENV=production \ MAP_ZOOM="" \ MAX_DISTANCE=42 \ CONTACT_LINK="#potatomesh:dod.ngo" \ + FERRUM_BROWSER_PATH=/usr/bin/chromium \ + MESHTASTIC_PRESET="" \ + MESHTASTIC_FREQ="" \ + MESHCORE_PRESET="" \ + MESHCORE_FREQ="" \ + ANNOUNCEMENT="" \ + EVENTS=1 \ + OG_IMAGE_URL="" \ + PAGES_DIR=/app/pages \ + PROM_REPORT_IDS="" \ + ALLOWED_CHANNELS="" \ + HIDDEN_CHANNELS="" \ + FEDERATION=1 \ + PRIVATE=0 \ + CONNECTION=/dev/ttyACM0 \ + INSTANCE_DOMAIN="" \ + API_TOKEN="" \ DEBUG=0 # Start the application diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..362a5686 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ + + + +# Security policy + +## Supported versions + +Only the latest release receives security fixes. + +## Reporting a vulnerability + +Report vulnerabilities privately through GitHub private vulnerability +reporting: + +Include: + +- the affected version +- steps to reproduce +- the impact + +Do not open public issues, pull requests, or discussions for vulnerabilities. diff --git a/tests/test_dockerfile_sync.py b/tests/test_dockerfile_sync.py new file mode 100644 index 00000000..71eee115 --- /dev/null +++ b/tests/test_dockerfile_sync.py @@ -0,0 +1,161 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensure the root ``Dockerfile`` stays identical to ``web/Dockerfile``. + +``web/Dockerfile`` is the canonical web image build; the root ``Dockerfile`` is +a backward-compatibility copy that nothing in the repository builds, so its +drift goes unnoticed without this guard. Each file keeps its own leading +comment header (the license block, plus the root copy's compatibility note); +the parser directives and every line after the header must match. +""" + +from __future__ import annotations + +import difflib +import itertools +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +#: Repository-relative path of the canonical web image build file. +CANONICAL_DOCKERFILE = "web/Dockerfile" + +#: Repository-relative path of the backward-compatibility copy. +ROOT_DOCKERFILE = "Dockerfile" + +#: BuildKit parser directives (``# syntax=``, ``# escape=``, ``# check=``). +#: They are written as comments but change how the file is parsed, so they are +#: compared with the body rather than stripped with the header. +_PARSER_DIRECTIVE = re.compile(r"\s*#\s*(?:syntax|escape|check)\s*=", re.IGNORECASE) + + +def _is_header_line(line: str) -> bool: + """Report whether ``line`` can belong to a Dockerfile's leading header. + + Args: + line: One line of a Dockerfile, without its newline. + + Returns: + ``True`` for a blank line or a ``#`` comment, ``False`` otherwise. + """ + + stripped = line.strip() + return not stripped or stripped.startswith("#") + + +def _dockerfile_body(text: str) -> list[str]: + """Return a Dockerfile's parser directives and body, without its header. + + The leading header (every comment and blank line before the first + instruction) is dropped. Parser directives at the top of the file are kept, + as is every line from the first instruction onward, comments included. + + Args: + text: Full contents of a Dockerfile. + + Returns: + The parser directive lines followed by the body lines, without line + terminators. + """ + + lines = text.splitlines() + # Docker honours parser directives only as the very first lines of a + # file, so collection stops at the first line that is not one. + directives = list(itertools.takewhile(_PARSER_DIRECTIVE.match, lines)) + body = itertools.dropwhile(_is_header_line, lines[len(directives) :]) + return directives + list(body) + + +def _body_drift(canonical: str, copy: str) -> str: + """Diff the canonical file's directives and body against the copy's. + + Args: + canonical: Contents of ``web/Dockerfile``. + copy: Contents of the root ``Dockerfile``. + + Returns: + A unified diff labelled with both repository paths (``-`` lines are + missing from the copy, ``+`` lines exist only in the copy), or an empty + string when they match. Hunk line numbers count the compared lines, + not the lines of either file. + """ + + diff = difflib.unified_diff( + _dockerfile_body(canonical), + _dockerfile_body(copy), + fromfile=CANONICAL_DOCKERFILE, + tofile=ROOT_DOCKERFILE, + lineterm="", + ) + return "\n".join(diff) + + +def test_root_dockerfile_matches_canonical_web_dockerfile() -> None: + """Guard the root ``Dockerfile`` against drift from ``web/Dockerfile``.""" + + drift = _body_drift( + (REPO_ROOT / CANONICAL_DOCKERFILE).read_text(encoding="utf-8"), + (REPO_ROOT / ROOT_DOCKERFILE).read_text(encoding="utf-8"), + ) + assert not drift, ( + f"{ROOT_DOCKERFILE} has drifted from {CANONICAL_DOCKERFILE}, the canonical " + f"build file. Copy everything below {CANONICAL_DOCKERFILE}'s license " + f"header into {ROOT_DOCKERFILE}, below {ROOT_DOCKERFILE}'s own header, " + f"and keep the two files' line-1 parser directives (# syntax=) identical. " + f"Diff of the compared lines (leading comment headers excluded):\n{drift}" + ) + + +def test_dockerfile_body_drops_only_the_leading_header() -> None: + """Parser directives and comments after the first instruction survive.""" + + text = ( + "# syntax=docker/dockerfile:1.6\n" + "# License notice\n" + "\n" + "# NOTE: compatibility copy\n" + "\n" + "FROM alpine AS base\n" + "\n" + "# In-body comment\n" + "RUN true\n" + ) + + assert _dockerfile_body(text) == [ + "# syntax=docker/dockerfile:1.6", + "FROM alpine AS base", + "", + "# In-body comment", + "RUN true", + ] + + +def test_body_drift_ignores_headers_and_reports_build_changes() -> None: + """Header-only differences are not drift; directive and body changes are.""" + + canonical = "# syntax=docker/dockerfile:1.6\n# License\n\nFROM alpine\nRUN true\n" + noted = canonical.replace("\n\n", "\n\n# NOTE: compatibility copy\n\n") + reworded = canonical.replace("RUN true", "RUN false") + bumped = canonical.replace("dockerfile:1.6", "dockerfile:1.7") + + assert _body_drift(canonical, noted) == "" + + body_drift = _body_drift(canonical, reworded) + assert body_drift.startswith(f"--- {CANONICAL_DOCKERFILE}\n+++ {ROOT_DOCKERFILE}") + assert "-RUN true" in body_drift + assert "+RUN false" in body_drift + + assert "+# syntax=docker/dockerfile:1.7" in _body_drift(canonical, bumped)