From 7e59315e3d8aa1037b58b0a3c3fb9e1349666c5b Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 3 Jul 2025 20:07:42 +0900 Subject: [PATCH] feat: Add rootless Docker support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #547 - Add support for rootless Docker images to avoid permission issues. Key changes: - Add Dockerfile.rootless that runs as UID 1000 by default - Create simplified entrypoint script without chown operations - Add build-rootless.py to build rootless variants with -rootless suffix - Document rootless usage in README-ROOTLESS.md - Update main README with rootless section The rootless images eliminate common permission problems by: - Running as non-root from the start (USER 1000:1000) - Avoiding recursive chown operations that can cause race conditions - Using open permissions (777) on directories during build - Not supporting PUID/PGID environment variables This provides a cleaner solution for rootless Docker users and those experiencing permission issues with volumes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README-ROOTLESS.md | 136 +++++++++++++++++++++ README.md | 31 +++++ build-rootless.py | 128 +++++++++++++++++++ docker/Dockerfile.rootless | 93 ++++++++++++++ docker/files/docker-entrypoint-rootless.sh | 124 +++++++++++++++++++ 5 files changed, 512 insertions(+) create mode 100644 README-ROOTLESS.md create mode 100755 build-rootless.py create mode 100644 docker/Dockerfile.rootless create mode 100755 docker/files/docker-entrypoint-rootless.sh diff --git a/README-ROOTLESS.md b/README-ROOTLESS.md new file mode 100644 index 0000000..260a8ab --- /dev/null +++ b/README-ROOTLESS.md @@ -0,0 +1,136 @@ +# Rootless Docker Support + +This document describes the rootless Docker images for Factorio, which are designed to work better with rootless Docker installations and avoid permission issues. + +## What is Rootless Docker? + +Rootless Docker allows running the Docker daemon and containers as a non-root user, which improves security by eliminating the need for root privileges. However, it introduces complexity with UID/GID mapping that can cause permission issues with volumes. + +## Rootless Image Tags + +For each regular Factorio image tag, there's a corresponding rootless tag with the `-rootless` suffix: + +- `latest` → `latest-rootless` +- `stable` → `stable-rootless` +- `2.0.55` → `2.0.55-rootless` +- etc. + +## Key Differences from Regular Images + +1. **No dynamic UID/GID mapping**: The rootless images run as UID 1000 by default and don't support PUID/PGID environment variables +2. **No runtime chown operations**: Eliminates the recursive chown that can cause race conditions +3. **Simplified permissions**: All directories are created with open permissions (777) during build +4. **USER directive**: The container runs as non-root from the start + +## Usage + +### Basic Usage + +```bash +docker run -d \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /opt/factorio:/factorio \ + --name factorio \ + factoriotools/factorio:stable-rootless +``` + +### With Rootless Docker + +If you're running rootless Docker, the container will work out of the box: + +```bash +# As your regular user (not root) +docker run -d \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v ~/factorio:/factorio \ + --name factorio \ + factoriotools/factorio:stable-rootless +``` + +### With Regular Docker + +If you're running regular Docker but want to avoid permission issues: + +```bash +# Pre-create the volume directory with your user's permissions +mkdir -p /opt/factorio +sudo chown -R $(id -u):$(id -g) /opt/factorio + +# Run the container +docker run -d \ + --user $(id -u):$(id -g) \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /opt/factorio:/factorio \ + --name factorio \ + factoriotools/factorio:stable-rootless +``` + +## Environment Variables + +All the same environment variables from the regular image are supported, except: +- `PUID` - Not supported (container runs as UID 1000) +- `PGID` - Not supported (container runs as GID 1000) + +## Migrating from Regular Images + +If you're switching from a regular image to a rootless image: + +1. Stop your existing container +2. Fix permissions on your volume (one time only): + ```bash + sudo chown -R 1000:1000 /opt/factorio + # Or if you want to match your user: + sudo chown -R $(id -u):$(id -g) /opt/factorio + ``` +3. Start the new rootless container + +## Troubleshooting + +### Permission Denied Errors + +If you get permission errors, ensure your volume directory is writable by UID 1000 or your user: + +```bash +# Check current ownership +ls -la /opt/factorio + +# Fix ownership for UID 1000 (default) +sudo chown -R 1000:1000 /opt/factorio + +# Or fix for your current user +sudo chown -R $(id -u):$(id -g) /opt/factorio +``` + +### Running as a Different User + +If you need to run as a different UID, override it at runtime: + +```bash +docker run -d \ + --user 2000:2000 \ + -v /opt/factorio:/factorio \ + factoriotools/factorio:stable-rootless +``` + +## Building Rootless Images + +To build rootless images locally: + +```bash +# Build for current architecture +python3 build-rootless.py + +# Build and push multi-arch images +python3 build-rootless.py --multiarch --push-tags +``` + +## Why Use Rootless Images? + +1. **Avoid permission issues**: No more files with unexpected ownership +2. **Better security**: Runs as non-root by default +3. **Simpler**: No complex permission logic at startup +4. **Faster startup**: No recursive chown operations +5. **Rootless Docker compatible**: Works seamlessly with rootless Docker installations \ No newline at end of file diff --git a/README.md b/README.md index 19557b6..40ba463 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,37 @@ stream { If your factorio host uses multiple IP addresses (very common with IPv6), you might additionally need to bind Factorio to a single IP (otherwise the UDP proxy might get confused with IP mismatches). To do that pass the `BIND` envvar to the container: `docker run --network=host -e BIND=2a02:1234::5678 ...` +## Rootless Docker Support + +If you're experiencing permission issues or want better security, consider using the rootless images. These images are designed to work seamlessly with rootless Docker installations and avoid common permission problems. + +### Rootless Image Tags + +Each regular tag has a corresponding rootless version with the `-rootless` suffix: +- `latest-rootless` +- `stable-rootless` +- `2.0.55-rootless` + +### Quick Start with Rootless + +```shell +docker run -d \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v ~/factorio:/factorio \ + --name factorio \ + --restart=unless-stopped \ + factoriotools/factorio:stable-rootless +``` + +Key differences: +- No `chown` command needed +- No PUID/PGID environment variables +- Runs as UID 1000 by default +- No permission issues with volumes + +For more information, see the [Rootless Docker documentation](README-ROOTLESS.md). + ## Troubleshooting ### My server is listed in the server browser, but nobody can connect diff --git a/build-rootless.py b/build-rootless.py new file mode 100755 index 0000000..da98761 --- /dev/null +++ b/build-rootless.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +import os +import json +import subprocess +import shutil +import sys +import tempfile + + +PLATFORMS = [ + "linux/arm64", + "linux/amd64", +] + + +def create_builder(build_dir, builder_name, platform): + check_exists_command = ["docker", "buildx", "inspect", builder_name] + if subprocess.run(check_exists_command, stderr=subprocess.DEVNULL).returncode != 0: + create_command = ["docker", "buildx", "create", "--platform", platform, "--name", builder_name] + try: + subprocess.run(create_command, cwd=build_dir, check=True) + except subprocess.CalledProcessError: + print("Creating builder failed") + exit(1) + + +def build_and_push_multiarch(build_dir, build_args, push): + builder_name = "factoriotools-rootless-multiarch" + platform=",".join(PLATFORMS) + create_builder(build_dir, builder_name, platform) + build_command = ["docker", "buildx", "build", "--platform", platform, "--builder", builder_name] + build_args + if push: + build_command.append("--push") + try: + subprocess.run(build_command, cwd=build_dir, check=True) + except subprocess.CalledProcessError: + print("Build and push of rootless image failed") + exit(1) + + +def build_singlearch(build_dir, build_args): + build_command = ["docker", "build"] + build_args + try: + subprocess.run(build_command, cwd=build_dir, check=True) + except subprocess.CalledProcessError: + print("Build of rootless image failed") + exit(1) + + +def push_singlearch(tags): + for tag in tags: + try: + subprocess.run(["docker", "push", f"factoriotools/factorio:{tag}"], + check=True) + except subprocess.CalledProcessError: + print("Docker push failed") + exit(1) + + +def build_and_push(sha256, version, tags, push, multiarch): + build_dir = tempfile.mktemp() + shutil.copytree("docker", build_dir) + # Use the rootless Dockerfile + build_args = ["-f", "Dockerfile.rootless", "--build-arg", f"VERSION={version}", "--build-arg", f"SHA256={sha256}", "."] + for tag in tags: + build_args.extend(["-t", f"factoriotools/factorio:{tag}"]) + if multiarch: + build_and_push_multiarch(build_dir, build_args, push) + else: + build_singlearch(build_dir, build_args) + if push: + push_singlearch(tags) + + +def login(): + try: + username = os.environ["DOCKER_USERNAME"] + password = os.environ["DOCKER_PASSWORD"] + subprocess.run(["docker", "login", "-u", username, "-p", password], check=True) + except KeyError: + print("Username and password need to be given") + exit(1) + except subprocess.CalledProcessError: + print("Docker login failed") + exit(1) + + +def generate_rootless_tags(original_tags): + """Generate rootless-specific tags from original tags""" + rootless_tags = [] + for tag in original_tags: + # Add -rootless suffix to each tag + rootless_tags.append(f"{tag}-rootless") + return rootless_tags + + +def main(push_tags=False, multiarch=False): + with open(os.path.join(os.path.dirname(__file__), "buildinfo.json")) as file_handle: + builddata = json.load(file_handle) + + if push_tags: + login() + + # Build only the latest stable and experimental versions for rootless + versions_to_build = [] + + # Find latest stable and experimental versions + for version, buildinfo in builddata.items(): + if "stable" in buildinfo["tags"] or "latest" in buildinfo["tags"]: + versions_to_build.append((version, buildinfo)) + + for version, buildinfo in versions_to_build: + sha256 = buildinfo["sha256"] + original_tags = buildinfo["tags"] + rootless_tags = generate_rootless_tags(original_tags) + build_and_push(sha256, version, rootless_tags, push_tags, multiarch) + + +if __name__ == '__main__': + push_tags = False + multiarch = False + for arg in sys.argv[1:]: + if arg == "--push-tags": + push_tags = True + elif arg == "--multiarch": + multiarch = True + main(push_tags, multiarch) \ No newline at end of file diff --git a/docker/Dockerfile.rootless b/docker/Dockerfile.rootless new file mode 100644 index 0000000..b5a10dd --- /dev/null +++ b/docker/Dockerfile.rootless @@ -0,0 +1,93 @@ +# build rcon client +FROM debian:stable-slim AS rcon-builder +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install build-essential + +WORKDIR /src +COPY rcon/ /src +RUN make + +# build factorio image +FROM debian:stable-slim +LABEL maintainer="https://github.com/factoriotools/factorio-docker" + +ARG BOX64_VERSION=v0.2.4 + +# optionally utilize a built-in map-gen-preset (see data/base/prototypes/map-gen-presets +ARG PRESET + +# number of retries that curl will use when pulling the headless server tarball +ARG CURL_RETRIES=8 + +ENV PORT=34197 \ + RCON_PORT=27015 \ + SAVES=/factorio/saves \ + PRESET="$PRESET" \ + CONFIG=/factorio/config \ + MODS=/factorio/mods \ + SCENARIOS=/factorio/scenarios \ + SCRIPTOUTPUT=/factorio/script-output \ + DLC_SPACE_AGE="true" + +SHELL ["/bin/bash", "-eo", "pipefail", "-c"] + +RUN apt-get -q update \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install ca-certificates curl jq pwgen xz-utils procps gettext-base --no-install-recommends \ + && if [[ "$(uname -m)" == "aarch64" ]]; then \ + echo "installing ARM compatability layer" \ + && DEBIAN_FRONTEND=noninteractive apt-get -qy install unzip --no-install-recommends \ + && curl -LO https://github.com/ptitSeb/box64/releases/download/${BOX64_VERSION}/box64-GENERIC_ARM-RelWithDebInfo.zip \ + && unzip box64-GENERIC_ARM-RelWithDebInfo.zip -d /bin \ + && rm -f box64-GENERIC_ARM-RelWithDebInfo.zip \ + && chmod +x /bin/box64; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +# version checksum of the archive to download +ARG VERSION +ARG SHA256 + +LABEL factorio.version=${VERSION} + +ENV VERSION=${VERSION} \ + SHA256=${SHA256} + +RUN set -ox pipefail \ + && if [[ "${VERSION}" == "" ]]; then \ + echo "build-arg VERSION is required" \ + && exit 1; \ + fi \ + && if [[ "${SHA256}" == "" ]]; then \ + echo "build-arg SHA256 is required" \ + && exit 1; \ + fi \ + && archive="/tmp/factorio_headless_x64_$VERSION.tar.xz" \ + && mkdir -p /opt /factorio \ + && curl -sSL "https://www.factorio.com/get-download/$VERSION/headless/linux64" -o "$archive" --retry $CURL_RETRIES \ + && echo "$SHA256 $archive" | sha256sum -c \ + || (sha256sum "$archive" && file "$archive" && exit 1) \ + && tar xf "$archive" --directory /opt \ + && chmod ugo=rwx /opt/factorio \ + && rm "$archive" \ + && ln -s "$SCENARIOS" /opt/factorio/scenarios \ + && ln -s "$SAVES" /opt/factorio/saves \ + && mkdir -p /opt/factorio/config/ + +COPY files/*.sh / +COPY files/docker-entrypoint-rootless.sh /docker-entrypoint.sh +COPY files/config.ini /opt/factorio/config/config.ini +COPY --from=rcon-builder /src/rcon /bin/rcon + +# Make all scripts executable +RUN chmod +x /*.sh + +# Set proper permissions for the factorio directory +RUN chmod -R 777 /opt/factorio /factorio + +VOLUME /factorio +EXPOSE $PORT/udp $RCON_PORT/tcp + +# Run as non-root user (UID 1000 is common for the first user in rootless containers) +USER 1000:1000 + +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker/files/docker-entrypoint-rootless.sh b/docker/files/docker-entrypoint-rootless.sh new file mode 100755 index 0000000..081e108 --- /dev/null +++ b/docker/files/docker-entrypoint-rootless.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -eoux pipefail +INSTALLED_DIRECTORY=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") +FACTORIO_VOL=/factorio +LOAD_LATEST_SAVE="${LOAD_LATEST_SAVE:-true}" +GENERATE_NEW_SAVE="${GENERATE_NEW_SAVE:-false}" +PRESET="${PRESET:-""}" +SAVE_NAME="${SAVE_NAME:-""}" +BIND="${BIND:-""}" +CONSOLE_LOG_LOCATION="${CONSOLE_LOG_LOCATION:-""}" + +# Create directories if they don't exist +# In rootless mode, these should be writable by the container user +mkdir -p "$FACTORIO_VOL" +mkdir -p "$SAVES" +mkdir -p "$CONFIG" +mkdir -p "$MODS" +mkdir -p "$SCENARIOS" +mkdir -p "$SCRIPTOUTPUT" + +# Generate RCON password if needed +if [[ ! -f $CONFIG/rconpw ]]; then + pwgen 15 1 >"$CONFIG/rconpw" +fi + +# Copy default configs if they don't exist +if [[ ! -f $CONFIG/server-settings.json ]]; then + cp /opt/factorio/data/server-settings.example.json "$CONFIG/server-settings.json" +fi + +if [[ ! -f $CONFIG/map-gen-settings.json ]]; then + cp /opt/factorio/data/map-gen-settings.example.json "$CONFIG/map-gen-settings.json" +fi + +if [[ ! -f $CONFIG/map-settings.json ]]; then + cp /opt/factorio/data/map-settings.example.json "$CONFIG/map-settings.json" +fi + +# Clean up incomplete saves +NRTMPSAVES=$( find -L "$SAVES" -iname \*.tmp.zip -mindepth 1 | wc -l ) +if [[ $NRTMPSAVES -gt 0 ]]; then + rm -f "$SAVES"/*.tmp.zip +fi + +# Update mods if requested +if [[ ${UPDATE_MODS_ON_START:-} == "true" ]]; then + ${INSTALLED_DIRECTORY}/docker-update-mods.sh +fi + +# Handle DLC +${INSTALLED_DIRECTORY}/docker-dlc.sh + +# In rootless mode, we don't need to handle user switching or chown +# The container runs as the specified user from the start +EXEC="" +if [[ -f /bin/box64 ]]; then + # Use emulator for ARM hosts + EXEC="/bin/box64" +fi + +# Update config path +sed -i '/write-data=/c\write-data=\/factorio/' /opt/factorio/config/config.ini + +# Generate new save if needed +NRSAVES=$(find -L "$SAVES" -iname \*.zip -mindepth 1 | wc -l) +if [[ $GENERATE_NEW_SAVE != true && $NRSAVES == 0 ]]; then + GENERATE_NEW_SAVE=true + SAVE_NAME=_autosave1 +fi + +if [[ $GENERATE_NEW_SAVE == true ]]; then + if [[ -z "$SAVE_NAME" ]]; then + echo "If \$GENERATE_NEW_SAVE is true, you must specify \$SAVE_NAME" + exit 1 + fi + if [[ -f "$SAVES/$SAVE_NAME.zip" ]]; then + echo "Map $SAVES/$SAVE_NAME.zip already exists, skipping map generation" + else + if [[ ! -z "$PRESET" ]]; then + $EXEC /opt/factorio/bin/x64/factorio \ + --create "$SAVES/$SAVE_NAME.zip" \ + --preset "$PRESET" \ + --map-gen-settings "$CONFIG/map-gen-settings.json" \ + --map-settings "$CONFIG/map-settings.json" + else + $EXEC /opt/factorio/bin/x64/factorio \ + --create "$SAVES/$SAVE_NAME.zip" \ + --map-gen-settings "$CONFIG/map-gen-settings.json" \ + --map-settings "$CONFIG/map-settings.json" + fi + fi +fi + +# Build command flags +FLAGS=(\ + --port "$PORT" \ + --server-settings "$CONFIG/server-settings.json" \ + --server-banlist "$CONFIG/server-banlist.json" \ + --rcon-port "$RCON_PORT" \ + --server-whitelist "$CONFIG/server-whitelist.json" \ + --use-server-whitelist \ + --server-adminlist "$CONFIG/server-adminlist.json" \ + --rcon-password "$(cat "$CONFIG/rconpw")" \ + --server-id /factorio/config/server-id.json \ + --mod-directory "$MODS" \ +) + +if [ -n "$CONSOLE_LOG_LOCATION" ]; then + FLAGS+=( --console-log "$CONSOLE_LOG_LOCATION" ) +fi + +if [ -n "$BIND" ]; then + FLAGS+=( --bind "$BIND" ) +fi + +if [[ $LOAD_LATEST_SAVE == true ]]; then + FLAGS+=( --start-server-load-latest ) +else + FLAGS+=( --start-server "$SAVE_NAME" ) +fi + +# Execute factorio +# In rootless mode, we run directly without user switching +exec $EXEC /opt/factorio/bin/x64/factorio "${FLAGS[@]}" "$@" \ No newline at end of file