Package static Linux x64 releases with first-run launcher

This commit is contained in:
mrvasil 2026-10-23 22:29:51 +03:00
parent 8dcd1feaf1
commit f264f8a673
8 changed files with 323 additions and 0 deletions

56
.github/workflows/linux-release.yml vendored Normal file
View File

@ -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.'

View File

@ -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

View File

@ -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.

60
deploy/QUICKSTART.md Normal file
View File

@ -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.

34
deploy/run.sh Normal file
View File

@ -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 "$@"

25
scripts/package-linux.sh Normal file
View File

@ -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)

View File

@ -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.")

View File

@ -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.