Bind panel on all IPv4 interfaces and support TOML password

This commit is contained in:
mrvasil 2026-10-24 00:20:12 +03:00
parent e7e33c1a32
commit 3674534985
14 changed files with 141 additions and 60 deletions

View File

@ -53,4 +53,4 @@ jobs:
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.'
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 mirror.toml with listen=0.0.0.0:6700 and password=mirror-tool-change-me. Change the password in the config before shared-network use. Existing configs are preserved. No Rust, Python, Node, glibc or external SQLite required. See included README.md. Flag substitution is disabled by default.'

2
Cargo.lock generated
View File

@ -921,7 +921,7 @@ dependencies = [
[[package]]
name = "mirror-tool"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",

View File

@ -1,6 +1,6 @@
[package]
name = "mirror-tool"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
rust-version = "1.85"
description = "Async TCP traffic mirror and ForcAD-aware upstream router"

View File

@ -18,11 +18,19 @@ cd mirror-tool-linux-x64
```
Статический 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).
Docker или отдельной SQLite. Скрипт создаст `mirror.toml` (0600) с настройками:
```toml
[web]
listen = "0.0.0.0:6700"
password = "mirror-tool-change-me"
state_file = "data/state.json"
```
Панель — `http://IP-СЕРВЕРА:6700`. Пароль меняется в TOML, затем процесс нужно
перезапустить. Перед использованием в общей сети замените стандартный пароль.
Повторный запуск сохраняет конфиг и состояние. Команды/сервисы добавляются через
панель; подмена флагов выключена. [Инструкция архива](deploy/QUICKSTART.md).
GitHub Actions проверяет musl-тесты, статическую линковку и запуск архива с TCP,
API и перезапуском; push тега `v*` публикует архив и SHA256SUMS в Releases.
@ -36,13 +44,12 @@ API и перезапуском; push тега `v*` публикует архи
cargo build --release --locked
cp mirror.example.toml mirror.toml
# Отредактируйте команды, адреса и порты. Пример использует тестовую сеть RFC 5737.
read -s MIRROR_PASSWORD
export MIRROR_PASSWORD
# Укажите свой пароль в [web] password в mirror.toml.
./target/release/mirror-tool --config mirror.toml check
./target/release/mirror-tool --config mirror.toml
```
Пароль должен содержать минимум 12 символов. Панель по умолчанию доступна на `http://127.0.0.1:6700`. Для удалённого доступа используйте VPN/SSH-туннель либо HTTPS reverse proxy; при HTTPS установите `secure_cookie = true`. TLS для управляющей панели внутри бинарника не реализован. Переменные окружения с паролем и конфигурацию игры не коммитьте.
Пароль должен содержать минимум 12 символов. Панель по умолчанию слушает `0.0.0.0:6700`, адрес в браузере — `http://IP-СЕРВЕРА:6700`. При HTTPS установите `secure_cookie = true`. TLS для управляющей панели внутри бинарника не реализован. Переменные окружения с паролем и конфигурацию игры не коммитьте.
Панель сохраняет оформление старого `mirror_tool`: горизонтальные колонки сервисов, список команд, кружок primary, выбор реплики нажатием на строку, робот авто режима, открытие сервиса и переключатель запуска. При выборе нового primary прежний становится репликой. Кнопка «+» открывает прежнюю форму: название, порт и необязательный IPv4/IPv6. Предварительно создавать команду для произвольного IP не нужно. У custom-прокси остаются поле IP и удаление в самой колонке.
@ -115,7 +122,7 @@ Stop отключает приём новых соединений, сохран
Запись состояния: временный файл на той же ФС → fsync → atomic rename; права `0600`. Сбой сохранения вызывает попытку отката runtime. Публикация topology сериализована с ручными и автоматическими изменениями. Runtime и ФС не образуют распределённую транзакцию: клиент, принятый в коротком окне до неудачного сохранения, может сохранить временно применённый маршрут. Необходимость отката выводится как ошибка. После допуска к транзакции она завершается независимо от HTTP-соединения: закрытие вкладки не оставляет сохранение/очистку наполовину. Если ответ потерян, обновите snapshot перед повтором — изменение могло сохраниться.
Старый `config.py` не выполняется и не импортируется. Перенесите `TEAMS` в `[[teams]]`, `TASKS` в `[[services]]`, ForcAD URL в `[scoreboard]`, `MAIN_TEAM_IP` в верхнеуровневый `main_team_ip`. `PASSWORD` заменён переменной окружения, `SECRET_KEY` не нужен: сервер хранит случайные сессии в памяти. Старые cookie после перезапуска недействительны. API описан в [docs/api.md](docs/api.md).
Старый `config.py` не выполняется и не импортируется. Перенесите `TEAMS` в `[[teams]]`, `TASKS` в `[[services]]`, ForcAD URL в `[scoreboard]`, `MAIN_TEAM_IP` в верхнеуровневый `main_team_ip`. `PASSWORD` переносится в `[web] password` (если поле отсутствует, используется `MIRROR_PASSWORD`), `SECRET_KEY` не нужен: сервер хранит случайные сессии в памяти. Старые cookie после перезапуска недействительны. API описан в [docs/api.md](docs/api.md).
Для Linux есть [systemd unit](deploy/mirror-tool.service): создайте отдельного пользователя `mirror-tool`, разместите бинарник в `/usr/local/bin/`, конфиг и закрытый `mirror.env` в `/etc/mirror-tool/`; в TOML задайте `state_file = "/var/lib/mirror-tool/state.json"`. Unit даёт только CAP_NET_BIND_SERVICE для портов ниже 1024; при ненужности удалите capability. При увеличении drain timeout увеличьте `TimeoutStopSec`. Unit поставляется как шаблон, автоматически не устанавливается.

View File

@ -50,3 +50,6 @@ User explicitly requires optional substitution, default off. Implemented per-ser
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.
Portable release completed: v0.1.0 at source f264f8a, private GitHub Release includes static musl Linux x64 tar.gz + SHA256SUMS. CI40011983417 succeeded: fmt/Clippy,50 musl tests, static linkage check, release build and packaged first-run/login/API/restart/256KiB TCP smoke. Downloaded release independently passed SHA256, full package smoke in Orb Ubuntu amd64 and launcher/HTTP/graceful-stop in isolated Alpine3.22 without glibc. Native49 tests/fmt/Clippy also passed. No runtime/compiler/database dependencies beyond normal shell utilities for run.sh. First-run config is empty; real targets still require operator setup. Live demo unchanged. See docs/linux-release-verification.md.
## v0.1.1 startup correction — in progress
User explicitly requires panel bind0.0.0.0 and default password in TOML. Native default, example and first-run launcher now bind0.0.0.0:6700; launcher/example explicitly set web.password to a public placeholder. Explicit config password wins; absent field uses legacy environment, and run.sh preserves v0.1.0 password-file fallback. Existing configs are never overwritten. WebConfig password is excluded from serialization and redacted Debug. Native50 tests/fmt/Clippy passed; tag release/package verification pending.

View File

@ -6,24 +6,26 @@ cd mirror-tool-linux-x64
./run.sh
```
Панель: http://127.0.0.1:6700. В другом терминале из этой папки:
Панель слушает все IPv4-интерфейсы: **http://IP-СЕРВЕРА:6700**.
Пароль по умолчанию: **mirror-tool-change-me**. Настройки — в `mirror.toml`:
```sh
cat data/panel-password
```toml
[web]
listen = "0.0.0.0:6700"
password = "mirror-tool-change-me"
state_file = "data/state.json"
```
Пароль генерируется случайно при первом запуске и сохраняется с правами0600.
Можно передать собственный пароль через MIRROR_PASSWORD (не менее12 символов).
Пароль не выводится в логи. Запускать от root не требуется для портов выше1023.
Измените `password` и перезапустите процесс. Минимум12 символов; перед
использованием в общей сети замените стандартное значение. Созданный конфиг
имеет права0600. Пароль не выводится в логи/API. Если `password` не указан,
используется переменная MIRROR_PASSWORD; старый run.sh-пароль из
`data/panel-password` также подхватывается. Явный `web.password` имеет приоритет.
Запускать от root не требуется для портов выше1023.
Для удалённого сервера откройте туннель со своего компьютера:
```sh
ssh -N -L 6700:127.0.0.1:6700 user@server
```
Затем откройте http://127.0.0.1:6700 на своём компьютере. Панель по умолчанию
доступна только локально; не публикуйте её HTTP-порт в игровой сети.
Обновление с v0.1.0 не перезаписывает ваш конфиг: для доступа по IP измените
существующее `[web] listen` на `"0.0.0.0:6700"` и задайте `password` в той же секции.
Для локального доступа можно явно оставить `"127.0.0.1:6700"`.
Первый запуск создаёт пустой mirror.toml. Добавьте команды и прокси в панели;
нажмите название сервиса для настройки слушателя и upstream-порта. Выберите
@ -46,7 +48,7 @@ mirror.example.toml содержит полный пример. Для ForcAD д
Бинарник статический (musl), базовая архитектура x86-64, без требования AVX.
Не нужны glibc, Rust, Python, Node, Docker или отдельная SQLite. Для run.sh нужны
обычные /bin/sh, uname, dirname, mkdir, od, tr, cat и /dev/urandom. Для распаковки
обычные /bin/sh, uname, dirname, mkdir и cat. Для распаковки
нужен tar с gzip. Linux kernel3.2+ — базовый минимум target Rust; конкретная старая
система не считается проверенной только по номеру ядра. Ограничения контейнера,
seccomp, права, доступная память, файловые дескрипторы и сеть всё равно действуют.

View File

@ -13,22 +13,16 @@ if [ ! -e mirror.toml ]; then
# 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"
listen = "0.0.0.0:6700"
password = "mirror-tool-change-me"
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
# Compatibility with v0.1.0: explicit web.password takes priority over this.
if [ -z "${MIRROR_PASSWORD:-}" ] && [ -f data/panel-password ]; then
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).'
echo 'Config/password: mirror.toml [web]. Default listener: 0.0.0.0:6700; open http://SERVER-IP:6700.'
exec ./mirror-tool --config mirror.toml "$@"

View File

@ -3,6 +3,10 @@
JSON API, UTF-8. Authenticated sessions use an HttpOnly SameSite=Strict cookie. Mutating authenticated requests require `X-CSRF-Token` from login or session response. Browser requests must be same-origin. No secrets returned in snapshots. Application-handled failures return `{ "error": "human readable explanation" }` with non-2xx status. Framework routing errors (for example 405) may have a plain-text body; clients must tolerate this. A snapshot is the source of truth; a 2xx response confirms completion. After admission under the serialized operation lock, topology mutations run independently of the HTTP connection through commit/rollback and cleanup. A lost response does not imply an uncommitted change: refresh the snapshot before retrying. Requests cancelled while waiting for admission make no change. Shutdown rejects new admissions and waits for admitted work within the documented process deadline.
## Routes
Panel credentials come from TOML `web.password` when present; otherwise the
configured `web.password_env` is used. They are never exposed in snapshots or
serialized WebConfig diagnostics. Changing the password requires process restart.
- `GET /` — embedded UI shell (login shown until session validated).
- `GET /licenses.txt` — licenses and attribution for the bundled Bootstrap/Font Awesome assets.
- `GET /healthz` — process readiness `{status:"ok"}` (not upstream checker health).

View File

@ -57,8 +57,10 @@ state-файл в резервную копию и запустите проце
сервиса относится к своей команде (`main_team_ip`), а не к выбранному upstream.
Серый статус означает отсутствие свежих данных, а не успех checker.
Пароль обычного процесса задаётся в `MIRROR_PASSWORD` (минимум 12 символов),
не в TOML. Тестовый fixture для настоящей игры не используйте; запускайте бинарник
Пароль задаётся в `[web] password` в TOML (минимум12 символов). Стартовый
шаблон использует `mirror-tool-change-me`, измените его перед игрой. Если поле
отсутствует, используется `MIRROR_PASSWORD`. По умолчанию панель слушает
`0.0.0.0:6700`, в браузере открывайте `http://IP-СЕРВЕРА:6700`. Тестовый fixture для настоящей игры не используйте; запускайте бинарник
напрямую с собственным конфигом и паролем. Реальная игра этой проверкой не затрагивалась.
## Повторяемое локальное демо

View File

@ -1,10 +1,11 @@
# Copy to mirror.toml. Set MIRROR_PASSWORD in your environment (12+ characters).
# Copy to mirror.toml. Change web.password before using on a shared network.
# Bind the panel to a VPN interface or put it behind HTTPS when used remotely.
# Optional: own team's checker status in the top strip, as in the legacy UI.
# main_team_ip = "192.0.2.10"
[web]
listen = "127.0.0.1:6700"
listen = "0.0.0.0:6700"
password = "mirror-tool-change-me"
password_env = "MIRROR_PASSWORD"
state_file = "data/state.json"
session_ttl_seconds = 43200

View File

@ -29,13 +29,17 @@ with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
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"
password = "mirror-tool-change-me"
assert f'password = "{password}"' in config.read_text()
assert 'listen = "0.0.0.0:6700"' in config.read_text()
assert stat.S_IMODE(config.stat().st_mode) == 0o600
assert not (package / "data/panel-password").exists()
# Explicit config must win over a legacy password file during upgrades.
legacy_password = "legacy-synthetic-passphrase"
(package / "data/panel-password").write_text(legacy_password)
web_port = free_port()
config.write_text(config.read_text().replace("127.0.0.1:6700", f"127.0.0.1:{web_port}"))
config.write_text(config.read_text().replace("0.0.0.0:6700", f"0.0.0.0:{web_port}"))
original_config = config.read_bytes()
base = f"http://127.0.0.1:{web_port}"
upstream = socket.socket()
@ -55,7 +59,8 @@ with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
thread = threading.Thread(target=echo, daemon=True)
thread.start()
proxy_port = free_port()
for restart in (False, True):
for phase in range(3):
restart = phase > 0
with (root / "server.log").open("wb") as log:
process = subprocess.Popen([str(package / "run.sh")], cwd=root, env=env, stdout=log, stderr=log)
try:
@ -71,6 +76,7 @@ with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
time.sleep(0.1)
else:
raise RuntimeError("packaged server did not become ready")
assert any(line.split()[1] == f"00000000:{web_port:04X}" and line.split()[3] == "0A" for line in Path("/proc/net/tcp").read_text().splitlines()[1:])
def request(path, value=None, method=None, csrf=None):
headers = {"Content-Type": "application/json"}
@ -99,9 +105,10 @@ with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
assert not thread.is_alive()
assert reply == received == payload
snapshot = request("/api/snapshot")
assert password not in json.dumps(snapshot)
assert snapshot["services"][0]["flag_protection"] is False
assert snapshot["services"][0]["enabled"] is True
assert password_path.read_text() == password
assert (package / "data/panel-password").read_text() == legacy_password
assert config.read_bytes() == original_config
finally:
process.terminate()
@ -112,5 +119,14 @@ with tempfile.TemporaryDirectory(prefix="mirror package ") as temporary:
process.wait()
raise
assert process.returncode == 0
if not restart:
password = "updated-config-passphrase"
config.write_text(config.read_text().replace("mirror-tool-change-me", password))
original_config = config.read_bytes()
elif phase == 1:
# Old v0.1.0 config without web.password still uses the saved password.
config.write_text(config.read_text().replace(f'password = "{password}"\n', ""))
password = legacy_password
original_config = config.read_bytes()
upstream.close()
print("Package OK: first start, private password, empty topology, login, API persistence/restart, exact 256KiB TCP half-close, flag substitution off.")
print("Package OK: wildcard IPv4 listener, config password/change/restart, legacy-file precedence, empty topology, API persistence, exact 256KiB TCP half-close, flag substitution off.")

View File

@ -46,16 +46,20 @@ fn random_token() -> String {
}
impl Auth {
pub fn from_env(settings: &WebConfig) -> Result<Self> {
let password = std::env::var(&settings.password_env).map_err(|_| {
anyhow::anyhow!(
"set {} to a password with at least 12 characters",
settings.password_env
)
})?;
let password = settings
.password
.clone()
.map(Ok)
.unwrap_or_else(|| std::env::var(&settings.password_env))
.map_err(|_| {
anyhow::anyhow!(
"set web.password in TOML or {} to a password with at least 12 characters",
settings.password_env
)
})?;
ensure!(
password.len() >= 12,
"{} must contain at least 12 characters",
settings.password_env
"panel password must contain at least 12 characters"
);
Ok(Self::new(settings.clone(), &password))
}
@ -181,6 +185,30 @@ pub fn same_origin(headers: &HeaderMap) -> bool {
mod tests {
use super::*;
#[test]
fn configured_password_authenticates_without_environment_and_is_redacted() {
let config: crate::config::Config = toml::from_str(
"[web]\npassword = 'configuration-test-passphrase'\npassword_env = 'MIRROR_UNUSED_TEST_ENV'",
).unwrap();
config.validate().unwrap();
let a = Auth::from_env(&config.web).unwrap();
let peer = "127.0.0.1".parse().unwrap();
assert!(a.login("configuration-test-passphrase", peer).is_ok());
assert!(matches!(
a.login("incorrect-password", peer),
Err(LoginError::Invalid)
));
assert!(!format!("{:?}", config.web).contains("configuration-test-passphrase"));
assert!(
!serde_json::to_string(&config.web)
.unwrap()
.contains("configuration-test-passphrase")
);
let mut invalid = config;
invalid.web.password = Some("short".into());
assert!(invalid.validate().is_err());
assert!(Auth::from_env(&invalid.web).is_err());
}
#[test]
fn session_csrf_and_revocation() {
let a = Auth::new(WebConfig::default(), "test-passphrase");
let (cookie, info) = a

View File

@ -19,16 +19,31 @@ pub struct Config {
pub services: Vec<ServiceConfig>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WebConfig {
pub listen: SocketAddr,
#[serde(skip_serializing)]
pub password: Option<String>,
pub password_env: String,
pub state_file: PathBuf,
pub session_ttl_seconds: u64,
pub secure_cookie: bool,
}
impl std::fmt::Debug for WebConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebConfig")
.field("listen", &self.listen)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.field("password_env", &self.password_env)
.field("state_file", &self.state_file)
.field("session_ttl_seconds", &self.session_ttl_seconds)
.field("secure_cookie", &self.secure_cookie)
.finish()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkConfig {
@ -117,7 +132,8 @@ pub enum ReplicaPolicy {
impl Default for WebConfig {
fn default() -> Self {
Self {
listen: "127.0.0.1:6700".parse().unwrap(),
listen: "0.0.0.0:6700".parse().unwrap(),
password: None,
password_env: "MIRROR_PASSWORD".into(),
state_file: "data/state.json".into(),
session_ttl_seconds: 43200,
@ -192,6 +208,10 @@ impl Config {
}
pub fn validate(&self) -> Result<()> {
ensure!(
self.web.password.as_ref().is_none_or(|p| p.len() >= 12),
"web.password must contain at least 12 characters"
);
ensure!(
!self.web.password_env.is_empty(),
"password_env must name an environment variable"

View File

@ -49,3 +49,7 @@ Flag implementation issues recorded: cargo-add combined feature syntax rejected
- [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.
- [complete] Validate actual archive on Linux, publish private GitHub release and verify downloaded asset.
## v0.1.1 startup correction
- [complete] Wildcard IPv4 panel default and explicit TOML password, preserving existing configs/legacy auth.
- [in progress] Publish and validate static Linux release; package tests assert wildcard socket, config password/change/restart and legacy-file fallback.