From a68d6157befdfe7f5d8ee0f7cf258704e5e68973 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 10:37:03 -0400 Subject: [PATCH] Add verifiable atomic server updates --- .dockerignore | 1 - Dockerfile | 4 + README.md | 63 ++++++--- scripts/install-native-release.sh | 206 ++++++++++++++++++++++++++++++ scripts/package-native.sh | 15 ++- scripts/rollback-native.sh | 70 ++++++++++ scripts/stage-build.sh | 32 ++++- systemd/netfishing-server.service | 4 +- 8 files changed, 371 insertions(+), 24 deletions(-) create mode 100755 scripts/install-native-release.sh create mode 100755 scripts/rollback-native.sh diff --git a/.dockerignore b/.dockerignore index 6947859..beaa206 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ .git .gitignore packages -dist/SHA256SUMS diff --git a/Dockerfile b/Dockerfile index 35da49d..96a45be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,16 @@ RUN groupadd --system --gid 10001 netfishing \ COPY --chown=netfishing:netfishing \ dist/NETfishingServer.x86_64 \ dist/NETfishingServer.pck \ + dist/BUILD_INFO \ + dist/SHA256SUMS \ /opt/netfishing-server/ COPY --chown=netfishing:netfishing \ config/server.cfg.example /etc/netfishing-server.cfg RUN chmod 0555 /opt/netfishing-server/NETfishingServer.x86_64 \ && chmod 0444 /opt/netfishing-server/NETfishingServer.pck \ + /opt/netfishing-server/BUILD_INFO \ + /opt/netfishing-server/SHA256SUMS \ /etc/netfishing-server.cfg USER 10001:10001 diff --git a/README.md b/README.md index 6b601cc..4366614 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,20 @@ repository so clients and servers use the same protocol implementation. ## Stage a build Export the `Linux Dedicated Server` preset from the game project, then stage -its executable and PCK: +its executable and PCK with the exact game version and source commit: ```sh -./scripts/stage-build.sh ../netfishing/builds/vX.Y.Z-alpha/server-linux-x86_64 +game_commit="$(git -C ../netfishing rev-parse HEAD)" +./scripts/stage-build.sh \ + ../netfishing/builds/vX.Y.Z-alpha/server-linux-x86_64 \ + X.Y.Z-alpha \ + "${game_commit}" ``` The staged binaries under `dist/` are release inputs and are intentionally not -tracked by Git. +tracked by Git. `dist/BUILD_INFO` records the version, full game commit, export +preset, platform, and binary hashes. Packaging refuses a version mismatch or +failed internal checksum. Create a deterministic native archive after staging: @@ -22,17 +28,25 @@ Create a deterministic native archive after staging: ./scripts/package-native.sh X.Y.Z-alpha ``` -## Run natively +## Install or update natively -Copy `dist/NETfishingServer.x86_64` and `dist/NETfishingServer.pck` into the -same directory, make the executable runnable, and start it with a persistent -data directory: +The native archive contains its `BUILD_INFO` and internal checksums. Transfer +both the archive and its adjacent `.sha256` file, then install it: ```sh -./NETfishingServer.x86_64 \ - -- \ - --config=/etc/netfishing-server.cfg \ - --data-dir=/var/lib/netfishing-server +sudo ./scripts/install-native-release.sh \ + packages/netfishing-dedicated-server-X.Y.Z-alpha-linux-x86_64.tar.gz \ + packages/netfishing-dedicated-server-X.Y.Z-alpha-linux-x86_64.tar.gz.sha256 +``` + +Releases are immutable directories under `/opt/netfishing-server/releases`. +The installer verifies the outer archive hash, internal hashes, manifest, and +platform before atomically changing `/opt/netfishing-server/current`. When the +sample systemd unit is already active, a failed start automatically restores +the prior release. Manually return to the recorded previous release with: + +```sh +sudo ./scripts/rollback-native.sh ``` Copy `config/server.cfg.example` to `/etc/netfishing-server.cfg` and adjust the @@ -53,16 +67,31 @@ standalone `--` shown above; it separates Godot engine flags from server flags. Public listing requires the discovery URL and a publicly reachable ENet UDP port. Discovery makes a server findable but does not relay gameplay traffic. -For a systemd installation, create a dedicated `netfishing` system user, place -the two staged build files in `/opt/netfishing-server`, install the sample -configuration and environment files under `/etc`, and copy -`systemd/netfishing-server.service` to the system unit directory. The service -expects `/var/lib/netfishing-server` to be writable by that user. +For a systemd installation, create a dedicated `netfishing` system user, +install the sample configuration and environment files under `/etc`, and copy +`systemd/netfishing-server.service` to the system unit directory. The unit runs +the atomic `current` release link and expects `/var/lib/netfishing-server` to be +writable by that user. When migrating an older direct-path installation, stop +the service, run the installer once, replace/reload the unit, and then start it. +After that one-time migration, future upgrades can be applied while the unit +is running. + +For a direct foreground run from staged build inputs: + +```sh +./dist/NETfishingServer.x86_64 \ + -- \ + --config=/etc/netfishing-server.cfg \ + --data-dir=/var/lib/netfishing-server +``` ## Run with Docker Compose ```sh -./scripts/stage-build.sh ../netfishing/builds/vX.Y.Z-alpha/server-linux-x86_64 +./scripts/stage-build.sh \ + ../netfishing/builds/vX.Y.Z-alpha/server-linux-x86_64 \ + X.Y.Z-alpha \ + "$(git -C ../netfishing rev-parse HEAD)" docker compose build docker compose up -d ``` diff --git a/scripts/install-native-release.sh b/scripts/install-native-release.sh new file mode 100755 index 0000000..06fef49 --- /dev/null +++ b/scripts/install-native-release.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 ARCHIVE SHA256_OR_SHA256_FILE" >&2 + exit 2 +fi +if [[ ${EUID} -ne 0 ]]; then + echo "Run this installer as root." >&2 + exit 2 +fi + +archive_path="$(readlink -f -- "$1")" +checksum_input="$2" +install_root="${NETFISHING_INSTALL_ROOT:-/opt/netfishing-server}" +service_name="${NETFISHING_SERVICE_NAME:-netfishing-server.service}" + +if [[ ! -f "${archive_path}" ]]; then + echo "Archive not found: $1" >&2 + exit 1 +fi +if [[ "${install_root}" != /* || "${install_root}" == "/" ]]; then + echo "NETFISHING_INSTALL_ROOT must be a specific absolute directory." >&2 + exit 2 +fi + +if [[ "${checksum_input}" =~ ^[0-9A-Fa-f]{64}$ ]]; then + expected_sha256="${checksum_input,,}" +elif [[ -f "${checksum_input}" ]]; then + expected_sha256="$(awk 'NF {print tolower($1); exit}' "${checksum_input}")" +else + echo "Second argument must be a SHA-256 hash or checksum file." >&2 + exit 2 +fi +if [[ ! "${expected_sha256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Invalid expected SHA-256 value." >&2 + exit 2 +fi +actual_sha256="$(sha256sum "${archive_path}" | awk '{print $1}')" +if [[ "${actual_sha256}" != "${expected_sha256}" ]]; then + echo "Archive SHA-256 mismatch." >&2 + exit 1 +fi + +work_dir="$(mktemp -d /tmp/netfishing-server-install.XXXXXX)" +release_stage="" +cleanup() { + if [[ -n "${release_stage}" && -d "${release_stage}" ]]; then + rm -rf -- "${release_stage}" + fi + rm -rf -- "${work_dir}" +} +trap cleanup EXIT + +top_directory="" +while IFS= read -r member; do + [[ -n "${member}" ]] || continue + if [[ "${member}" == /* ]]; then + echo "Archive contains an absolute path." >&2 + exit 1 + fi + IFS='/' read -r -a path_parts <<< "${member}" + for part in "${path_parts[@]}"; do + if [[ "${part}" == ".." || "${part}" == "." ]]; then + echo "Archive contains an unsafe path." >&2 + exit 1 + fi + done + member_top="${member%%/*}" + if [[ -z "${top_directory}" ]]; then + top_directory="${member_top}" + elif [[ "${member_top}" != "${top_directory}" ]]; then + echo "Archive must contain exactly one top-level directory." >&2 + exit 1 + fi +done < <(tar -tzf "${archive_path}") + +if [[ -z "${top_directory}" ]]; then + echo "Archive is empty." >&2 + exit 1 +fi +tar --no-same-owner --no-same-permissions -xzf "${archive_path}" -C "${work_dir}" +payload_dir="${work_dir}/${top_directory}" +if [[ -n "$(find "${payload_dir}" -type l -print -quit)" ]]; then + echo "Archive payload may not contain symbolic links." >&2 + exit 1 +fi +for filename in NETfishingServer.x86_64 NETfishingServer.pck BUILD_INFO SHA256SUMS; do + if [[ ! -f "${payload_dir}/${filename}" ]]; then + echo "Archive is missing ${filename}." >&2 + exit 1 + fi +done +(cd -- "${payload_dir}" && sha256sum -c SHA256SUMS) + +manifest_value() { + local key="$1" + awk -F= -v key="${key}" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' \ + "${payload_dir}/BUILD_INFO" +} +manifest_version="$(manifest_value manifest_version)" +game_version="$(manifest_value game_version)" +game_commit="$(manifest_value game_commit)" +platform="$(manifest_value platform)" +if [[ "${manifest_version}" != "1" ]]; then + echo "Unsupported BUILD_INFO format." >&2 + exit 1 +fi +if [[ ! "${game_version}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "Invalid game version in BUILD_INFO." >&2 + exit 1 +fi +if [[ ! "${game_commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid game commit in BUILD_INFO." >&2 + exit 1 +fi +if [[ "${platform}" != "linux-x86_64" ]]; then + echo "This installer only accepts linux-x86_64 packages." >&2 + exit 1 +fi + +releases_dir="${install_root}/releases" +release_dir="${releases_dir}/${game_version}-${game_commit:0:12}" +install -d -m 0755 "${install_root}" "${releases_dir}" +if [[ -e "${release_dir}" ]]; then + if [[ ! -f "${release_dir}/BUILD_INFO" ]] \ + || ! cmp -s "${payload_dir}/BUILD_INFO" "${release_dir}/BUILD_INFO"; then + echo "A different release already occupies ${release_dir}." >&2 + exit 1 + fi + (cd -- "${release_dir}" && sha256sum -c SHA256SUMS) +else + release_stage="$(mktemp -d "${releases_dir}/.incoming.XXXXXX")" + install -m 0555 "${payload_dir}/NETfishingServer.x86_64" "${release_stage}/" + install -m 0444 "${payload_dir}/NETfishingServer.pck" "${release_stage}/" + install -m 0444 "${payload_dir}/BUILD_INFO" "${release_stage}/" + install -m 0444 "${payload_dir}/SHA256SUMS" "${release_stage}/" + if [[ -f "${payload_dir}/README.md" ]]; then + install -m 0444 "${payload_dir}/README.md" "${release_stage}/" + fi + chmod 0755 "${release_stage}" + mv -- "${release_stage}" "${release_dir}" + release_stage="" +fi + +replace_symlink() { + local target="$1" + local link_path="$2" + local temporary_link="${install_root}/.$(basename -- "${link_path}").new.$$" + ln -s -- "${target}" "${temporary_link}" + mv -Tf -- "${temporary_link}" "${link_path}" +} + +old_current="" +if [[ -L "${install_root}/current" ]]; then + old_current="$(readlink -f -- "${install_root}/current")" +fi + +service_exists=false +service_active=false +if command -v systemctl >/dev/null && systemctl cat "${service_name}" >/dev/null 2>&1; then + service_exists=true + if systemctl is-active --quiet "${service_name}"; then + service_active=true + unit_text="$(systemctl cat "${service_name}")" + if [[ "${unit_text}" != *"${install_root}/current/NETfishingServer.x86_64"* ]]; then + echo "Active ${service_name} does not use the current-release symlink." >&2 + echo "Migrate the unit while stopped before using this updater." >&2 + exit 1 + fi + fi +fi + +if ${service_active}; then + systemctl stop "${service_name}" +fi +replace_symlink "${release_dir}" "${install_root}/current" + +if ${service_active}; then + if ! systemctl start "${service_name}"; then + start_succeeded=false + else + sleep 1 + start_succeeded=true + systemctl is-active --quiet "${service_name}" || start_succeeded=false + fi + if ! ${start_succeeded}; then + echo "New release failed to start; restoring the previous release." >&2 + if [[ -n "${old_current}" ]]; then + replace_symlink "${old_current}" "${install_root}/current" + else + unlink -- "${install_root}/current" + fi + systemctl start "${service_name}" || true + exit 1 + fi +fi + +if [[ -n "${old_current}" && "${old_current}" != "${release_dir}" ]]; then + replace_symlink "${old_current}" "${install_root}/previous" +fi + +echo "Installed NETfishing dedicated server ${game_version} (${game_commit})." +if ${service_exists} && ! ${service_active}; then + echo "${service_name} was inactive and was not started." +fi diff --git a/scripts/package-native.sh b/scripts/package-native.sh index 4fcd8d5..ebd25ae 100755 --- a/scripts/package-native.sh +++ b/scripts/package-native.sh @@ -7,6 +7,10 @@ if [[ $# -ne 1 || -z "$1" ]]; then fi version="$1" +if [[ ! "${version}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "VERSION must contain only letters, numbers, dots, underscores, or hyphens." >&2 + exit 2 +fi repository_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" dist_dir="${repository_dir}/dist" packages_dir="${repository_dir}/packages" @@ -14,18 +18,27 @@ package_name="netfishing-dedicated-server-${version}-linux-x86_64" staging_dir="$(mktemp -d)" trap 'rm -rf -- "${staging_dir}"' EXIT -for filename in NETfishingServer.x86_64 NETfishingServer.pck; do +for filename in NETfishingServer.x86_64 NETfishingServer.pck BUILD_INFO SHA256SUMS; do if [[ ! -f "${dist_dir}/${filename}" ]]; then echo "Run scripts/stage-build.sh before packaging." >&2 exit 1 fi done +if ! grep -Fxq "game_version=${version}" "${dist_dir}/BUILD_INFO"; then + echo "Staged BUILD_INFO does not match package version ${version}." >&2 + exit 1 +fi +(cd -- "${dist_dir}" && sha256sum -c SHA256SUMS) install -d "${staging_dir}/${package_name}" install -m 0755 "${dist_dir}/NETfishingServer.x86_64" \ "${staging_dir}/${package_name}/NETfishingServer.x86_64" install -m 0644 "${dist_dir}/NETfishingServer.pck" \ "${staging_dir}/${package_name}/NETfishingServer.pck" +install -m 0644 "${dist_dir}/BUILD_INFO" \ + "${staging_dir}/${package_name}/BUILD_INFO" +install -m 0644 "${dist_dir}/SHA256SUMS" \ + "${staging_dir}/${package_name}/SHA256SUMS" install -m 0644 "${repository_dir}/config/server.cfg.example" \ "${staging_dir}/${package_name}/server.cfg" install -m 0644 "${repository_dir}/README.md" \ diff --git a/scripts/rollback-native.sh b/scripts/rollback-native.sh new file mode 100755 index 0000000..ce84af8 --- /dev/null +++ b/scripts/rollback-native.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 0 ]]; then + echo "Usage: $0" >&2 + exit 2 +fi +if [[ ${EUID} -ne 0 ]]; then + echo "Run this rollback tool as root." >&2 + exit 2 +fi + +install_root="${NETFISHING_INSTALL_ROOT:-/opt/netfishing-server}" +service_name="${NETFISHING_SERVICE_NAME:-netfishing-server.service}" +releases_dir="${install_root}/releases" + +if [[ "${install_root}" != /* || "${install_root}" == "/" ]]; then + echo "NETFISHING_INSTALL_ROOT must be a specific absolute directory." >&2 + exit 2 +fi +if [[ ! -L "${install_root}/current" || ! -L "${install_root}/previous" ]]; then + echo "Both current and previous release links are required." >&2 + exit 1 +fi + +current_target="$(readlink -f -- "${install_root}/current")" +previous_target="$(readlink -f -- "${install_root}/previous")" +for target in "${current_target}" "${previous_target}"; do + if [[ ! -d "${target}" || "${target}" != "${releases_dir}/"* ]]; then + echo "Refusing to use a release outside ${releases_dir}." >&2 + exit 1 + fi + (cd -- "${target}" && sha256sum -c SHA256SUMS) +done + +replace_symlink() { + local target="$1" + local link_path="$2" + local temporary_link="${install_root}/.$(basename -- "${link_path}").new.$$" + ln -s -- "${target}" "${temporary_link}" + mv -Tf -- "${temporary_link}" "${link_path}" +} + +service_active=false +if systemctl is-active --quiet "${service_name}"; then + service_active=true + systemctl stop "${service_name}" +fi + +replace_symlink "${previous_target}" "${install_root}/current" +replace_symlink "${current_target}" "${install_root}/previous" + +if ${service_active}; then + if ! systemctl start "${service_name}"; then + start_succeeded=false + else + sleep 1 + start_succeeded=true + systemctl is-active --quiet "${service_name}" || start_succeeded=false + fi + if ! ${start_succeeded}; then + echo "Rollback target failed to start; restoring the original release." >&2 + replace_symlink "${current_target}" "${install_root}/current" + replace_symlink "${previous_target}" "${install_root}/previous" + systemctl start "${service_name}" || true + exit 1 + fi +fi + +echo "Rolled back to $(basename -- "${previous_target}")." diff --git a/scripts/stage-build.sh b/scripts/stage-build.sh index 74158c3..f007691 100755 --- a/scripts/stage-build.sh +++ b/scripts/stage-build.sh @@ -1,15 +1,26 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -ne 1 ]]; then - echo "Usage: $0 /path/to/server-linux-x86_64" >&2 +if [[ $# -ne 3 ]]; then + echo "Usage: $0 /path/to/server-linux-x86_64 GAME_VERSION GAME_COMMIT" >&2 exit 2 fi source_dir="${1%/}" +game_version="$2" +game_commit="${3,,}" repository_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" destination_dir="${repository_dir}/dist" +if [[ ! "${game_version}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "GAME_VERSION must contain only letters, numbers, dots, underscores, or hyphens." >&2 + exit 2 +fi +if [[ ! "${game_commit}" =~ ^[0-9a-f]{40}$ ]]; then + echo "GAME_COMMIT must be the full 40-character Git commit." >&2 + exit 2 +fi + for filename in NETfishingServer.x86_64 NETfishingServer.pck; do if [[ ! -f "${source_dir}/${filename}" ]]; then echo "Missing ${source_dir}/${filename}" >&2 @@ -23,5 +34,20 @@ install -m 0644 "${source_dir}/NETfishingServer.pck" \ "${destination_dir}/NETfishingServer.pck" cd -- "${destination_dir}" -sha256sum NETfishingServer.x86_64 NETfishingServer.pck > SHA256SUMS +executable_sha256="$(sha256sum NETfishingServer.x86_64 | awk '{print $1}')" +pck_sha256="$(sha256sum NETfishingServer.pck | awk '{print $1}')" + +apply_umask="$(umask)" +umask 022 +printf '%s\n' \ + "manifest_version=1" \ + "game_version=${game_version}" \ + "game_commit=${game_commit}" \ + "export_preset=Linux Dedicated Server" \ + "platform=linux-x86_64" \ + "executable_sha256=${executable_sha256}" \ + "pck_sha256=${pck_sha256}" \ + > BUILD_INFO +umask "${apply_umask}" +sha256sum NETfishingServer.x86_64 NETfishingServer.pck BUILD_INFO > SHA256SUMS echo "Staged dedicated server build in ${destination_dir}" diff --git a/systemd/netfishing-server.service b/systemd/netfishing-server.service index 984f993..3cae181 100644 --- a/systemd/netfishing-server.service +++ b/systemd/netfishing-server.service @@ -7,9 +7,9 @@ Wants=network-online.target Type=simple User=netfishing Group=netfishing -WorkingDirectory=/opt/netfishing-server +WorkingDirectory=/opt/netfishing-server/current EnvironmentFile=-/etc/netfishing-server.env -ExecStart=/opt/netfishing-server/NETfishingServer.x86_64 -- --config=/etc/netfishing-server.cfg +ExecStart=/opt/netfishing-server/current/NETfishingServer.x86_64 -- --config=/etc/netfishing-server.cfg Restart=on-failure RestartSec=5 NoNewPrivileges=true