#!/usr/bin/env bash # Factorio headless server entrypoint. # # Responsibilities, in order: # 1. normalise ownership of the data volume and drop root (if started as root) # 2. lay out the volume on first run # 3. render server-settings.json (and friends) from environment variables # 4. update mods against the portal, if UPDATE_MODS=true # 5. create a save if none exists # 6. exec the server so it receives signals directly set -euo pipefail FACTORIO_DIR="${FACTORIO_DIR:-/factorio}" FACTORIO_HOME="${FACTORIO_HOME:-/opt/factorio}" BIN="${FACTORIO_HOME}/factorio" log() { printf '[entrypoint] %s\n' "$*"; } warn() { printf '[entrypoint] WARNING: %s\n' "$*" >&2; } die() { printf '[entrypoint] ERROR: %s\n' "$*" >&2; exit 1; } # -------------------------------------------------------------------------- # 1. privilege handling # -------------------------------------------------------------------------- # Running as root is supported for convenience (it lets us fix up ownership of # a freshly created bind mount) but we never *stay* root. If the image is # started with --user, we skip all of this and run as whoever we were given. if [ "$(id -u)" = "0" ]; then PUID="${PUID:-845}" PGID="${PGID:-845}" # The image creates this user, but the entrypoint is also usable against a # hand-rolled base, so create it rather than failing confusingly if missing. if ! getent group factorio >/dev/null 2>&1; then groupadd -o -g "${PGID}" factorio fi if ! id -u factorio >/dev/null 2>&1; then useradd -o -u "${PUID}" -g "${PGID}" -d "${FACTORIO_DIR}" -s /usr/sbin/nologin factorio fi if [ "$(id -u factorio)" != "${PUID}" ] || [ "$(id -g factorio)" != "${PGID}" ]; then log "remapping factorio user to ${PUID}:${PGID}" groupmod -o -g "${PGID}" factorio usermod -o -u "${PUID}" -g "${PGID}" factorio fi # Only chown when it is actually wrong — recursively chowning a large saves # directory on every boot is slow, especially on an SD card. if [ "$(stat -c '%u:%g' "${FACTORIO_DIR}")" != "${PUID}:${PGID}" ]; then log "fixing ownership of ${FACTORIO_DIR} (this may take a moment on first run)" chown -R "${PUID}:${PGID}" "${FACTORIO_DIR}" fi log "dropping privileges to ${PUID}:${PGID}" exec gosu "${PUID}:${PGID}" "$0" "$@" fi # -------------------------------------------------------------------------- # 2. volume layout # -------------------------------------------------------------------------- mkdir -p \ "${FACTORIO_DIR}/saves" \ "${FACTORIO_DIR}/mods" \ "${FACTORIO_DIR}/config" \ "${FACTORIO_DIR}/scenarios" \ "${FACTORIO_DIR}/script-output" SETTINGS_FILE="${FACTORIO_DIR}/config/server-settings.json" MAP_GEN_FILE="${FACTORIO_DIR}/config/map-gen-settings.json" MAP_SETTINGS_FILE="${FACTORIO_DIR}/config/map-settings.json" ADMINLIST_FILE="${FACTORIO_DIR}/config/server-adminlist.json" WHITELIST_FILE="${FACTORIO_DIR}/config/server-whitelist.json" BANLIST_FILE="${FACTORIO_DIR}/config/server-banlist.json" CONFIG_INI="${FACTORIO_DIR}/config/config.ini" # Seed the map-generation files from upstream examples so they are easy to edit, # but never overwrite what the operator has already put there. [ -f "${MAP_GEN_FILE}" ] || cp "${FACTORIO_HOME}/data/map-gen-settings.example.json" "${MAP_GEN_FILE}" [ -f "${MAP_SETTINGS_FILE}" ] || cp "${FACTORIO_HOME}/data/map-settings.example.json" "${MAP_SETTINGS_FILE}" # Without this, Factorio's write-data path defaults to alongside its own binary # (${FACTORIO_HOME}), not the volume — so mods/saves/script-output would resolve # outside ${FACTORIO_DIR}, and --start-server-load-latest (which takes no path # argument) would find nothing there since the Dockerfile deletes that directory. [ -f "${CONFIG_INI}" ] || cat > "${CONFIG_INI}" < 0))' } if [ "$(jqbool "${UPDATE_CONFIG:-true}")" = "true" ] || [ ! -f "${SETTINGS_FILE}" ]; then log "rendering ${SETTINGS_FILE} from environment" # RCON without a password would expose an unauthenticated admin console, so # generate a strong one rather than silently starting without protection. if [ -z "${RCON_PASSWORD:-}" ]; then RCON_PASSWORD="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 || true)" warn "RCON_PASSWORD was not set; generated a random one for this container." warn "Set RCON_PASSWORD explicitly if you intend to use RCON." fi export RCON_PASSWORD jq -n \ --arg name "${NAME:-Factorio}" \ --arg description "${DESCRIPTION:-A Factorio server running in Docker}" \ --argjson tags "$(jqlist "${TAGS:-}")" \ --argjson max_players "${MAX_PLAYERS:-0}" \ --argjson public "$(jqbool "${VISIBILITY_PUBLIC:-false}")" \ --argjson lan "$(jqbool "${VISIBILITY_LAN:-true}")" \ --arg username "${FACTORIO_USERNAME:-${USERNAME:-}}" \ --arg token "${FACTORIO_TOKEN:-${TOKEN:-}}" \ --arg game_password "${GAME_PASSWORD:-}" \ --argjson verify "$(jqbool "${REQUIRE_USER_VERIFICATION:-true}")" \ --argjson upload_max "${MAX_UPLOAD_IN_KILOBYTES_PER_SECOND:-0}" \ --argjson upload_slots "${MAX_UPLOAD_SLOTS:-5}" \ --argjson min_latency "${MINIMUM_LATENCY_IN_TICKS:-0}" \ --argjson pause_empty "$(jqbool "${AUTO_PAUSE:-true}")" \ --argjson pause_connect "$(jqbool "${AUTO_PAUSE_WHEN_PLAYERS_CONNECT:-false}")" \ --argjson admins_pause "$(jqbool "${ONLY_ADMINS_CAN_PAUSE_THE_GAME:-true}")" \ --argjson autosave_only "$(jqbool "${AUTOSAVE_ONLY_ON_SERVER:-true}")" \ --argjson nonblocking "$(jqbool "${NON_BLOCKING_SAVING:-false}")" \ --argjson autosave_int "${AUTOSAVE_INTERVAL:-10}" \ --argjson autosave_slot "${AUTOSAVE_SLOTS:-5}" \ --argjson afk_kick "${AFK_AUTOKICK_INTERVAL:-0}" \ --argjson admins "$(jqlist "${ADMINS:-}")" \ '{ name: $name, description: $description, tags: $tags, max_players: $max_players, visibility: { public: $public, lan: $lan }, username: $username, token: $token, game_password: $game_password, require_user_verification: $verify, max_upload_in_kilobytes_per_second: $upload_max, max_upload_slots: $upload_slots, minimum_latency_in_ticks: $min_latency, ignore_player_limit_for_returning_players: false, allow_commands: "admins-only", autosave_interval: $autosave_int, autosave_slots: $autosave_slot, afk_autokick_interval: $afk_kick, auto_pause: $pause_empty, auto_pause_when_players_connect: $pause_connect, only_admins_can_pause_the_game: $admins_pause, autosave_only_on_server: $autosave_only, non_blocking_saving: $nonblocking, admins: $admins }' > "${SETTINGS_FILE}.tmp" mv "${SETTINGS_FILE}.tmp" "${SETTINGS_FILE}" chmod 600 "${SETTINGS_FILE}" # it can hold a game password and an auth token else log "keeping existing ${SETTINGS_FILE} (UPDATE_CONFIG=false)" fi # Optional player lists, rendered only when the corresponding variable is set so # a hand-maintained file on the volume is never clobbered. if [ -n "${ADMINS:-}" ]; then jqlist "${ADMINS}" > "${ADMINLIST_FILE}" fi if [ -n "${WHITELIST:-}" ]; then jqlist "${WHITELIST}" > "${WHITELIST_FILE}" fi if [ ! -f "${BANLIST_FILE}" ]; then echo '[]' > "${BANLIST_FILE}" fi # -------------------------------------------------------------------------- # 4. mods # -------------------------------------------------------------------------- if [ "$(jqbool "${UPDATE_MODS:-false}")" = "true" ]; then log "UPDATE_MODS=true, checking mods against the portal" /usr/local/bin/update-mods.sh fi # -------------------------------------------------------------------------- # 5. save file # -------------------------------------------------------------------------- SAVE_NAME="${SAVE_NAME:-default}" SAVE_PATH="${FACTORIO_DIR}/saves/${SAVE_NAME}.zip" shopt -s nullglob existing_saves=("${FACTORIO_DIR}"/saves/*.zip) shopt -u nullglob if [ ${#existing_saves[@]} -eq 0 ]; then if [ "$(jqbool "${GENERATE_NEW_SAVE:-true}")" = "true" ]; then log "no save found — generating a new map at ${SAVE_PATH}" "${BIN}" \ --create "${SAVE_PATH}" \ --map-gen-settings "${MAP_GEN_FILE}" \ --map-settings "${MAP_SETTINGS_FILE}" \ --config "${CONFIG_INI}" else die "no save found in ${FACTORIO_DIR}/saves and GENERATE_NEW_SAVE=false" fi else log "found ${#existing_saves[@]} save(s) in ${FACTORIO_DIR}/saves" fi # -------------------------------------------------------------------------- # 6. launch # -------------------------------------------------------------------------- args=( --port "${PORT:-34197}" --server-settings "${SETTINGS_FILE}" --server-banlist "${BANLIST_FILE}" --server-id "${FACTORIO_DIR}/config/server-id.json" --config "${CONFIG_INI}" ) # Prefer the newest save unless the operator pinned one by name. if [ "$(jqbool "${LOAD_LATEST_SAVE:-true}")" = "true" ]; then args+=(--start-server-load-latest) else args+=(--start-server "${SAVE_PATH}") fi if [ -f "${ADMINLIST_FILE}" ]; then args+=(--server-adminlist "${ADMINLIST_FILE}") fi if [ -f "${WHITELIST_FILE}" ]; then args+=(--server-whitelist "${WHITELIST_FILE}" --use-server-whitelist) fi if [ -n "${BIND:-}" ]; then args+=(--bind "${BIND}") fi if [ -n "${CONSOLE_LOG:-}" ]; then args+=(--console-log "${CONSOLE_LOG}") fi if [ -n "${RCON_PASSWORD:-}" ]; then args+=(--rcon-port "${RCON_PORT:-27015}" --rcon-password "${RCON_PASSWORD}") fi # Anything the operator wants to pass straight through. if [ -n "${EXTRA_ARGS:-}" ]; then # shellcheck disable=SC2206 # deliberate word-splitting of an argument string args+=(${EXTRA_ARGS}) fi log "starting: factorio ${args[*]//${RCON_PASSWORD:-__none__}/******}" exec "${BIN}" "${args[@]}"