From f264f8a673554e6b6fa9f2cf6beb231bb07b3c29 Mon Sep 17 00:00:00 2001 From: mrvasil Date: Fri, 23 Oct 2026 22:29:51 +0300 Subject: [PATCH] Package static Linux x64 releases with first-run launcher --- .github/workflows/linux-release.yml | 56 ++++++++++++++ README.md | 24 ++++++ agent.md | 3 + deploy/QUICKSTART.md | 60 ++++++++++++++ deploy/run.sh | 34 ++++++++ scripts/package-linux.sh | 25 ++++++ scripts/verify-linux-package.py | 116 ++++++++++++++++++++++++++++ task_plan.md | 5 ++ 8 files changed, 323 insertions(+) create mode 100644 .github/workflows/linux-release.yml create mode 100644 deploy/QUICKSTART.md create mode 100644 deploy/run.sh create mode 100644 scripts/package-linux.sh create mode 100644 scripts/verify-linux-package.py diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml new file mode 100644 index 0000000..5678092 --- /dev/null +++ b/.github/workflows/linux-release.yml @@ -0,0 +1,56 @@ +name: Linux x64 +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install pinned Rust and musl build tools + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + rustup toolchain install 1.93.1 --profile minimal --component rustfmt --component clippy --target x86_64-unknown-linux-musl + rustup override set 1.93.1 + - name: Verify + run: | + cargo fmt --check + cargo clippy --all-targets --locked -- -D warnings + cargo test --all-targets --locked --target x86_64-unknown-linux-musl + node --check web/app.js + sh -n deploy/run.sh scripts/package-linux.sh + - name: Build static release and verify package + run: | + cargo build --release --locked --target x86_64-unknown-linux-musl + sh scripts/package-linux.sh + python3 scripts/verify-linux-package.py dist/release/mirror-tool-linux-x64.tar.gz + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mirror-tool-linux-x64 + path: dist/release/* + if-no-files-found: error + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: mirror-tool-linux-x64 + path: release + - name: Publish verified archive + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release create "$RELEASE_TAG" release/* --verify-tag --title "Mirror Tool $RELEASE_TAG — Linux x64" --notes 'Ready-to-run static Linux x64 package. Extract and run ./run.sh. First launch creates a private random panel password and an empty configuration. No Rust, Python, Node, glibc or external SQLite required. See included README.md. Flag substitution is disabled by default.' diff --git a/README.md b/README.md index c70756f..d9a4d20 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,30 @@ ## Запуск +Для Linux x64 скачайте **mirror-tool-linux-x64.tar.gz** и **SHA256SUMS** из +[Releases](https://github.com/mrvasil/ctf-ad-mirror-tool-rust/releases/latest) +(нужен доступ к приватному репозиторию): + +```sh +sha256sum -c SHA256SUMS +tar -xzf mirror-tool-linux-x64.tar.gz +cd mirror-tool-linux-x64 +./run.sh +``` + +Статический musl-бинарник не требует установленной glibc, Rust, Python, Node, +Docker или отдельной SQLite. Скрипт создаст пустой `mirror.toml` и случайный пароль +в `data/panel-password` (0600); повторный запуск сохраняет настройки и пароль. +Панель — `http://127.0.0.1:6700`, пароль прочитайте в другом терминале командой +`cat data/panel-password`. Команды/сервисы добавляются через панель; подмена флагов +выключена. [Полная инструкция архива](deploy/QUICKSTART.md). + +GitHub Actions проверяет musl-тесты, статическую линковку и запуск архива с TCP, +API и перезапуском; push тега `v*` публикует архив и SHA256SUMS в Releases. +Обычный push в main сохраняет сборку как Actions artifact, не меняя релиз. + +Для сборки из исходников: + Нужен актуальный stable Rust (проверенные версии записаны в `docs/verification.md`). ```sh diff --git a/agent.md b/agent.md index 1f35905..0120950 100644 --- a/agent.md +++ b/agent.md @@ -45,3 +45,6 @@ User authorized design explanation followed by implementation. Explained random ## Flag protection completed — 2026-10-23 User explicitly requires optional substitution, default off. Implemented per-service `flag_protection=false`, backwards-compatible omitted config/state fields, settings checkbox and indicator without redesign. Mode is captured for each TCP session; disabling affects new sessions. Random same-format tokens use an XChaCha20-Poly1305 encrypted SQLite vault, durable before forwarding, bounded worker queue, TTL/capacity and fail-closed errors. See docs/flag-protection.md and docs/flag-protection-verification.md. Fresh49 native/50 Orb tests, native fmt/Clippy, JS syntax and both release builds passed. Browser toggled on/off and persisted both; TCP tests verify byte integrity/half-close/replica equality and session mode. Live demo16700 upgraded with ignored persistent synthetic vault, original topology unchanged, protection off. No real hosts or flags used. + +## Portable Linux release in progress +User wants a ready-to-run Linux x64 build on GitHub, exact distribution unknown. Package static musl binary with run.sh, empty first-run config and random persistent private password; do not package runtime data or enable substitution. Workflow verifies native Clippy/fmt and musl tests plus archive login/API/TCP/restart smoke, then tag releases publish privately. Main push artifacts do not replace releases. Actual CI/package verification pending; docs in deploy/QUICKSTART.md. diff --git a/deploy/QUICKSTART.md b/deploy/QUICKSTART.md new file mode 100644 index 0000000..67543d3 --- /dev/null +++ b/deploy/QUICKSTART.md @@ -0,0 +1,60 @@ +# Mirror Tool — Linux x64 + +```sh +tar -xzf mirror-tool-linux-x64.tar.gz +cd mirror-tool-linux-x64 +./run.sh +``` + +Панель: http://127.0.0.1:6700. В другом терминале из этой папки: + +```sh +cat data/panel-password +``` + +Пароль генерируется случайно при первом запуске и сохраняется с правами0600. +Можно передать собственный пароль через MIRROR_PASSWORD (не менее12 символов). +Пароль не выводится в логи. Запускать от root не требуется для портов выше1023. + +Для удалённого сервера откройте туннель со своего компьютера: + +```sh +ssh -N -L 6700:127.0.0.1:6700 user@server +``` + +Затем откройте http://127.0.0.1:6700 на своём компьютере. Панель по умолчанию +доступна только локально; не публикуйте её HTTP-порт в игровой сети. + +Первый запуск создаёт пустой mirror.toml. Добавьте команды и прокси в панели; +нажмите название сервиса для настройки слушателя и upstream-порта. Выберите +primary, реплики и включите сервис. Порты должны быть свободны. Подмена флагов +выключена; системные маршруты/firewall не изменяются. Игровые адреса не угадываются. + +mirror.example.toml содержит полный пример. Для ForcAD добавьте [scoreboard] +в mirror.toml и перезапустите процесс. Подробности в docs/configuration.md. +`./run.sh check` проверяет конфиг, но не сетевую доступность или ключ кеша. + +Остановка: Ctrl+C или SIGTERM. Для постоянной работы используйте tmux либо +приложенный шаблон systemd (требует установки бинарника, пользователя и конфигов, +как описано в исходном README проекта). С закрытием SSH foreground-процесс может +завершиться. run.sh сам автозапуск и системную службу не устанавливает. + +Сохраняйте mirror.toml и каталог data при обновлении. Архив их не содержит: +распаковка обновления не заменяет рабочие настройки/пароль/состояние. Остановите +процесс перед обновлением. Настройки команд/сервисов из data/state.json имеют +приоритет над TOML. Резервную копию делайте при остановленном процессе. + +Бинарник статический (musl), базовая архитектура x86-64, без требования AVX. +Не нужны glibc, Rust, Python, Node, Docker или отдельная SQLite. Для run.sh нужны +обычные /bin/sh, uname, dirname, mkdir, od, tr, cat и /dev/urandom. Для распаковки +нужен tar с gzip. Linux kernel3.2+ — базовый минимум target Rust; конкретная старая +система не считается проверенной только по номеру ядра. Ограничения контейнера, +seccomp, права, доступная память, файловые дескрипторы и сеть всё равно действуют. + +Проверка архива: `sha256sum -c SHA256SUMS` до распаковки. BUILD-COMMIT указывает +исходный коммит. Репозиторий и Releases приватные: скачивание требует доступа +к GitHub. С GitHub CLI: `gh release download --repo mrvasil/ctf-ad-mirror-tool-rust +--pattern 'mirror-tool-linux-x64.tar.gz' --pattern SHA256SUMS` (одной строкой). + +Перед игрой проверьте checker и переключение на своих сервисах: зеркалирование +TCP-запросов не является репликацией БД. Подробнее: docs/flag-protection.md. diff --git a/deploy/run.sh b/deploy/run.sh new file mode 100644 index 0000000..32a8450 --- /dev/null +++ b/deploy/run.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Portable Linux launcher. Runtime data is never included in release archives. +set -eu +case "$(uname -s):$(uname -m)" in + Linux:x86_64|Linux:amd64) ;; + *) echo 'This package requires Linux x86-64.' >&2; exit 1 ;; +esac +cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +umask 077 +mkdir -p data +if [ ! -e mirror.toml ]; then + (set -C; cat > mirror.toml <<'CONFIG' +# Add teams/services in the panel or use mirror.example.toml as a reference. +# Existing data/state.json takes precedence over teams/services in this file. +[web] +listen = "127.0.0.1:6700" +state_file = "data/state.json" +CONFIG + ) +fi +if [ -z "${MIRROR_PASSWORD:-}" ]; then + if [ ! -e data/panel-password ]; then + (set -C; od -An -N24 -tx1 /dev/urandom | tr -d ' \n' > data/panel-password) + fi + MIRROR_PASSWORD=$(cat data/panel-password) + if [ "${#MIRROR_PASSWORD}" -lt 12 ]; then + echo 'data/panel-password is invalid; restore it or supply MIRROR_PASSWORD.' >&2 + exit 1 + fi + export MIRROR_PASSWORD + echo 'Panel password: read data/panel-password (kept across restarts).' +fi +echo 'Config: mirror.toml. Default panel: http://127.0.0.1:6700 (see web.listen).' +exec ./mirror-tool --config mirror.toml "$@" diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100644 index 0000000..da88621 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Run from the repository root after building the musl target. +set -eu +binary=${1:-target/x86_64-unknown-linux-musl/release/mirror-tool} +out=${2:-dist/release} +readelf -h "$binary" | grep -q 'Advanced Micro Devices X86-64' +if readelf -l "$binary" | grep -q INTERP || readelf -d "$binary" | grep -q NEEDED; then + echo 'Refusing to package a dynamically linked binary.' >&2 + exit 1 +fi +mkdir -p "$out" +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT HUP INT TERM +package="$stage/mirror-tool-linux-x64" +mkdir -p "$package/docs" +cp "$binary" "$package/mirror-tool" +cp deploy/run.sh "$package/run.sh" +chmod 755 "$package/mirror-tool" "$package/run.sh" +cp mirror.example.toml "$package/" +cp deploy/QUICKSTART.md "$package/README.md" +cp docs/configuration.md docs/flag-protection.md "$package/docs/" +cp deploy/mirror-tool.service "$package/" +git rev-parse HEAD > "$package/BUILD-COMMIT" +tar -czf "$out/mirror-tool-linux-x64.tar.gz" -C "$stage" mirror-tool-linux-x64 +(cd "$out" && sha256sum mirror-tool-linux-x64.tar.gz > SHA256SUMS) diff --git a/scripts/verify-linux-package.py b/scripts/verify-linux-package.py new file mode 100644 index 0000000..cd03568 --- /dev/null +++ b/scripts/verify-linux-package.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Test the shipped archive in isolation; never print generated credentials.""" +import http.cookiejar +import json +import os +from pathlib import Path +import socket +import stat +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import urllib.request + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary: + root = Path(temporary) + with tarfile.open(sys.argv[1]) as archive: + archive.extractall(root, filter="data") + package = root / "mirror-tool-linux-x64" + env = os.environ.copy() + env.pop("MIRROR_PASSWORD", None) + subprocess.run([str(package / "run.sh"), "check"], cwd=root, env=env, check=True) + password_path = package / "data/panel-password" + password = password_path.read_text() + assert len(password) == 48 + assert stat.S_IMODE(password_path.stat().st_mode) == 0o600 + config = package / "mirror.toml" + web_port = free_port() + config.write_text(config.read_text().replace("127.0.0.1:6700", f"127.0.0.1:{web_port}")) + original_config = config.read_bytes() + base = f"http://127.0.0.1:{web_port}" + upstream = socket.socket() + upstream.bind(("127.0.0.1", 0)) + upstream.listen() + upstream.settimeout(15) + received = bytearray() + + def echo(): + connection, _ = upstream.accept() + with connection: + connection.settimeout(10) + while data := connection.recv(65536): + received.extend(data) + connection.sendall(data) + + thread = threading.Thread(target=echo, daemon=True) + thread.start() + proxy_port = free_port() + for restart in (False, True): + with (root / "server.log").open("wb") as log: + process = subprocess.Popen([str(package / "run.sh")], cwd=root, env=env, stdout=log, stderr=log) + try: + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + for _ in range(100): + if process.poll() is not None: + raise RuntimeError("packaged process exited during startup") + try: + with opener.open(base + "/", timeout=1) as response: + assert b"html" in response.read().lower() + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("packaged server did not become ready") + + def request(path, value=None, method=None, csrf=None): + headers = {"Content-Type": "application/json"} + if csrf: + headers["x-csrf-token"] = csrf + req = urllib.request.Request(base + path, data=None if value is None else json.dumps(value).encode(), headers=headers, method=method) + with opener.open(req, timeout=3) as response: + return json.load(response) + + csrf = request("/api/login", {"password": password})["csrf"] + if not restart: + snapshot = request("/api/snapshot") + assert snapshot["teams"] == [] and snapshot["services"] == [] + assert snapshot["flag_protection"] is None + request("/api/teams/local", {"id": "local", "name": "Local", "address": "127.0.0.1"}, "PUT", csrf) + service = {"id": "echo", "name": "Echo", "listen": f"127.0.0.1:{proxy_port}", "upstream_port": upstream.getsockname()[1], "primary": "local", "replicas": [], "enabled": True, "auto_switch": False} + request("/api/services/echo", service, "PUT", csrf) + payload = bytes(range(256)) * 1024 + with socket.create_connection(("127.0.0.1", proxy_port), timeout=5) as connection: + connection.sendall(payload) + connection.shutdown(socket.SHUT_WR) + reply = bytearray() + while part := connection.recv(65536): + reply.extend(part) + thread.join(5) + assert not thread.is_alive() + assert reply == received == payload + snapshot = request("/api/snapshot") + assert snapshot["services"][0]["flag_protection"] is False + assert snapshot["services"][0]["enabled"] is True + assert password_path.read_text() == password + assert config.read_bytes() == original_config + finally: + process.terminate() + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + assert process.returncode == 0 + upstream.close() +print("Package OK: first start, private password, empty topology, login, API persistence/restart, exact 256KiB TCP half-close, flag substitution off.") diff --git a/task_plan.md b/task_plan.md index 618f22e..063dbce 100644 --- a/task_plan.md +++ b/task_plan.md @@ -44,3 +44,8 @@ One native executable with an async TCP engine and embedded web UI. No Python/ru Design limits: stateful tokenization, not stateless same-length AEAD; XChaCha20-Poly1305 protects mappings on disk. Raw visible fixed-shape ASCII only; no TLS/compression/framing adapter. Incomplete possible flags may hold a protocol tail; bounded timeout closes rather than leaking. All clients get reverse translation, so not an authorization boundary. Disabled by default; no real game hosts/flags used. Flag implementation issues recorded: cargo-add combined feature syntax rejected and corrected with separate calls; first module write preceded async mkdir and was retried after explicit directory creation; first test compile warned about unused Arc import, removed before Clippy gate. No failing runtime regressions so far. Final durability review upgraded SQLite synchronous from FULL to EXTRA; final native/Orb gates rerun on that source. + +## Portable Linux release — 2026-10-23 +- [complete] Add static x86_64-unknown-linux-musl packaging, first-run launcher and operator quickstart. +- [complete] Add pinned GitHub Actions build/test/artifact and tag-triggered release workflow. +- [in progress] Validate actual archive on Linux, publish private GitHub release and verify downloaded asset.