fix: harden sing-box egress rollout

This commit is contained in:
2026-08-27 03:39:06 +05:00
parent adb3e849df
commit 8ac3b2b1ba
24 changed files with 228 additions and 48 deletions
+3 -1
View File
@@ -15,10 +15,12 @@ sing-box.
- Смена credential выполняется одной безопасной командой через stdin.
- Перед установкой candidate проверяется реальным `sing-box check`.
- Запись атомарна; после неуспешного restart/healthcheck выполняется rollback.
- URI, production config, state и backups имеют режим `0600`.
- Policy, URI, production config, state и backups имеют `root:root 0600`,
защищённые каталоги — `root:root 0700`.
- Неизвестные URI/policy-параметры отклоняются, а не игнорируются.
- systemd следит за desired state без постоянно работающего Python-процесса.
- Отдельный nftables guard блокирует прямой forwarding `eth1 -> eth0`.
- `sing-box.service` требует успешного запуска guard через package-managed drop-in.
## Источники состояния
+4
View File
@@ -1,5 +1,9 @@
vpn-egressctl (0.1.0) unstable; urgency=medium
* Initial release for sing-box 1.13.19.
* Require the nftables guard before starting sing-box.
* Enforce QUIC and gVisor build tags and remove the unsafe render command.
* Tighten URI parsing and protected filesystem diagnostics.
* Document local healthcheck and forwarded workload acceptance separately.
-- Flamy Studio <dev@flamy.studio> Thu, 27 Aug 2026 00:00:00 +0500
+1
View File
@@ -2,3 +2,4 @@ config/policy.json usr/share/vpn-egressctl
packaging/systemd/vpn-egress-guard.service usr/lib/systemd/system
packaging/systemd/vpn-egress-sync.service usr/lib/systemd/system
packaging/systemd/vpn-egress-sync.path usr/lib/systemd/system
packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf usr/lib/systemd/system/sing-box.service.d
+8 -3
View File
@@ -40,7 +40,7 @@ URI + policy
▼ ├─ last-good backup
no restart ├─ atomic replace
├─ systemctl restart
└─ bounded healthcheck
└─ bounded local connectivity healthcheck
fail ┴ success
│ │
@@ -66,11 +66,11 @@ Outbound сохраняет DNS hostname и содержит `bind_interface=eth
## Файловая модель
```text
/etc/vpn-egress/
/etc/vpn-egress/ root:root 0700
├── policy.json root:root 0600
└── hysteria2.uri root:root 0600
/var/lib/vpn-egress/
/var/lib/vpn-egress/ root:root 0700
├── state.json без секретов, 0600
├── last-good.json содержит secrets, 0600
└── backups/ ограниченная история, 0700/0600
@@ -86,6 +86,11 @@ Guard читает имена интерфейсов из того же policy
таблицу одной batch-транзакцией. Если новый ruleset некорректен, nft не оставляет
систему с частично заменённой таблицей.
Package-managed drop-in добавляет для `sing-box.service` зависимости
`Requires=` и `After=` от guard. Поэтому при совместном запуске ошибка guard
блокирует старт sing-box. Уже активный oneshot не является watchdog: ручное
удаление nftables-таблицы обнаруживает `doctor`, но не systemd dependency.
## Версионная граница
В коде существует только `renderer_1_13_19.py`. Renderer для 1.14 не является
+6 -2
View File
@@ -40,8 +40,9 @@ fallback rule 32768.
### `healthcheck`
По умолчанию выполняется HTTPS-запрос к Cloudflare trace и ожидается HTTP 200 с
маркером `ip=`. Этот запрос идёт после запуска sing-box и подтверждает не только
состояние systemd, но и рабочий data plane.
маркером `ip=`. Этот локальный post-activation connectivity healthcheck идёт
после запуска sing-box и проверяет состояние systemd и исходящую связность
самого шлюза. Он не заменяет acceptance-тест forwarding с workload за `eth1`.
`url: null` оставляет только проверку `systemctl is-active`. Это допустимо для
изолированного стенда, но слабее production-проверки.
@@ -64,6 +65,9 @@ fallback rule 32768.
1.13.19 используют разные виды certificate hash. `ech` будет добавлен только
после доказанного преобразования формата config list.
Специальные символы в auth должны быть percent-encoded: сырой `@` отклоняется,
а `%40` декодируется в `@`.
## Bandwidth
`up_mbps=50` и `down_mbps=200` являются локальной политикой и намеренно не
+29 -2
View File
@@ -56,8 +56,11 @@ systemctl status vpn-egress-guard.service --no-pager
nft list table inet vpn_egress_guard
```
Новый unit имеет `Before=sing-box.service`; это закрывает boot window, который
существовал у старого `After=sing-box.service`.
Новый unit имеет `Before=sing-box.service`, а package-managed drop-in для
`sing-box.service` добавляет `Requires=` и `After=` от guard. Это закрывает boot
window и блокирует запуск sing-box, если совместно запущенный guard завершился с
ошибкой. Зависимость не является watchdog для ручного удаления nftables-таблицы;
текущее runtime-состояние проверяет `vpn-egressctl doctor`.
## 5. Dry run и первый import
@@ -116,7 +119,31 @@ ip -4 rule show
ip -4 route show table 2022
nft list table inet vpn_egress_guard
nft list table inet sing-box
systemctl show sing-box.service -p Requires -p After
```
В `config.json` и nftables не должно быть ни старого `185.156.108.141`, ни
текущего `85.208.119.160`.
С настоящего workload в `10.30.0.0/24` обязательно проверить:
1. DNS через шлюз;
2. TCP и UDP через VPN;
3. HTTPS-запрос к контролируемому endpoint или Cloudflare trace;
4. соответствие наблюдаемого public IP ожидаемому VPN egress;
5. повтор тех же проверок после restart sing-box.
Anti-leak проверяется только из консоли canary/staging, чтобы не потерять
удалённый доступ к production:
```bash
# На шлюзе:
systemctl stop sing-box.service
# На workload за eth1: запрос наружу должен завершиться ошибкой, а не пойти напрямую.
curl --fail --connect-timeout 5 https://www.cloudflare.com/cdn-cgi/trace
# На шлюзе:
systemctl start sing-box.service
vpn-egressctl doctor
```
+4 -3
View File
@@ -48,9 +48,10 @@ vpn-egressctl rollback
перезапускает сервис и выполняет тот же healthcheck. Повторный rollback возвращает
конфигурацию, которая была активна до первого rollback.
После ручного rollback desired URI остаётся прежним, поэтому status показывает
drift. Перед включением нового `sync` нужно либо исправить URI, либо осознанно
вернуть desired state.
После ручного rollback desired URI остаётся прежним. `status` показывает
`status=rolled_back`, а `doctor` сообщает `config-drift`, потому что production
config отличается от заново отрендеренного desired state. Перед новым `sync`
нужно либо исправить URI, либо осознанно вернуть desired state.
## Реакция systemd.path
+10 -6
View File
@@ -10,7 +10,8 @@ generated config и все backups.
- Ошибки parser не включают исходное значение.
- diff заменяет secret values на `<REDACTED>`.
- state хранит только SHA-256 source/config и безопасный endpoint label.
- URI/config/backups имеют `0600`, каталоги — `0700`.
- Policy/URI/config/state/backups имеют `root:root 0600`, каталоги —
`root:root 0700`; `doctor` проверяет этот ограниченный набор объектов.
- subprocess вызывается массивом аргументов без shell.
После попадания действующего URI в чат, issue, shell history или journal оба
@@ -22,7 +23,7 @@ credential следует перевыпустить.
- policy schema;
- URI syntax и поддерживаемые параметры;
- точная версия и `with_quic`;
- точная версия, `with_quic` и `with_gvisor` для `stack=mixed`;
- deterministic candidate;
- `sing-box check`.
@@ -35,7 +36,9 @@ credential следует перевыпустить.
на `eth0`. Она не принадлежит sing-box и остаётся отдельной от динамической
таблицы `inet sing-box`.
Guard запускается до sing-box. Остановка или удаление guard unit является
Guard запускается до sing-box. Drop-in `sing-box.service` одновременно задаёт
requirement и ordering dependency: ошибка запуска guard блокирует sing-box.
Остановка, удаление guard unit или ручное изменение его nftables-таблицы является
security-sensitive операцией и не выполняется CLI автоматически.
Имена интерфейсов поступают из уже провалидированного policy, subprocess не
@@ -43,9 +46,10 @@ security-sensitive операцией и не выполняется CLI авт
## Healthcheck
HTTPS healthcheck подтверждает data plane, но раскрывает проверочному endpoint
факт обращения с VPN egress IP. URL можно заменить внутренним контролируемым
endpoint. Отключение URL ослабляет проверку до состояния systemd.
HTTPS healthcheck подтверждает локальную post-activation связность шлюза, но не
весь forwarded path `eth1 -> TUN -> HY2`. Он раскрывает проверочному endpoint факт
обращения с VPN egress IP. URL можно заменить внутренним контролируемым endpoint.
Отключение URL ослабляет проверку до состояния systemd.
## Ограничения URI 1.13.19
+18 -4
View File
@@ -6,7 +6,7 @@
2. Failure injection: candidate rejection, restart failure, URI/config rollback,
idempotency и manual rollback swap.
3. Real binary: `sing-box 1.13.19 check` на Linux и schema/format validation на Windows.
4. Privileged Linux: TUN, nftables, systemd и HTTP data-plane healthcheck.
4. Privileged Linux: TUN, nftables, systemd и локальный HTTP connectivity healthcheck.
5. Canary: DNS A change без endpoint CIDR и credential rotation.
6. Reboot: guard ordering, persisted config и path watcher.
@@ -22,9 +22,9 @@ make check
SING_BOX_1_13_19=/usr/bin/sing-box make test
```
Он предварительно проверяет exact version и `with_quic`. Windows-бинарник не
может создать Linux auto-redirect и поэтому выполняет `format` полной схемы;
обязательный `check` остаётся в Linux CI/Incus.
Он предварительно проверяет exact version, `with_quic` и `with_gvisor`.
Windows-бинарник не может создать Linux auto-redirect и поэтому выполняет
`format` полной схемы; обязательный `check` остаётся в Linux CI/Incus.
## Privileged acceptance
@@ -39,4 +39,18 @@ SING_BOX_1_13_19=/usr/bin/sing-box make test
7. изменить A-запись endpoint, не меняя URI/config;
8. перезагрузить контейнер и повторить doctor.
Отдельный dependency failure test выполняется только в disposable Incus:
1. остановить sing-box и guard;
2. временно задать в test policy отсутствующий upstream interface;
3. убедиться, что `systemctl start sing-box.service` завершается ошибкой из-за
неуспешного guard;
4. восстановить policy и подтвердить успешный совместный запуск;
5. проверить drop-in командой `systemd-analyze verify` и содержимое собранного
Debian-пакета через `dpkg-deb -c`.
Локальный HTTPS healthcheck не заменяет запросы DNS/TCP/UDP с реального workload
за `eth1`. При остановленном sing-box такой workload не должен получить прямой
доступ через `eth0`.
Production A-запись не используется для эксперимента: нужен staging hostname.
+2 -1
View File
@@ -48,7 +48,8 @@ timestamped backup из консоли контейнера, затем `sing-bo
Проверить DNS, handshake Hysteria2, доступность health URL и nftables. Временно
ставить `url: null` на production нельзя без отдельного решения: это скрывает
неработающий data plane.
неработающую локальную post-activation связность. Даже успешный healthcheck не
заменяет отдельную проверку forwarded path с workload за `eth1`.
## Watcher failed
@@ -0,0 +1,3 @@
[Unit]
Requires=vpn-egress-guard.service
After=vpn-egress-guard.service
+1 -7
View File
@@ -9,7 +9,6 @@ from pathlib import Path
from .doctor import Doctor
from .errors import ApplyError, UnsupportedVersionError, ValidationError, VpnEgressError
from .fsutil import atomic_write
from .guard import apply_guard
from .policy import load_policy
from .transaction import Controller
@@ -32,8 +31,6 @@ def _parser() -> argparse.ArgumentParser:
commands.add_parser("check", help="validate source, renderer and generated config")
commands.add_parser("diff", help="show a redacted desired-state diff")
render = commands.add_parser("render", help="write a validated generated config to a protected file")
render.add_argument("--output", required=True, help="output path; stdout is deliberately unsupported")
commands.add_parser("sync", help="reconcile the current URI and policy")
status = commands.add_parser("status", help="show safe state summary")
status.add_argument("--json", action="store_true")
@@ -82,9 +79,6 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == "diff":
changes = controller.diff()
print("\n".join(changes) if changes else "No changes.")
elif args.command == "render":
atomic_write(args.output, controller.render())
print(f"Validated configuration written to {args.output}")
elif args.command == "sync":
changed = controller.sync()
print("Configuration applied." if changed else "Desired state is already installed.")
@@ -95,7 +89,7 @@ def main(argv: list[str] | None = None) -> int:
else:
_print_status(state)
elif args.command == "doctor":
doctor = Doctor(policy)
doctor = Doctor(policy, policy_path=args.policy)
checks = doctor.run()
if args.json:
print(doctor.as_json())
+56 -9
View File
@@ -31,10 +31,12 @@ class Doctor:
self,
policy: Policy,
*,
policy_path: str | None = None,
runner: RunFunction = subprocess.run,
resolver: Callable[..., Any] = socket.getaddrinfo,
) -> None:
self.policy = policy
self.policy_path = policy_path
self.runner = runner
self.resolver = resolver
self.checks: list[Check] = []
@@ -60,16 +62,48 @@ class Doctor:
else:
self._add("OK", name, "runtime state matches policy")
def _permissions(self, path: str, expected: int, required: bool = True) -> None:
def _permissions(
self,
path: str,
expected: int,
required: bool = True,
*,
directory: bool = False,
) -> None:
target = Path(path)
if not target.exists():
self._add("ERROR" if required else "WARN", f"permissions:{path}", "file does not exist")
try:
metadata = target.stat()
except FileNotFoundError:
object_name = "directory" if directory else "file"
self._add(
"ERROR" if required else "WARN",
f"permissions:{path}",
f"{object_name} does not exist",
)
return
actual = stat.S_IMODE(target.stat().st_mode)
except OSError:
self._add("ERROR", f"permissions:{path}", "cannot inspect filesystem metadata")
return
expected_type = "directory" if directory else "regular file"
actual_type_ok = stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode)
if not actual_type_ok:
self._add("ERROR", f"permissions:{path}", f"object is not a {expected_type}")
return
actual = stat.S_IMODE(metadata.st_mode)
problems: list[str] = []
if actual != expected:
self._add("ERROR", f"permissions:{path}", f"mode is {actual:04o}, expected {expected:04o}")
else:
self._add("OK", f"permissions:{path}", f"mode is {actual:04o}")
problems.append(f"mode is {actual:04o}, expected {expected:04o}")
if os.name == "posix" and (metadata.st_uid != 0 or metadata.st_gid != 0):
problems.append(
f"owner is {metadata.st_uid}:{metadata.st_gid}, expected 0:0"
)
if problems:
self._add("ERROR", f"permissions:{path}", "; ".join(problems))
return
owner = " and owner is 0:0" if os.name == "posix" else ""
self._add("OK", f"permissions:{path}", f"mode is {actual:04o}{owner}")
def run(self) -> list[Check]:
if os.name == "posix" and os.geteuid() != 0:
@@ -80,7 +114,7 @@ class Doctor:
try:
version = probe_version(self.policy.sing_box.binary, self.runner)
require_supported(version, self.policy.sing_box.required_version)
self._add("OK", "sing-box-version", f"exact version {version.version} with QUIC")
self._add("OK", "sing-box-version", f"exact version {version.version} with QUIC and gVisor")
except VpnEgressError as exc:
self._add("ERROR", "sing-box-version", str(exc))
@@ -97,10 +131,23 @@ class Doctor:
except (OSError, ValidationError) as exc:
self._add("ERROR", "hysteria2-uri", str(exc))
if self.policy_path is not None:
self._permissions(self.policy_path, 0o600)
self._permissions(self.policy.runtime.uri_path, 0o600)
self._permissions(self.policy.sing_box.config_path, 0o600)
state_path = Path(self.policy.runtime.state_dir) / "state.json"
uri_dir = Path(self.policy.runtime.uri_path).parent
state_dir = Path(self.policy.runtime.state_dir)
backup_dir = state_dir / "backups"
state_path = state_dir / "state.json"
last_good_path = state_dir / "last-good.json"
self._permissions(str(uri_dir), 0o700, directory=True)
self._permissions(str(state_dir), 0o700, required=False, directory=True)
self._permissions(str(backup_dir), 0o700, required=False, directory=True)
self._permissions(str(state_path), 0o600, required=False)
self._permissions(str(last_good_path), 0o600, required=False)
if backup_dir.is_dir():
for backup in sorted(backup_dir.glob("config.*.json")):
self._permissions(str(backup), 0o600)
if endpoint is not None:
try:
+1 -8
View File
@@ -85,14 +85,7 @@ def render_config(policy: Policy, endpoint: Hy2Endpoint) -> dict[str, Any]:
"route_exclude_address": list(network.route_exclude_address),
}
],
"outbounds": [
outbound,
{
"type": "direct",
"tag": "direct",
"bind_interface": network.upstream_interface,
},
],
"outbounds": [outbound],
"route": {
"auto_detect_interface": True,
"default_domain_resolver": {
+2
View File
@@ -122,6 +122,8 @@ def parse_hysteria2_uri(raw_uri: str) -> Hy2Endpoint:
raw_auth, at, endpoint_authority = authority.rpartition("@")
if not at:
raise ValidationError("Missing Hysteria2 authentication data")
if "@" in raw_auth:
raise ValidationError("Special characters in Hysteria2 authentication data must be percent-encoded")
password = _decode(raw_auth, "authentication data")
if not password:
raise ValidationError("Missing Hysteria2 authentication data")
+4
View File
@@ -58,3 +58,7 @@ def require_supported(version: SingBoxVersion, required: str = "1.13.19") -> Non
raise UnsupportedVersionError("sing-box must be a Linux build")
if "with_quic" not in version.tags:
raise UnsupportedVersionError("sing-box build does not include with_quic")
if "with_gvisor" not in version.tags:
raise UnsupportedVersionError(
"sing-box build does not include with_gvisor required by TUN stack=mixed"
)
+2 -2
View File
@@ -12,9 +12,9 @@ class CliTests(unittest.TestCase):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["import", "hysteria2://secret@example.com"])
def test_render_requires_output(self) -> None:
def test_render_command_is_not_public(self) -> None:
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
_parser().parse_args(["render"])
_parser().parse_args(["render", "--output", "/etc/sing-box/config.json"])
def test_all_commands_parse(self) -> None:
for command in ("check", "diff", "sync", "status", "doctor", "rollback"):
+32
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import json
import stat
import subprocess
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from vpn_egressctl.doctor import Doctor
from vpn_egressctl.renderer_1_13_19 import render_bytes
@@ -58,6 +61,35 @@ class DoctorTests(unittest.TestCase):
selected = [check for check in checks if check.name == "tls-insecure"]
self.assertEqual(selected[0].level, "WARN")
def test_permissions_reject_non_root_owner_on_posix(self) -> None:
with tempfile.TemporaryDirectory() as directory:
doctor = Doctor(make_policy(Path(directory)))
metadata = SimpleNamespace(
st_mode=stat.S_IFREG | 0o600,
st_uid=1000,
st_gid=1000,
)
with (
mock.patch("vpn_egressctl.doctor.Path.stat", return_value=metadata),
mock.patch("vpn_egressctl.doctor.os.name", "posix"),
):
doctor._permissions("/protected/file", 0o600)
self.assertEqual(doctor.checks[-1].level, "ERROR")
self.assertIn("expected 0:0", doctor.checks[-1].message)
def test_permissions_require_expected_object_type(self) -> None:
with tempfile.TemporaryDirectory() as directory:
doctor = Doctor(make_policy(Path(directory)))
metadata = SimpleNamespace(
st_mode=stat.S_IFDIR | 0o700,
st_uid=0,
st_gid=0,
)
with mock.patch("vpn_egressctl.doctor.Path.stat", return_value=metadata):
doctor._permissions("/protected/file", 0o600)
self.assertEqual(doctor.checks[-1].level, "ERROR")
self.assertIn("regular file", doctor.checks[-1].message)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class PackagingTests(unittest.TestCase):
def test_sing_box_drop_in_requires_guard(self) -> None:
drop_in = ROOT / "packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf"
text = drop_in.read_text(encoding="utf-8")
self.assertIn("Requires=vpn-egress-guard.service", text)
self.assertIn("After=vpn-egress-guard.service", text)
def test_debian_package_installs_sing_box_drop_in(self) -> None:
install = (ROOT / "debian/install").read_text(encoding="utf-8")
self.assertIn(
"packaging/systemd/sing-box.service.d/10-vpn-egress-guard.conf "
"usr/lib/systemd/system/sing-box.service.d",
install,
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -20,6 +20,7 @@ class RealSingBoxIntegrationTests(unittest.TestCase):
version = probe_version(binary)
self.assertEqual(version.version, "1.13.19")
self.assertIn("with_quic", version.tags)
self.assertIn("with_gvisor", version.tags)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
policy = make_policy(root)
+1
View File
@@ -31,6 +31,7 @@ class RendererTests(unittest.TestCase):
self.assertEqual(outbound["password"], "AUTH")
self.assertEqual(outbound["obfs"]["password"], "OBFS")
self.assertEqual(outbound["bind_interface"], "eth0")
self.assertEqual([item["tag"] for item in config["outbounds"]], ["hy2-out"])
self.assertEqual(config["dns"]["servers"][1]["detour"], "hy2-out")
self.assertEqual(config["route"]["final"], "hy2-out")
self.assertNotIn("185.156.108.141", render_bytes(policy, endpoint).decode())
+3
View File
@@ -116,6 +116,9 @@ class TransactionTests(unittest.TestCase):
self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous)
self.assertEqual(controller.last_good_path.read_bytes(), current)
self.assertEqual(json.loads(controller.state_path.read_text())["status"], "rolled_back")
status = controller.status()
self.assertEqual(status["status"], "rolled_back")
self.assertFalse(status["source_drift"])
def test_diff_never_contains_secrets(self) -> None:
with tempfile.TemporaryDirectory() as directory:
+5
View File
@@ -50,6 +50,10 @@ class UriParserTests(unittest.TestCase):
endpoint = parse_hysteria2_uri("hysteria2://alice%3Acorrect%20horse@example.com")
self.assertEqual(endpoint.password, "alice:correct horse")
def test_percent_encoded_at_is_preserved(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://alice%40example@example.com")
self.assertEqual(endpoint.password, "alice@example")
def test_unicode_host_is_idna(self) -> None:
endpoint = parse_hysteria2_uri("hysteria2://secret@пример.рф")
self.assertEqual(endpoint.server, "xn--e1afmkfd.xn--p1ai")
@@ -89,6 +93,7 @@ class UriParserTests(unittest.TestCase):
("hysteria2://secret@example.com?pinSHA256=x", "safely"),
("hysteria2://secret@example.com?ech=x", "safely"),
("hysteria2://sec%ZZret@example.com", "percent"),
("hysteria2://alice@example@server.example", "percent-encoded"),
("hysteria2://secret@example.com?sni=%FF", "UTF-8"),
]
for uri, marker in cases:
+5
View File
@@ -32,6 +32,11 @@ class VersionTests(unittest.TestCase):
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_missing_gvisor_rejected(self) -> None:
version = parse_version_output(VERSION_OUTPUT.replace("with_gvisor,", ""))
with self.assertRaises(UnsupportedVersionError):
require_supported(version)
def test_command_failure(self) -> None:
runner = FakeRunner()
runner.version = "garbage"