diff --git a/.github/path-filters.yml b/.github/path-filters.yml index c7b360598a..303d47accf 100644 --- a/.github/path-filters.yml +++ b/.github/path-filters.yml @@ -19,6 +19,7 @@ global: &global deployment_config: - *global + - deployment-files/fleetnode/** - deployment-files/ha/** - deployment-files/profiles/** - deployment-files/tests/** diff --git a/.github/workflows/deployment-config-checks.yml b/.github/workflows/deployment-config-checks.yml index a6da68a26f..e4f5e8c05c 100644 --- a/.github/workflows/deployment-config-checks.yml +++ b/.github/workflows/deployment-config-checks.yml @@ -49,3 +49,16 @@ jobs: - name: Validate uninstaller updater cleanup run: ./deployment-files/tests/test-uninstall-updater-cleanup.sh + + fleetnode-installer: + name: Fleet Node installer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate shell syntax + run: bash -n deployment-files/fleetnode/install-fleetnode.sh deployment-files/fleetnode/tests/test-install-fleetnode.sh + + - name: Test installer + run: ./deployment-files/fleetnode/tests/test-install-fleetnode.sh diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml index 9bc16b6ef5..cdb843dff3 100644 --- a/.github/workflows/nightly-builds.yml +++ b/.github/workflows/nightly-builds.yml @@ -127,6 +127,13 @@ jobs: path: /tmp/nightly-assets/server merge-multiple: true + - name: Download Fleet Node artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: fleetnode-linux-* + path: /tmp/nightly-assets/fleetnode + merge-multiple: true + - name: Download client artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -161,6 +168,23 @@ jobs: fi done + - name: Verify Fleet Node checksums + run: | + cd /tmp/nightly-assets/fleetnode + for arch in amd64 arm64; do + archive="fleetnode-${VERSION}-linux-${arch}.tar.gz" + if [[ ! -f "$archive" || ! -f "$archive.sha256" ]]; then + echo "::error::Missing Fleet Node archive or checksum for $arch" + exit 1 + fi + expected_sidecar="$RUNNER_TEMP/fleetnode-${arch}.sha256" + sha256sum "$archive" > "$expected_sidecar" + if ! cmp -s "$expected_sidecar" "$archive.sha256"; then + echo "::error::Fleet Node checksum is not bound to $archive" + exit 1 + fi + done + - name: Create release notes run: | cat > /tmp/nightly-release-notes.md <> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/proto-fleet-artifact-build.yml b/.github/workflows/proto-fleet-artifact-build.yml index 06f151bbf6..5ab4825638 100644 --- a/.github/workflows/proto-fleet-artifact-build.yml +++ b/.github/workflows/proto-fleet-artifact-build.yml @@ -237,6 +237,92 @@ jobs: path: ./server/proto-fleet-server-${{ needs.metadata.outputs.version }}-${{ matrix.arch }}.tar.gz retention-days: ${{ inputs.retention_days }} + package-fleet-node: + name: Package Fleet Node (${{ matrix.arch }}) + needs: [metadata] + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + env: + CGO_ENABLED: 0 + VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build Fleet Node and Go plugins + run: | + go work sync + go build -v -o server/fleetnode ./server/cmd/fleetnode + go build -o server/proto-plugin ./plugin/proto + go build -o server/antminer-plugin ./plugin/antminer + go build -o server/virtual-plugin ./plugin/virtual + cp plugin/virtual/config.json server/virtual-plugin.json + + for binary in fleetnode proto-plugin antminer-plugin virtual-plugin; do + if ! readelf -h "server/$binary" >/dev/null; then + echo "::error::Unable to inspect Fleet Node binary $binary" + exit 1 + fi + if readelf -l "server/$binary" | grep INTERP >/dev/null || readelf -d "server/$binary" | grep NEEDED >/dev/null; then + echo "::error::Fleet Node binary $binary must be statically linked" + exit 1 + fi + done + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build asicrs plugin + run: | + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output "type=local,dest=/tmp/asicrs" \ + . + cp /tmp/asicrs/asicrs-plugin server/asicrs-plugin + cp /tmp/asicrs/asicrs-config.yaml server/asicrs-config.yaml + + - name: Package Fleet Node + working-directory: ./server + run: | + package_dir="fleetnode-${VERSION}-linux-${{ matrix.arch }}" + archive="${package_dir}.tar.gz" + + mkdir -p "$package_dir/plugins" + install -m 0755 fleetnode "$package_dir/fleetnode" + install -m 0644 ../deployment-files/fleetnode/fleetnode.service "$package_dir/fleetnode.service" + install -m 0755 proto-plugin "$package_dir/plugins/proto-plugin" + install -m 0755 antminer-plugin "$package_dir/plugins/antminer-plugin" + install -m 0755 virtual-plugin "$package_dir/plugins/virtual-plugin" + install -m 0644 virtual-plugin.json "$package_dir/plugins/virtual-plugin.json" + install -m 0755 asicrs-plugin "$package_dir/plugins/asicrs-plugin" + install -m 0644 asicrs-config.yaml "$package_dir/plugins/asicrs-config.yaml" + printf 'version: %s\n' "$VERSION" > "$package_dir/version.txt" + + tar -czf "$archive" "$package_dir" + sha256sum "$archive" > "$archive.sha256" + sha256sum -c "$archive.sha256" + + - name: Upload Fleet Node artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fleetnode-linux-${{ matrix.arch }} + path: | + ./server/fleetnode-${{ needs.metadata.outputs.version }}-linux-${{ matrix.arch }}.tar.gz + ./server/fleetnode-${{ needs.metadata.outputs.version }}-linux-${{ matrix.arch }}.tar.gz.sha256 + retention-days: ${{ inputs.retention_days }} + build-proto-fleet-client: name: Build ProtoFleet client runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 572da566fb..8a982e7c23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,6 +92,13 @@ jobs: path: /tmp/release-assets/server merge-multiple: true + - name: Download Fleet Node artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: fleetnode-linux-* + path: /tmp/release-assets/fleetnode + merge-multiple: true + - name: Download client artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -128,6 +135,25 @@ jobs: fi done + - name: Verify Fleet Node checksums + env: + VERSION: ${{ github.ref_name }} + run: | + cd /tmp/release-assets/fleetnode + for arch in amd64 arm64; do + archive="fleetnode-${VERSION}-linux-${arch}.tar.gz" + if [[ ! -f "$archive" || ! -f "$archive.sha256" ]]; then + echo "::error::Missing Fleet Node archive or checksum for $arch" + exit 1 + fi + expected_sidecar="$RUNNER_TEMP/fleetnode-${arch}.sha256" + sha256sum "$archive" > "$expected_sidecar" + if ! cmp -s "$expected_sidecar" "$archive.sha256"; then + echo "::error::Fleet Node checksum is not bound to $archive" + exit 1 + fi + done + - name: Create or refresh draft release and upload assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -142,10 +168,13 @@ jobs: /tmp/release-assets/windows/installer.exe /tmp/release-assets/windows/uninstall.exe /tmp/release-assets/server/proto-fleet-server-"$TAG_NAME"-*.tar.gz + /tmp/release-assets/fleetnode/fleetnode-"$TAG_NAME"-linux-*.tar.gz + /tmp/release-assets/fleetnode/fleetnode-"$TAG_NAME"-linux-*.tar.gz.sha256 /tmp/release-assets/client/proto-fleet-client-"$TAG_NAME".tar.gz /tmp/release-assets/proto-os/proto-os-"$TAG_NAME".tar.gz /tmp/release-assets/proto-os/proto-os_"$TAG_NAME".ipk ./deployment-files/install.sh + ./deployment-files/fleetnode/install-fleetnode.sh ./deployment-files/uninstall.sh ) # Refuse to clobber a non-draft release — published assets are immutable from this workflow. diff --git a/deployment-files/fleetnode/fleetnode.service b/deployment-files/fleetnode/fleetnode.service new file mode 100644 index 0000000000..b8ee18679e --- /dev/null +++ b/deployment-files/fleetnode/fleetnode.service @@ -0,0 +1,33 @@ +[Unit] +Description=Proto Fleet Node +Documentation=https://github.com/block/proto-fleet +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=fleetnode +Group=fleetnode +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=-/etc/fleetnode/fleetnode.env +ExecStart=/usr/bin/env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /opt/fleetnode/fleetnode --state-dir /var/lib/fleetnode run +Restart=on-failure +RestartSec=5s +TimeoutStopSec=90s +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadOnlyPaths=/opt/fleetnode /etc/fleetnode +ReadWritePaths=/var/lib/fleetnode +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/deployment-files/fleetnode/install-fleetnode.sh b/deployment-files/fleetnode/install-fleetnode.sh new file mode 100755 index 0000000000..3133a9332d --- /dev/null +++ b/deployment-files/fleetnode/install-fleetnode.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="" +DOWNLOAD_BASE_URL="${FLEETNODE_DOWNLOAD_BASE_URL:-}" +TEST_MODE="${FLEETNODE_TEST_MODE:-0}" +ROOT_PREFIX="${FLEETNODE_ROOT_PREFIX:-}" +LINUX_SERVICE_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +if [[ "$TEST_MODE" != "1" ]]; then + PATH="$LINUX_SERVICE_PATH" + export PATH +fi + +usage() { + cat <<'EOF' +Usage: install-fleetnode.sh --version VERSION + +Install one exact Proto Fleet Node release on Linux. VERSION must be a +release-specific tag such as v1.2.3 or nightly-20260825-0123456789ab. + +The installer starts the service but leaves a fresh installation disabled at +boot. After installation, enroll the node and then enable the service. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "--version requires a value" >&2; exit 2; } + VERSION="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ! "$VERSION" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]] || [[ "$VERSION" == "latest" ]]; then + echo "version must be an explicit release identifier (1-128 letters, numbers, dots, underscores, or hyphens; not latest)" >&2 + exit 2 +fi +if [[ "$TEST_MODE" != "1" ]]; then + if [[ -n "$ROOT_PREFIX" || -n "${FLEETNODE_ARCH:-}" || -n "$DOWNLOAD_BASE_URL" || -n "${FLEETNODE_SYSTEMCTL:-}" ]]; then + echo "Fleet Node installer overrides are restricted to automated tests" >&2 + exit 1 + fi +fi +if [[ "$(uname -s)" != "Linux" && "$TEST_MODE" != "1" ]]; then + echo "Fleet Node installation currently supports Linux only" >&2 + exit 1 +fi +if [[ "$TEST_MODE" != "1" ]]; then + if [[ "$(id -u)" -ne 0 ]]; then + echo "run this installer as root (for example: sudo ./install-fleetnode.sh --version $VERSION)" >&2 + exit 1 + fi +fi + +case "${FLEETNODE_ARCH:-$(uname -m)}" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "unsupported Linux architecture: ${FLEETNODE_ARCH:-$(uname -m)}" >&2; exit 1 ;; +esac + +if [[ "$TEST_MODE" == "1" ]]; then + [[ -n "$ROOT_PREFIX" && "$ROOT_PREFIX" == /* && "$ROOT_PREFIX" != "/" ]] || { + echo "test mode requires an absolute, non-root FLEETNODE_ROOT_PREFIX" >&2 + exit 1 + } +fi + +PROGRAM_DIR="${ROOT_PREFIX}/opt/fleetnode" +CONFIG_DIR="${ROOT_PREFIX}/etc/fleetnode" +STATE_DIR="${ROOT_PREFIX}/var/lib/fleetnode" +UNIT_PATH="${ROOT_PREFIX}/etc/systemd/system/fleetnode.service" +INSTALL_LOCK_DIR="${ROOT_PREFIX}/run/proto-fleet" +INSTALL_LOCK_PATH="$INSTALL_LOCK_DIR/fleetnode-installer.lock" +SYSTEMCTL="${FLEETNODE_SYSTEMCTL:-systemctl}" +ARCHIVE_ROOT="fleetnode-${VERSION}-linux-${ARCH}" +ARCHIVE_NAME="${ARCHIVE_ROOT}.tar.gz" +if [[ -z "$DOWNLOAD_BASE_URL" ]]; then + DOWNLOAD_BASE_URL="https://github.com/block/proto-fleet/releases/download/${VERSION}" +fi + +for command in curl sha256sum tar install flock "$SYSTEMCTL"; do + command -v "$command" >/dev/null 2>&1 || { echo "required command not found: $command" >&2; exit 1; } +done +if [[ "$TEST_MODE" != "1" && ! -x /usr/bin/env ]]; then + echo "required command not found: /usr/bin/env" >&2 + exit 1 +fi +if ! command -v nmap >/dev/null 2>&1; then + echo "required command not found: nmap; install nmap in the Fleet Node service PATH ($LINUX_SERVICE_PATH)" >&2 + exit 1 +fi +if [[ "$TEST_MODE" != "1" ]]; then + for command in getent useradd nologin; do + command -v "$command" >/dev/null 2>&1 || { echo "required command not found: $command" >&2; exit 1; } + done +fi + +for path in "$PROGRAM_DIR" "$CONFIG_DIR" "$STATE_DIR" "$UNIT_PATH" "$INSTALL_LOCK_DIR" "$INSTALL_LOCK_PATH"; do + if [[ -L "$path" ]]; then + echo "refusing to install through symlink: $path" >&2 + exit 1 + fi +done +if [[ -e "$PROGRAM_DIR" && ! -d "$PROGRAM_DIR" ]]; then + echo "program path exists but is not a directory: $PROGRAM_DIR" >&2 + exit 1 +fi + +if [[ "$TEST_MODE" == "1" ]]; then + install -d -m 0755 "$INSTALL_LOCK_DIR" +else + install -d -o root -g root -m 0755 "$INSTALL_LOCK_DIR" +fi +( + umask 077 + : >> "$INSTALL_LOCK_PATH" +) +chmod 0600 "$INSTALL_LOCK_PATH" +INSTALL_LOCK_FD=9 +exec 9<>"$INSTALL_LOCK_PATH" +if ! flock -n "$INSTALL_LOCK_FD"; then + echo "another Fleet Node installer is running" >&2 + exit 1 +fi + +work_dir=$(mktemp -d) +incoming="${PROGRAM_DIR%/*}/.fleetnode.install.$$" +previous="${PROGRAM_DIR%/*}/.fleetnode.previous.$$" +unit_backup="$work_dir/fleetnode.service.previous" +fresh_install=1 +[[ -x "$PROGRAM_DIR/fleetnode" && -f "$UNIT_PATH" ]] && fresh_install=0 +service_stopped=0 +previous_saved=0 +program_replaced=0 +unit_replaced=0 +install_complete=0 + +cleanup() { + status=$? + if [[ "$install_complete" != "1" ]]; then + if [[ "$unit_replaced" == "1" ]]; then + if [[ -f "$unit_backup" ]]; then + install -m 0644 "$unit_backup" "$UNIT_PATH" + else + rm -f "$UNIT_PATH" + fi + "$SYSTEMCTL" daemon-reload >/dev/null 2>&1 || true + fi + if [[ "$program_replaced" == "1" ]]; then + rm -rf "$PROGRAM_DIR" + fi + if [[ "$previous_saved" == "1" && -d "$previous" ]]; then + mv "$previous" "$PROGRAM_DIR" + fi + if [[ "$service_stopped" == "1" ]]; then + "$SYSTEMCTL" start fleetnode.service >/dev/null 2>&1 || true + fi + fi + rm -rf "$work_dir" "$incoming" + if [[ "$install_complete" == "1" ]]; then + rm -rf "$previous" + fi + exit "$status" +} +trap cleanup EXIT + +echo "Downloading Fleet Node $VERSION for linux/$ARCH..." +curl_options=(--disable --fail --location --silent --show-error) +if [[ "$TEST_MODE" != "1" ]]; then + curl_options+=(--proto '=https' --proto-redir '=https') +fi +curl "${curl_options[@]}" \ + --output "$work_dir/$ARCHIVE_NAME" "$DOWNLOAD_BASE_URL/$ARCHIVE_NAME" +curl "${curl_options[@]}" \ + --output "$work_dir/$ARCHIVE_NAME.sha256" "$DOWNLOAD_BASE_URL/$ARCHIVE_NAME.sha256" + +( + cd "$work_dir" + sha256sum "$ARCHIVE_NAME" > expected.sha256 + if ! cmp -s expected.sha256 "$ARCHIVE_NAME.sha256"; then + echo "checksum sidecar is not bound to $ARCHIVE_NAME" >&2 + exit 1 + fi + sha256sum -c "$ARCHIVE_NAME.sha256" +) + +while IFS= read -r entry; do + case "$entry" in + "$ARCHIVE_ROOT"|"$ARCHIVE_ROOT/"|"$ARCHIVE_ROOT/"*) ;; + *) echo "archive contains an unexpected path: $entry" >&2; exit 1 ;; + esac + case "/$entry/" in + */../*|*/./*) echo "archive contains an unsafe path: $entry" >&2; exit 1 ;; + esac +done < <(tar -tzf "$work_dir/$ARCHIVE_NAME") + +mkdir -p "$work_dir/extracted" +tar -xzf "$work_dir/$ARCHIVE_NAME" -C "$work_dir/extracted" +source_dir="$work_dir/extracted/$ARCHIVE_ROOT" +[[ -d "$source_dir" && ! -L "$source_dir" ]] || { echo "archive root must be a directory" >&2; exit 1; } +for required in \ + fleetnode \ + version.txt \ + fleetnode.service \ + plugins/proto-plugin \ + plugins/antminer-plugin \ + plugins/virtual-plugin \ + plugins/virtual-plugin.json \ + plugins/asicrs-plugin \ + plugins/asicrs-config.yaml; do + [[ -f "$source_dir/$required" ]] || { echo "archive is missing $required" >&2; exit 1; } +done +if find "$source_dir" -type l -print -quit | grep -q .; then + echo "archive must not contain symlinks" >&2 + exit 1 +fi +if find "$source_dir" -type f -links +1 -print -quit | grep -q .; then + echo "archive must not contain hard links" >&2 + exit 1 +fi +while IFS= read -r -d '' path; do + relative_path="${path#"$source_dir"/}" + case "$relative_path" in + fleetnode|version.txt|fleetnode.service|plugins|plugins/proto-plugin|plugins/antminer-plugin|plugins/virtual-plugin|plugins/virtual-plugin.json|plugins/asicrs-plugin|plugins/asicrs-config.yaml) ;; + *) echo "archive contains an unexpected entry: $relative_path" >&2; exit 1 ;; + esac +done < <(find "$source_dir" -mindepth 1 -print0) +if ! grep -Fxq "version: $VERSION" "$source_dir/version.txt"; then + echo "Fleet Node archive metadata does not match requested version '$VERSION'" >&2 + exit 1 +fi + +if [[ "$fresh_install" == "0" ]]; then + "$SYSTEMCTL" stop fleetnode.service + service_stopped=1 +fi + +install -d -m 0755 "${PROGRAM_DIR%/*}" "${UNIT_PATH%/*}" +if [[ "$TEST_MODE" == "1" ]]; then + install -d -m 0750 "$CONFIG_DIR" + install -d -m 0700 "$STATE_DIR" +else + if ! getent passwd fleetnode >/dev/null; then + if getent group fleetnode >/dev/null; then + useradd --system --gid fleetnode --home-dir /var/lib/fleetnode --shell "$(command -v nologin)" fleetnode + else + useradd --system --user-group --home-dir /var/lib/fleetnode --shell "$(command -v nologin)" fleetnode + fi + fi + getent group fleetnode >/dev/null || { echo "fleetnode user exists without a fleetnode group" >&2; exit 1; } + install -d -o root -g fleetnode -m 0750 "$CONFIG_DIR" + install -d -o fleetnode -g fleetnode -m 0700 "$STATE_DIR" +fi + +rm -rf "$incoming" "$previous" +install -d -m 0755 "$incoming" "$incoming/plugins" +install -m 0755 "$source_dir/fleetnode" "$incoming/fleetnode" +install -m 0644 "$source_dir/version.txt" "$incoming/version.txt" +install -m 0644 "$source_dir/fleetnode.service" "$incoming/fleetnode.service" +install -m 0755 "$source_dir/plugins/proto-plugin" "$incoming/plugins/proto-plugin" +install -m 0755 "$source_dir/plugins/antminer-plugin" "$incoming/plugins/antminer-plugin" +install -m 0755 "$source_dir/plugins/virtual-plugin" "$incoming/plugins/virtual-plugin" +install -m 0644 "$source_dir/plugins/virtual-plugin.json" "$incoming/plugins/virtual-plugin.json" +install -m 0755 "$source_dir/plugins/asicrs-plugin" "$incoming/plugins/asicrs-plugin" +install -m 0644 "$source_dir/plugins/asicrs-config.yaml" "$incoming/plugins/asicrs-config.yaml" +if [[ "$TEST_MODE" != "1" ]]; then + chown -R root:root "$incoming" +fi +if [[ -d "$PROGRAM_DIR" ]]; then + mv "$PROGRAM_DIR" "$previous" + previous_saved=1 +fi +mv "$incoming" "$PROGRAM_DIR" +program_replaced=1 + +if [[ -f "$UNIT_PATH" ]]; then + cp -p "$UNIT_PATH" "$unit_backup" +fi +install -m 0644 "$PROGRAM_DIR/fleetnode.service" "$UNIT_PATH" +unit_replaced=1 +"$SYSTEMCTL" daemon-reload + +if [[ "$fresh_install" == "1" ]]; then + if "$SYSTEMCTL" is-enabled --quiet fleetnode.service; then + "$SYSTEMCTL" disable fleetnode.service + fi +fi +"$SYSTEMCTL" start fleetnode.service + +install_complete=1 +echo "Fleet Node $VERSION installed." +echo "Configuration: $CONFIG_DIR" +echo "Protected state: $STATE_DIR" +if [[ "$fresh_install" == "1" ]]; then + echo "The service was started and remains disabled at boot until enrollment is complete." +else + echo "The service was restarted; its enablement state was preserved." +fi +echo +echo "Enroll:" +echo " sudo -u fleetnode $PROGRAM_DIR/fleetnode --state-dir $STATE_DIR enroll --server-url=https://YOUR-FLEET-SERVER" +echo "Then enable at boot:" +echo " sudo systemctl enable fleetnode.service" +echo "Inspect:" +echo " systemctl status fleetnode.service" +echo " journalctl -u fleetnode.service" diff --git a/deployment-files/fleetnode/tests/test-install-fleetnode.sh b/deployment-files/fleetnode/tests/test-install-fleetnode.sh new file mode 100755 index 0000000000..64dc6bd920 --- /dev/null +++ b/deployment-files/fleetnode/tests/test-install-fleetnode.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +FLEETNODE_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +TEST_DIR=$(mktemp -d) +trap 'rm -rf "$TEST_DIR"' EXIT + +ASSETS_DIR="$TEST_DIR/assets" +ROOT_PREFIX="$TEST_DIR/root" +SYSTEMCTL_LOG="$TEST_DIR/systemctl.log" +LINUX_SERVICE_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +mkdir -p "$ASSETS_DIR" "$ROOT_PREFIX" "$TEST_DIR/bin" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_file_contains() { + local path="$1" + local expected="$2" + grep -Fq "$expected" "$path" || fail "$path does not contain: $expected" +} + +create_release() { + local version="$1" + local extra_entry="${2:-}" + local release_dir="$ASSETS_DIR/$version" + local archive_root="fleetnode-${version}-linux-amd64" + mkdir -p "$release_dir/$archive_root/plugins" + + printf '#!/usr/bin/env bash\nexit 0\n' > "$release_dir/$archive_root/fleetnode" + chmod 0755 "$release_dir/$archive_root/fleetnode" + printf 'version: %s\n' "$version" > "$release_dir/$archive_root/version.txt" + cp "$FLEETNODE_DIR/fleetnode.service" "$release_dir/$archive_root/fleetnode.service" + + local plugin + for plugin in proto-plugin antminer-plugin virtual-plugin asicrs-plugin; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$release_dir/$archive_root/plugins/$plugin" + chmod 0755 "$release_dir/$archive_root/plugins/$plugin" + done + printf '{}\n' > "$release_dir/$archive_root/plugins/virtual-plugin.json" + printf 'plugin: {}\nminers: {}\n' > "$release_dir/$archive_root/plugins/asicrs-config.yaml" + if [[ -n "$extra_entry" ]]; then + printf '#!/usr/bin/env bash\nexit 0\n' > "$release_dir/$archive_root/$extra_entry" + chmod 4755 "$release_dir/$archive_root/$extra_entry" + fi + + ( + cd "$release_dir" + tar -czf "$archive_root.tar.gz" "$archive_root" + sha256sum "$archive_root.tar.gz" > "$archive_root.tar.gz.sha256" + rm -rf "$archive_root" + ) +} + +cat > "$TEST_DIR/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$SYSTEMCTL_LOG" +if [[ "${1:-}" == "stop" && -n "${FAKE_FLEETNODE_LOCK_READY:-}" ]]; then + : > "$FAKE_FLEETNODE_LOCK_READY" + while [[ -e "${FAKE_FLEETNODE_LOCK_BLOCK:-}" ]]; do + sleep 0.05 + done +fi +if [[ "${1:-}" == "is-enabled" ]]; then + [[ "${FAKE_FLEETNODE_ENABLED:-0}" == "1" ]] + exit +fi +exit 0 +EOF +chmod 0755 "$TEST_DIR/bin/systemctl" + +cat > "$TEST_DIR/bin/nmap" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod 0755 "$TEST_DIR/bin/nmap" + +cat > "$TEST_DIR/bin/flock" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${REAL_FLOCK:-}" ]]; then + exec "$REAL_FLOCK" "$@" +fi +exit 0 +EOF +chmod 0755 "$TEST_DIR/bin/flock" + +NO_NMAP_BIN="$TEST_DIR/no-nmap-bin" +mkdir -p "$NO_NMAP_BIN" +for command in uname curl sha256sum tar install; do + ln -s "$(command -v "$command")" "$NO_NMAP_BIN/$command" +done +ln -s "$TEST_DIR/bin/flock" "$NO_NMAP_BIN/flock" +if PATH="$NO_NMAP_BIN" \ + FLEETNODE_TEST_MODE=1 \ + FLEETNODE_ROOT_PREFIX="$ROOT_PREFIX" \ + FLEETNODE_ARCH=amd64 \ + FLEETNODE_SYSTEMCTL="$TEST_DIR/bin/systemctl" \ + /bin/bash "$FLEETNODE_DIR/install-fleetnode.sh" --version v1.0.0 2> "$TEST_DIR/missing-nmap.err"; then + fail "installer accepted a host without nmap on PATH" +fi +assert_file_contains "$TEST_DIR/missing-nmap.err" "required command not found: nmap" +assert_file_contains "$FLEETNODE_DIR/install-fleetnode.sh" "LINUX_SERVICE_PATH=\"$LINUX_SERVICE_PATH\"" +assert_file_contains "$FLEETNODE_DIR/install-fleetnode.sh" 'PATH="$LINUX_SERVICE_PATH"' +assert_file_contains "$FLEETNODE_DIR/install-fleetnode.sh" 'exec 9<>"$INSTALL_LOCK_PATH"' +assert_file_contains "$FLEETNODE_DIR/install-fleetnode.sh" 'if ! flock -n "$INSTALL_LOCK_FD"; then' +if grep -Fq 'is-active' "$FLEETNODE_DIR/install-fleetnode.sh"; then + fail "installer checks active state before restarting" +fi + +if FLEETNODE_SYSTEMCTL="$TEST_DIR/bin/systemctl" \ + /bin/bash "$FLEETNODE_DIR/install-fleetnode.sh" --version v1.0.0 2> "$TEST_DIR/systemctl-override.err"; then + fail "installer accepted a systemctl override outside test mode" +fi +assert_file_contains "$TEST_DIR/systemctl-override.err" "installer overrides are restricted to automated tests" + +create_release v1.0.0 +create_release v1.1.0 +create_release v1.2.0 unexpected-setuid + +mkdir -p "$TEST_DIR/curl-home" +printf 'proto = "=https"\n' > "$TEST_DIR/curl-home/.curlrc" + +run_installer() { + local version="$1" + FAKE_FLEETNODE_ENABLED="${FAKE_FLEETNODE_ENABLED:-0}" \ + FAKE_FLEETNODE_LOCK_READY="${FAKE_FLEETNODE_LOCK_READY:-}" \ + FAKE_FLEETNODE_LOCK_BLOCK="${FAKE_FLEETNODE_LOCK_BLOCK:-}" \ + REAL_FLOCK="${REAL_FLOCK:-}" \ + SYSTEMCTL_LOG="$SYSTEMCTL_LOG" \ + PATH="$TEST_DIR/bin:$PATH" \ + FLEETNODE_TEST_MODE=1 \ + FLEETNODE_ROOT_PREFIX="$ROOT_PREFIX" \ + FLEETNODE_ARCH=amd64 \ + FLEETNODE_SYSTEMCTL="$TEST_DIR/bin/systemctl" \ + FLEETNODE_DOWNLOAD_BASE_URL="file://$ASSETS_DIR/$version" \ + bash "$FLEETNODE_DIR/install-fleetnode.sh" --version "$version" +} + +: > "$SYSTEMCTL_LOG" +CURL_HOME="$TEST_DIR/curl-home" FAKE_FLEETNODE_ENABLED=1 run_installer v1.0.0 + +[[ -x "$ROOT_PREFIX/opt/fleetnode/fleetnode" ]] || fail "Fleet Node binary was not installed" +assert_file_contains "$ROOT_PREFIX/opt/fleetnode/version.txt" "version: v1.0.0" +[[ -f "$ROOT_PREFIX/etc/systemd/system/fleetnode.service" ]] || fail "systemd unit was not installed" +assert_file_contains "$ROOT_PREFIX/etc/systemd/system/fleetnode.service" "Environment=PATH=$LINUX_SERVICE_PATH" +assert_file_contains "$ROOT_PREFIX/etc/systemd/system/fleetnode.service" "ExecStart=/usr/bin/env PATH=$LINUX_SERVICE_PATH /opt/fleetnode/fleetnode --state-dir /var/lib/fleetnode run" +assert_file_contains "$ROOT_PREFIX/etc/systemd/system/fleetnode.service" "Restart=on-failure" +assert_file_contains "$ROOT_PREFIX/etc/systemd/system/fleetnode.service" "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK" +if grep -Eq '(^| )enable( |$)|(^| )stop( |$)' "$SYSTEMCTL_LOG"; then + fail "fresh install enabled or stopped the service" +fi +assert_file_contains "$SYSTEMCTL_LOG" "disable fleetnode.service" +assert_file_contains "$SYSTEMCTL_LOG" "start fleetnode.service" + +printf 'operator config\n' > "$ROOT_PREFIX/etc/fleetnode/config.yaml" +printf 'identity material\n' > "$ROOT_PREFIX/var/lib/fleetnode/state.yaml" +printf 'stale program file\n' > "$ROOT_PREFIX/opt/fleetnode/stale.txt" + +: > "$SYSTEMCTL_LOG" +run_installer v1.1.0 + +assert_file_contains "$ROOT_PREFIX/opt/fleetnode/version.txt" "version: v1.1.0" +[[ ! -e "$ROOT_PREFIX/opt/fleetnode/stale.txt" ]] || fail "upgrade retained stale program files" +assert_file_contains "$ROOT_PREFIX/etc/fleetnode/config.yaml" "operator config" +assert_file_contains "$ROOT_PREFIX/var/lib/fleetnode/state.yaml" "identity material" +assert_file_contains "$SYSTEMCTL_LOG" "stop fleetnode.service" +assert_file_contains "$SYSTEMCTL_LOG" "start fleetnode.service" + +if REAL_FLOCK_BINARY=$(command -v flock 2>/dev/null); then + LOCK_READY="$TEST_DIR/installer-lock.ready" + LOCK_BLOCK="$TEST_DIR/installer-lock.block" + : > "$LOCK_BLOCK" + REAL_FLOCK="$REAL_FLOCK_BINARY" \ + FAKE_FLEETNODE_LOCK_READY="$LOCK_READY" \ + FAKE_FLEETNODE_LOCK_BLOCK="$LOCK_BLOCK" \ + run_installer v1.1.0 > "$TEST_DIR/first-installer.log" 2>&1 & + first_installer_pid=$! + + for _ in {1..100}; do + [[ -e "$LOCK_READY" ]] && break + sleep 0.05 + done + if [[ ! -e "$LOCK_READY" ]]; then + rm -f "$LOCK_BLOCK" + wait "$first_installer_pid" || true + fail "first installer did not reach the lock test barrier" + fi + + if REAL_FLOCK="$REAL_FLOCK_BINARY" run_installer v1.1.0 > "$TEST_DIR/second-installer.log" 2>&1; then + rm -f "$LOCK_BLOCK" + wait "$first_installer_pid" || true + fail "installer accepted an overlapping invocation" + fi + assert_file_contains "$TEST_DIR/second-installer.log" "another Fleet Node installer is running" + + rm -f "$LOCK_BLOCK" + wait "$first_installer_pid" + assert_file_contains "$ROOT_PREFIX/opt/fleetnode/version.txt" "version: v1.1.0" +fi + +if run_installer v1.2.0; then + fail "installer accepted an archive entry outside the Fleet Node manifest" +fi +assert_file_contains "$ROOT_PREFIX/opt/fleetnode/version.txt" "version: v1.1.0" +[[ ! -e "$ROOT_PREFIX/opt/fleetnode/unexpected-setuid" ]] || fail "installer persisted an unexpected archive entry" + +cp -R "$ASSETS_DIR/v1.1.0" "$ASSETS_DIR/bad-checksum" +printf 'tampered\n' >> "$ASSETS_DIR/bad-checksum/fleetnode-v1.1.0-linux-amd64.tar.gz" +if FLEETNODE_TEST_MODE=1 \ + FLEETNODE_ROOT_PREFIX="$ROOT_PREFIX" \ + FLEETNODE_ARCH=amd64 \ + FLEETNODE_SYSTEMCTL="$TEST_DIR/bin/systemctl" \ + FLEETNODE_DOWNLOAD_BASE_URL="file://$ASSETS_DIR/bad-checksum" \ + SYSTEMCTL_LOG="$SYSTEMCTL_LOG" \ + PATH="$TEST_DIR/bin:$PATH" \ + bash "$FLEETNODE_DIR/install-fleetnode.sh" --version v1.1.0 2> "$TEST_DIR/bad-checksum.err"; then + fail "installer accepted a checksum mismatch" +fi +assert_file_contains "$TEST_DIR/bad-checksum.err" "checksum sidecar is not bound" +assert_file_contains "$ROOT_PREFIX/opt/fleetnode/version.txt" "version: v1.1.0" +assert_file_contains "$ROOT_PREFIX/var/lib/fleetnode/state.yaml" "identity material" + +echo "Fleet Node installer tests passed"