From d79de891b8131b06636550eb4e067c6d6e9ddd61 Mon Sep 17 00:00:00 2001 From: sligter <1771322848@qq.com> Date: Fri, 10 Jul 2026 16:15:31 +0800 Subject: [PATCH] fix(docker): migrate volumes before non-root startup --- Dockerfile | 8 ++- docker-compose-dev.yaml | 34 +++++++++++ docker-compose.yml | 34 ++++++++++- docker-permissions-init.sh | 101 +++++++++++++++++++++++++++++++ tests/test_container_security.py | 58 ++++++++++++++++++ 5 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 docker-permissions-init.sh diff --git a/Dockerfile b/Dockerfile index 3ee7a57..e8b62d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,12 +169,14 @@ WORKDIR /app COPY run.py ./ COPY src/ ./src/ COPY template_examples/ ./template_examples/ -COPY docker-healthcheck.sh docker-entrypoint.sh /usr/local/bin/ +COPY docker-healthcheck.sh docker-entrypoint.sh docker-permissions-init.sh /usr/local/bin/ COPY .env.example ./.env # Create directories and set permissions in one layer -RUN sed -i 's/\r$//' /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh && \ - chmod +x /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh && \ +RUN sed -i 's/\r$//' /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-permissions-init.sh && \ + chmod +x /usr/local/bin/docker-healthcheck.sh \ + /usr/local/bin/docker-entrypoint.sh \ + /usr/local/bin/docker-permissions-init.sh && \ mkdir -p temp/ai_responses_cache temp/style_genes_cache temp/summeryanyfile_cache temp/templates_cache \ research_reports lib/Linux lib/MacOS lib/Windows uploads data && \ chown -R landppt:landppt /app /home/landppt && \ diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index 6677270..a5a33a4 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -7,6 +7,7 @@ x-landppt-build: &landppt-build x-landppt-volumes: &landppt-volumes - ./:/app + - ${LANDPPT_ENV_FILE:-./.env}:/app/.env - landppt_data:/app/data - landppt_uploads:/app/uploads - landppt_reports:/app/research_reports @@ -51,6 +52,8 @@ x-landppt-env: &landppt-env S3_PRESIGNED_URL_EXPIRES_SECONDS: ${S3_PRESIGNED_URL_EXPIRES_SECONDS:-3600} x-landppt-depends-on: &landppt-depends-on + permissions-init: + condition: service_completed_successfully postgres: condition: service_healthy valkey: @@ -61,6 +64,37 @@ x-landppt-depends-on: &landppt-depends-on condition: service_completed_successfully services: + permissions-init: + build: *landppt-build + image: ${LANDPPT_DEV_IMAGE:-landppt-dev:latest} + user: "0:0" + entrypoint: ["/usr/local/bin/docker-permissions-init.sh"] + environment: + LANDPPT_UID: "10001" + LANDPPT_GID: "10001" + volumes: + - ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env + - landppt_data:/mnt/landppt/data + - landppt_uploads:/mnt/landppt/uploads + - landppt_reports:/mnt/landppt/reports + - landppt_cache:/mnt/landppt/cache + - landppt_lib:/mnt/landppt/lib + network_mode: "none" + read_only: true + restart: "no" + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true + healthcheck: + disable: true + landppt: build: *landppt-build image: ${LANDPPT_DEV_IMAGE:-landppt-dev:latest} diff --git a/docker-compose.yml b/docker-compose.yml index 9538cff..fb1683a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ x-landppt-volumes: &landppt-volumes - - ./.env:/app/.env + - ${LANDPPT_ENV_FILE:-./.env}:/app/.env - landppt_data:/app/data - landppt_uploads:/app/uploads - landppt_reports:/app/research_reports @@ -44,6 +44,8 @@ x-landppt-env: &landppt-env S3_PRESIGNED_URL_EXPIRES_SECONDS: ${S3_PRESIGNED_URL_EXPIRES_SECONDS:-3600} x-landppt-depends-on: &landppt-depends-on + permissions-init: + condition: service_completed_successfully postgres: condition: service_healthy valkey: @@ -54,6 +56,36 @@ x-landppt-depends-on: &landppt-depends-on condition: service_completed_successfully services: + permissions-init: + image: ${LANDPPT_IMAGE:-bradleylzh/landppt:latest} + user: "0:0" + entrypoint: ["/usr/local/bin/docker-permissions-init.sh"] + environment: + LANDPPT_UID: "10001" + LANDPPT_GID: "10001" + volumes: + - ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env + - landppt_data:/mnt/landppt/data + - landppt_uploads:/mnt/landppt/uploads + - landppt_reports:/mnt/landppt/reports + - landppt_cache:/mnt/landppt/cache + - landppt_lib:/mnt/landppt/lib + network_mode: "none" + read_only: true + restart: "no" + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + - DAC_OVERRIDE + - SETUID + - SETGID + security_opt: + - no-new-privileges:true + healthcheck: + disable: true + landppt: image: ${LANDPPT_IMAGE:-bradleylzh/landppt:latest} shm_size: ${LANDPPT_SHM_SIZE:-4gb} diff --git a/docker-permissions-init.sh b/docker-permissions-init.sh new file mode 100644 index 0000000..f89ed80 --- /dev/null +++ b/docker-permissions-init.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +set -eu + +TARGET_UID="${LANDPPT_UID:-10001}" +TARGET_GID="${LANDPPT_GID:-10001}" +MOUNT_ROOT="/mnt/landppt" +MARKER_NAME=".landppt-permissions-v1" + +log() { + printf '[permissions-init] %s\n' "$1" +} + +warn() { + printf '[permissions-init] WARNING: %s\n' "$1" >&2 +} + +fail() { + printf '[permissions-init] ERROR: %s\n' "$1" >&2 + exit 1 +} + +validate_access() { + target_path="$1" + target_kind="$2" + + if ! /opt/venv/bin/python - "$TARGET_UID" "$TARGET_GID" "$target_path" "$target_kind" <<'PY' +import os +import sys + +uid = int(sys.argv[1]) +gid = int(sys.argv[2]) +path = sys.argv[3] +kind = sys.argv[4] + +os.setgroups([]) +os.setgid(gid) +os.setuid(uid) + +if kind == "file": + descriptor = os.open(path, os.O_WRONLY | os.O_APPEND) + os.close(descriptor) +else: + probe = os.path.join(path, f".landppt-write-test-{os.getpid()}") + descriptor = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + os.unlink(probe) +PY + then + fail "UID ${TARGET_UID} cannot write ${target_path}" + fi +} + +migrate_volume() { + volume_path="$1" + marker_path="${volume_path}/${MARKER_NAME}" + + [ -d "$volume_path" ] || fail "Volume path is missing: ${volume_path}" + + if [ ! -f "$marker_path" ]; then + log "Migrating ${volume_path} to ${TARGET_UID}:${TARGET_GID}" + chown -R "${TARGET_UID}:${TARGET_GID}" "$volume_path" + chmod -R u+rwX "$volume_path" + validate_access "$volume_path" directory + : > "$marker_path" + chown "${TARGET_UID}:${TARGET_GID}" "$marker_path" + chmod 600 "$marker_path" + else + log "Migration marker found for ${volume_path}; validating" + validate_access "$volume_path" directory + fi +} + +configure_env_file() { + env_path="${MOUNT_ROOT}/env/.env" + + [ -e "$env_path" ] || fail "Mounted .env is missing: ${env_path}" + [ -f "$env_path" ] || fail "Mounted .env is not a regular file: ${env_path}" + + if ! chgrp "$TARGET_GID" "$env_path" 2>/dev/null; then + warn "Could not change .env group; checking effective access" + fi + if ! chmod g+rw,o-rwx "$env_path" 2>/dev/null; then + warn "Could not change .env mode; checking effective access" + fi + + validate_access "$env_path" file +} + +main() { + [ "$(id -u)" -eq 0 ] || fail "Permission migration must run as root" + + configure_env_file + for volume_name in data uploads reports cache lib; do + migrate_volume "${MOUNT_ROOT}/${volume_name}" + done + + log "Permission migration complete" +} + +main "$@" diff --git a/tests/test_container_security.py b/tests/test_container_security.py index 8876162..f77ef64 100644 --- a/tests/test_container_security.py +++ b/tests/test_container_security.py @@ -42,3 +42,61 @@ def test_entrypoint_checks_identity_and_never_repairs_permissions(): assert "chmod " not in entrypoint assert "chown " not in entrypoint assert 'cp "/app/.env"' not in entrypoint + + +def compose_init_service(compose_text: str) -> str: + return compose_text.split("\n permissions-init:\n", 1)[1].split( + "\n landppt:\n", 1 + )[0] + + +def compose_dependency_anchor(compose_text: str) -> str: + return compose_text.split( + "x-landppt-depends-on: &landppt-depends-on", 1 + )[1].split("\n\nservices:", 1)[0] + + +@pytest.mark.parametrize("compose_path", ["docker-compose.yml", "docker-compose-dev.yaml"]) +def test_compose_migrates_permissions_before_app_start(compose_path: str): + compose_text = read_repo_file(compose_path) + init_service = compose_init_service(compose_text) + dependencies = compose_dependency_anchor(compose_text) + + assert 'user: "0:0"' in init_service + assert 'entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]' in init_service + assert 'network_mode: "none"' in init_service + assert "read_only: true" in init_service + assert 'restart: "no"' in init_service + assert "- ALL" in init_service + for capability in ("CHOWN", "FOWNER", "DAC_OVERRIDE", "SETUID", "SETGID"): + assert f"- {capability}" in init_service + assert "no-new-privileges:true" in init_service + + for mount in ( + "${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env", + "landppt_data:/mnt/landppt/data", + "landppt_uploads:/mnt/landppt/uploads", + "landppt_reports:/mnt/landppt/reports", + "landppt_cache:/mnt/landppt/cache", + "landppt_lib:/mnt/landppt/lib", + ): + assert mount in init_service + + assert "/app" not in init_service + assert "${LANDPPT_ENV_FILE:-./.env}:/app/.env" in compose_text + assert "permissions-init:" in dependencies + assert "condition: service_completed_successfully" in dependencies + + +def test_permission_migration_script_is_idempotent_and_validates_as_target_user(): + script = read_repo_file("docker-permissions-init.sh") + dockerfile = read_repo_file("Dockerfile") + + assert ".landppt-permissions-v1" in script + assert 'LANDPPT_UID:-10001' in script + assert 'LANDPPT_GID:-10001' in script + assert "os.setgid(gid)" in script + assert "os.setuid(uid)" in script + assert 'chown -R "${TARGET_UID}:${TARGET_GID}"' in script + assert "chmod -R u+rwX" in script + assert "docker-permissions-init.sh /usr/local/bin/" in dockerfile