diff --git a/Dockerfile b/Dockerfile index 6e324e3f..64096252 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,9 +50,11 @@ RUN poetry config virtualenvs.create false && \ COPY meshchatx ./meshchatx COPY scripts/docker-bake-lxst-filterlib-musl.py ./scripts/docker-bake-lxst-filterlib-musl.py +COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public RUN pip install --no-cache-dir . && \ + python scripts/patch_lxst_pyogg_ogg_ctypes.py && \ python scripts/docker-bake-lxst-filterlib-musl.py && \ find /opt/venv -type d -name "tests" -exec rm -rf {} + && \ find /opt/venv -type d -name "test" -exec rm -rf {} + && \ diff --git a/Dockerfile.hardened b/Dockerfile.hardened new file mode 100644 index 00000000..1b201944 --- /dev/null +++ b/Dockerfile.hardened @@ -0,0 +1,90 @@ +# syntax=docker/dockerfile:1 +# Multi-stage image on Wolfi (Chainguard): Node dev for frontend, Python dev for +# Poetry/cffi builds, Python dev for runtime so native libs can be installed with +# apk (the minimal chainguard/python image has no shell or package manager). +# Wolfi provides the opus package (libopus); Alpine's opusfile split is not present. +# Glibc runtime uses LXST wheels as published; the musl filterlib bake is not used. +# Voicemail greeting synthesis is optional without espeak-ng (see voicemail_manager). + +ARG NODE_IMAGE=cgr.dev/chainguard/node:latest-dev +ARG PYTHON_BUILD_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG PYTHON_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev + +FROM ${NODE_IMAGE} AS build-frontend +USER root +WORKDIR /src +RUN apk add --no-cache git +COPY package.json pnpm-lock.yaml vite.config.js ./ +COPY patches ./patches +COPY meshchatx/src/frontend ./meshchatx/src/frontend +RUN npm install -g pnpm@10.33.0 && \ + pnpm config set verify-store-integrity true && \ + pnpm install --frozen-lockfile && \ + pnpm run build-frontend + +FROM ${PYTHON_BUILD_IMAGE} AS builder +USER root +WORKDIR /build +RUN apk add --no-cache build-base git pkgconf openssl-dev libffi-dev linux-headers + +RUN pip install --no-cache-dir --upgrade "pip>=26.0" poetry setuptools wheel "jaraco.context>=6.1.0" + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +RUN pip install --no-cache-dir --upgrade "pip>=26.0" "setuptools" "jaraco.context>=6.1.0" + +COPY pyproject.toml poetry.lock README.md ./ +RUN poetry config virtualenvs.create false && \ + poetry check --lock && \ + poetry install --no-root --only main --no-interaction --no-ansi && \ + rm -rf /root/.cache/pip /root/.cache/pypoetry + +COPY meshchatx ./meshchatx +COPY scripts/patch_lxst_pyogg_ogg_ctypes.py ./scripts/patch_lxst_pyogg_ogg_ctypes.py +COPY --from=build-frontend /src/meshchatx/public ./meshchatx/public + +RUN pip install --no-cache-dir . && \ + python scripts/patch_lxst_pyogg_ogg_ctypes.py && \ + find /opt/venv -type d -name "tests" -exec rm -rf {} + && \ + find /opt/venv -type d -name "test" -exec rm -rf {} + && \ + find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + && \ + python -m compileall -q /opt/venv + +FROM ${PYTHON_RUNTIME_IMAGE} + +ARG OCI_REVISION="" +ARG OCI_VERSION="" +ARG OCI_CREATED="" + +USER root +RUN apk add --no-cache opus libffi shadow && \ + pip install --no-cache-dir --upgrade "pip>=26.0" "setuptools" "jaraco.context>=6.1.0" && \ + rm -rf /root/.cache/pip && \ + groupadd -g 1000 meshchat && \ + useradd --uid 1000 --gid 1000 --create-home --home-dir /home/meshchat \ + --shell /sbin/nologin meshchat && \ + mkdir -p /config && chown meshchat:meshchat /config + +COPY --from=builder --chown=meshchat:meshchat /opt/venv /opt/venv +COPY scripts/docker_entrypoint_chainguard.py /docker-entrypoint.py + +LABEL org.opencontainers.image.source="https://git.quad4.io/RNS-Things/MeshChatX" +LABEL org.opencontainers.image.description="MeshChatX is a all in one Reticulum client." +LABEL org.opencontainers.image.licenses="MIT AND 0BSD" +LABEL org.opencontainers.image.authors="Quad4" +LABEL org.opencontainers.image.revision="${OCI_REVISION}" +LABEL org.opencontainers.image.version="${OCI_VERSION}" +LABEL org.opencontainers.image.created="${OCI_CREATED}" + +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +USER meshchat + +HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ + CMD ["python", "-c", "import ssl, urllib.request; urllib.request.urlopen('https://127.0.0.1:8000/api/v1/status', context=ssl._create_unverified_context())"] + +ENTRYPOINT ["/usr/bin/python", "/docker-entrypoint.py"] +CMD ["/opt/venv/bin/meshchatx", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"] diff --git a/scripts/docker_entrypoint_chainguard.py b/scripts/docker_entrypoint_chainguard.py new file mode 100644 index 00000000..3e0c0716 --- /dev/null +++ b/scripts/docker_entrypoint_chainguard.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: 0BSD +"""Entrypoint for Chainguard/minimal-style images without a POSIX shell.""" + +from __future__ import annotations + +import os +import pwd +import sys + + +def _chown_tree(path: str, uid: int, gid: int) -> None: + try: + st = os.lstat(path) + except FileNotFoundError: + return + if not (st.st_uid == uid and st.st_gid == gid): + os.lchown(path, uid, gid) + if not os.path.isdir(path) or os.path.islink(path): + return + with os.scandir(path) as it: + for entry in it: + _chown_tree(entry.path, uid, gid) + + +def main() -> None: + argv = sys.argv[1:] + if not argv: + print("docker-entrypoint: missing command", file=sys.stderr) + raise SystemExit(1) + + if os.getuid() == 0: + pw = pwd.getpwnam("meshchat") + _chown_tree("/config", pw.pw_uid, pw.pw_gid) + os.initgroups(pw.pw_name, pw.pw_gid) + os.setgid(pw.pw_gid) + os.setuid(pw.pw_uid) + os.environ.setdefault("HOME", pw.pw_dir) + + os.execvp(argv[0], argv) + + +if __name__ == "__main__": + main()