diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92eedcf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Tests (Python ${{ matrix.python }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # 3.12: Ubuntu 24.04 nodes; 3.13: Debian 13 (no crypt/spwd modules). + python: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: web/requirements.txt + - run: pip install -r web/requirements.txt -r requirements-dev.txt + - run: make lint-python test + + shell: + name: Shell scripts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: make lint-shell diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..0b23b6a --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,45 @@ +name: Build ISO image + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + arch: + description: "Architecture" + type: choice + options: [amd64, arm64, both] + default: amd64 + +permissions: + contents: read + +jobs: + iso: + name: ISO ${{ matrix.arch }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: sudo apt-get update && sudo apt-get install -y xorriso + - uses: actions/cache@v4 + with: + path: .cache/iso + key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }} + restore-keys: ubuntu-iso-${{ matrix.arch }}- + - run: make iso ARCH=${{ matrix.arch }} + # Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit, + # so images are published as workflow artifacts. + - uses: actions/upload-artifact@v4 + with: + name: virtuality-iso-${{ matrix.arch }} + path: | + dist/*.iso + dist/*.sha256 + compression-level: 0 + retention-days: 30 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3be3cb6 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +PYTHON ?= python3 +ARCH ?= amd64 +ISO_ARGS ?= +SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh) + +.PHONY: help test lint lint-python lint-shell check iso clean + +help: + @echo "make test - run the web panel test suite" + @echo "make lint - pyflakes + shellcheck" + @echo "make check - lint + test" + @echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')" + @echo "make clean - remove build output" + +test: + $(PYTHON) -m pytest -q + +lint-python: + $(PYTHON) -m pyflakes web tests + +lint-shell: + @for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done + shellcheck -S error $(SHELL_SCRIPTS) + +lint: lint-python lint-shell + +check: lint test + +iso: + ./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS) + +clean: + rm -rf dist .pytest_cache + find . -name __pycache__ -type d -prune -exec rm -rf {} + diff --git a/README.md b/README.md index 3fca50a..8580905 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией. -> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. +> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. --- @@ -35,6 +35,19 @@ generic-arm64 # другая ARM64-плата --- +## Установка с ISO-образа + +Для установки на «голое» железо собирается загрузочный ISO на базе Ubuntu Server 24.04: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. + +```bash +make iso # dist/virtuality-<версия>-ubuntu-24.04-amd64.iso +make iso ARCH=arm64 +``` + +Подробно: [`docs/IMAGE.md`](docs/IMAGE.md). + +--- + ## Быстрая установка одной командой Одна команда работает и под `root`, и под обычным пользователем с `sudo`: @@ -186,6 +199,8 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal /var/lib/virtuality/backups # backups /var/log/virtuality # логи установки и диагностики /var/log/virtuality/operations # JSON/log фоновых операций +/var/lib/virtuality/config/web.env # настройки ноды: порт, пользователь, автообновление +/var/lib/virtuality/disk-images # загруженные образы дисков /etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality ``` @@ -375,6 +390,8 @@ sudo virsh pool-refresh virtuality-iso ## Обновление +Из web-панели: раздел `/update`. Вручную: + ```bash cd /opt/virtuality/source sudo git pull @@ -382,6 +399,8 @@ sudo bash scripts/install_web_panel.sh sudo systemctl restart virtuality-web ``` +Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях. + Полная повторная установка компонентов ноды: ```bash @@ -392,6 +411,41 @@ sudo bash scripts/install_web_panel.sh --- +## Продакшен-настройки + +Настройки ноды — `/var/lib/virtuality/config/web.env`: + +```text +VIRTUALITY_WEB_PORT=8088 # порт панели +VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy +VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа +VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub +VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS) +``` + +После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`. + +Рекомендации: + +- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии; +- открывайте панель наружу только через HTTPS reverse proxy (nginx/Caddy) с `VIRTUALITY_WEB_HOST=127.0.0.1` и `VIRTUALITY_COOKIE_SECURE=1`; +- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`; +- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов; +- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`. + +--- + +## Разработка + +```bash +pip install -r web/requirements.txt -r requirements-dev.txt +make check # pyflakes + shellcheck + pytest +``` + +Код панели лежит в `web/` целиком и устанавливается как есть. Патчить `app.py` во время установки больше не нужно: изменения вносятся прямо в `web/` и покрываются тестами в `tests/`. GitHub Actions запускает проверки на Python 3.11–3.13 и собирает ISO по тегу `v*`. + +--- + ## Полезные команды ```bash @@ -416,14 +470,13 @@ journalctl -u virtuality-web -f - ARM64 cloud-image templates; - cloud-init для быстрых VM; -- web-console/noVNC; - управление storage pools; - backup/snapshot manager; - сетевой менеджер bridge/VLAN; - роли и права пользователей; - журнал событий; -- автообновление; -- кластеризация. +- кластеризация; +- готовые образы для Raspberry Pi / Orange Pi 5. --- diff --git a/VERSION b/VERSION index 85b7c69..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.6 +0.10.0 diff --git a/docs/IMAGE.md b/docs/IMAGE.md new file mode 100644 index 0000000..e4c49c1 --- /dev/null +++ b/docs/IMAGE.md @@ -0,0 +1,142 @@ +# Установочный образ Virtuality + +Virtuality можно поставить двумя способами: + +1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README). +2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду. + +Этот документ про второй способ. + +--- + +## Что внутри образа + +Образ собирается из официального **Ubuntu Server 24.04 LTS live-server ISO** (amd64 или arm64) и дополняется: + +```text +/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) +/virtuality/source # исходники Virtuality (git-репозиторий нужной версии) +/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки +/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь +/virtuality/virtuality-firstboot.service +/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z» +``` + +Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений. + +## Как проходит установка + +1. Загрузка с ISO → пункт **Install Virtuality**. +2. Установщик Ubuntu спрашивает только **сеть**, **диск** и **пользователя** (имя, пароль, hostname). Язык, раскладка, SSH-сервер и остальное заданы автоматически. +3. Установщик копирует Virtuality в `/opt/virtuality/source` и включает `virtuality-firstboot.service`. +4. После перезагрузки `virtuality-firstboot` (нужен интернет) ставит KVM/QEMU/libvirt, Cockpit, web-панель, `vhealth` и консольный dashboard. Обычно это 5–15 минут. Если сети нет или apt упал, попытка повторяется каждую минуту до успеха. +5. Готово: + +```text +Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике) +Cockpit: https://SERVER_IP:9090 +``` + +Адрес панели также показывается на экране входа консоли. Ход первой настройки: + +```bash +sudo journalctl -fu virtuality-firstboot +cat /var/log/virtuality/firstboot.log +``` + +--- + +## Сборка ISO + +Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты: + +```bash +sudo apt install -y xorriso git curl python3-pip +``` + +Сборка: + +```bash +git clone https://github.com/viktor138irk/virtuality.git +cd virtuality +make iso # amd64 +make iso ARCH=arm64 # ARM64-серверы с UEFI +``` + +Скрипт сам скачает последний Ubuntu 24.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: + +```text +dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso +dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256 +``` + +Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно: + +```bash +./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-24.04.5-live-server-amd64.iso +``` + +### Параметры + +```text +--arch amd64|arm64 архитектура +--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD) +--web-port PORT порт web-панели (по умолчанию 8088) +--no-auto-update не включать ночное автообновление с GitHub +--locale / --keyboard / --timezone +--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI) +--repo-url URL откуда нода будет получать обновления +``` + +Через make параметры передаются так: + +```bash +make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow" +``` + +### Полностью автоматическая установка + +Для массовой раскатки можно собрать образ, который не задаёт вопросов: + +```bash +make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01" +``` + +> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах. + +--- + +## Запись на флешку + +Linux / macOS: + +```bash +sudo dd if=dist/virtuality-0.10.0-ubuntu-24.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync +``` + +Windows: Rufus (режим DD) или balenaEtcher. + +Проверка целостности: + +```bash +cd dist && sha256sum -c virtuality-0.10.0-ubuntu-24.04-amd64.iso.sha256 +``` + +--- + +## Сборка в GitHub Actions + +Workflow `.github/workflows/image.yml` собирает ISO: + +- автоматически при пуше тега `v*` (amd64 и arm64); +- вручную: Actions → **Build ISO image** → Run workflow. + +Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней. + +--- + +## Ограничения + +- Целевая ОС образа — Ubuntu Server 24.04 LTS. +- Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе. +- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0e02926 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:cacheprovider diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d0eb39e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development and CI tools +pytest==8.3.4 +httpx==0.28.1 +pyflakes==3.2.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0491550 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,133 @@ +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +WEB_DIR = ROOT / "web" +sys.path.insert(0, str(WEB_DIR)) + +os.environ.setdefault("VIRTUALITY_AUTH_USER", "tester") +os.environ.setdefault("VIRTUALITY_SESSION_SECRET", "test-secret-" + "0" * 32) + +DOMINFO = """Id: 1 +Name: web01 +UUID: 6c1b3c1e-0000-0000-0000-000000000001 +OS Type: hvm +State: running +CPU(s): 2 +Max memory: 2097152 KiB +Used memory: 2097152 KiB +Persistent: yes +Autostart: enable +""" + +DUMPXML = """ + web01 + 2097152 + 2 + hvm + + + + +""" + + +def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict: + return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""} + + +def fake_run_cmd(cmd, timeout=12, **_kwargs): + joined = " ".join(cmd) + if cmd[:2] == ["virsh", "list"]: + return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off") + if cmd[:2] == ["virsh", "dominfo"]: + return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain") + if cmd[:2] == ["virsh", "dumpxml"]: + return fake_result(DUMPXML) + if cmd[:2] == ["virsh", "domstate"]: + return fake_result("running") + if cmd[:2] == ["virsh", "domifaddr"]: + return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24") + if cmd[:2] == ["virsh", "domiflist"]: + return fake_result(" Interface Type Source Model MAC\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc") + if cmd[:2] == ["virsh", "vncdisplay"]: + return fake_result(":0") + if cmd[:2] == ["virsh", "pool-list"]: + return fake_result(" Name State Autostart\n---\n virtuality-images active yes") + if cmd[:2] == ["virsh", "net-list"]: + return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes") + if cmd[:2] == ["systemctl", "is-active"]: + return fake_result("active") + if cmd[:1] == ["hostname"]: + return fake_result("192.168.1.10" if "-I" in cmd else "virtuality-test") + if cmd[:3] == ["ip", "route", "show"]: + return fake_result("default via 192.168.1.1 dev eth0") + if cmd[:1] in (["uptime"], ["uname"]): + return fake_result("up 1 hour" if cmd[0] == "uptime" else "6.8.0") + if "nft" in joined or "iptables" in joined or "ufw" in joined or "sysctl" in joined: + return fake_result() + return fake_result() + + +@pytest.fixture() +def data_dirs(tmp_path, monkeypatch): + import app + import host_profile + import network_core + import update_core + + dirs = { + "iso": tmp_path / "iso", + "images": tmp_path / "images", + "disk_images": tmp_path / "disk-images", + "operations": tmp_path / "operations", + "network": tmp_path / "network", + "config": tmp_path / "config", + "nft": tmp_path / "nft", + "update": tmp_path / "update", + } + for path in dirs.values(): + path.mkdir() + monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) + monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) + monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"]) + monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) + monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) + monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json") + monkeypatch.setattr(network_core, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml") + monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft") + monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None) + monkeypatch.setattr(network_core, "disable_rp_filter", lambda: None) + monkeypatch.setattr(host_profile, "PROFILE_FILE", dirs["config"] / "host_profile.json") + monkeypatch.setattr(update_core, "STATE_DIR", dirs["update"]) + monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") + monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") + monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) + for module in (app, network_core, host_profile): + monkeypatch.setattr(module, "run_cmd", fake_run_cmd) + return dirs + + +@pytest.fixture() +def client(data_dirs): + from fastapi.testclient import TestClient + + import app + + app.login_throttle = app.auth.LoginThrottle() + return TestClient(app.app, base_url="http://panel.test") + + +@pytest.fixture() +def logged_in(client, monkeypatch): + import app + + monkeypatch.setattr(app.auth, "verify_password", lambda user, password: password == "correct") + response = client.post("/login", data={"username": "tester", "password": "correct"}, follow_redirects=False) + assert response.status_code == 303 + return client diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..3d47ae2 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,157 @@ +from urllib.parse import unquote + +import pytest + +import app + +PAGES = ["/", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"] + + +def test_healthz_is_public(client): + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json()["ok"] is True + assert response.json()["version"] == (app.BASE_DIR.parent / "VERSION").read_text().strip() + + +def test_security_headers(client): + response = client.get("/login") + assert response.headers["x-frame-options"] == "SAMEORIGIN" + assert response.headers["x-content-type-options"] == "nosniff" + + +@pytest.mark.parametrize("path", PAGES) +def test_pages_require_login(client, path): + response = client.get(path, follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +@pytest.mark.parametrize("path", PAGES) +def test_pages_render(logged_in, path): + response = logged_in.get(path) + assert response.status_code == 200, response.text[:500] + + +def test_wrong_user_is_rejected(client, monkeypatch): + monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True) + response = client.post("/login", data={"username": "intruder", "password": "x"}, follow_redirects=False) + assert response.status_code == 401 + + +def test_login_throttle(client, monkeypatch): + monkeypatch.setattr(app.auth, "verify_password", lambda user, password: False) + for _ in range(5): + assert client.post("/login", data={"username": "tester", "password": "bad"}).status_code == 401 + response = client.post("/login", data={"username": "tester", "password": "bad"}) + assert response.status_code == 429 + assert int(response.headers["retry-after"]) > 0 + + +def test_session_cookie_flags(client, monkeypatch): + monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True) + response = client.post("/login", data={"username": "tester", "password": "x"}, follow_redirects=False) + cookie = response.headers["set-cookie"].lower() + assert "httponly" in cookie and "samesite=lax" in cookie + + +def test_forged_cookie_is_rejected(client): + client.cookies.set("virtuality_session", "forged.token.value") + assert client.get("/", follow_redirects=False).status_code == 303 + + +def test_cross_origin_post_blocked(logged_in): + response = logged_in.post("/vm/web01/start", headers={"Origin": "http://evil.example"}, follow_redirects=False) + assert response.status_code == 403 + + +def test_same_origin_post_allowed(logged_in): + response = logged_in.post("/vm/web01/start", headers={"Origin": "http://panel.test"}, follow_redirects=False) + assert response.status_code == 303 + + +def test_vm_action_rejects_bad_name(logged_in): + assert logged_in.post("/vm/-bad/start", follow_redirects=False).status_code == 400 + + +def test_vm_action_unknown(logged_in): + assert logged_in.post("/vm/web01/format-disk", follow_redirects=False).status_code == 400 + + +def test_vm_details_include_autostart(data_dirs): + details = app.vm_details("web01") + assert details["autostart_enabled"] is True + assert details["autostart_label"] == "enabled" + + +def test_vm_detail_shows_autostart_enabled(logged_in): + assert "autostart: enabled" in logged_in.get("/vm/web01").text + + +def test_redirect_message_is_url_encoded(logged_in, monkeypatch): + monkeypatch.setattr(app, "apply_vm_boot_order", lambda name, order: (True, "Готово: диск & ISO?")) + response = logged_in.post("/vm/web01/boot-order", data={"boot_order": "disk"}, follow_redirects=False) + location = response.headers["location"] + assert " " not in location and "&" not in location.split("?", 1)[1] + assert unquote(location.split("boot_message=", 1)[1]) == "Готово: диск & ISO?" + + +def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + captured = {} + monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd)) + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + form = {"name": "new-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "iso_path": str(iso), "source_type": "iso", "guest_arch": "x86_64", "boot_order": "auto", "network_mode": "nat"} + response = logged_in.post("/vm/create", data=form, follow_redirects=False) + assert response.status_code == 303, response.text[:500] + cmd = captured["cmd"] + assert cmd[0] == "virt-install" + assert "--cdrom" in cmd and str(iso) in cmd + assert "network=virtuality-nat,model=virtio" in cmd + assert captured["operation"]["boot_order"] == "cdrom_disk" + + +def test_vm_create_validation_error(logged_in): + form = {"name": "x", "memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": "nat"} + response = logged_in.post("/vm/create", data=form) + assert response.status_code == 400 + + +def test_iso_upload_and_delete(logged_in, data_dirs, monkeypatch): + response = logged_in.post("/iso/upload", files={"iso_file": ("My Distro 1.0.iso", b"data", "application/octet-stream")}, follow_redirects=False) + assert response.status_code == 303 + assert (data_dirs["iso"] / "My-Distro-1.0.iso").read_bytes() == b"data" + logged_in.post("/iso/My-Distro-1.0.iso/delete", follow_redirects=False) + assert not (data_dirs["iso"] / "My-Distro-1.0.iso").exists() + + +def test_iso_upload_rejects_other_types(logged_in): + response = logged_in.post("/iso/upload", files={"iso_file": ("evil.sh", b"#!/bin/sh", "text/plain")}) + assert response.status_code == 400 + + +def test_iso_delete_path_traversal(logged_in, data_dirs, tmp_path): + victim = tmp_path / "victim.iso" + victim.write_bytes(b"keep") + logged_in.post("/iso/..%2Fvictim.iso/delete", follow_redirects=False) + assert victim.exists() + + +def test_port_forward_add(logged_in, data_dirs): + form = {"vm_name": "web01", "guest_ip": "192.168.100.51", "external_port": "2222", "guest_port": "22", "protocol": "tcp"} + response = logged_in.post("/network/forward/add", data=form, follow_redirects=False) + assert response.status_code == 303 + rules = (data_dirs["nft"] / "virtuality.nft").read_text() + assert "tcp dport 2222 dnat to 192.168.100.51:22" in rules + + +def test_api_requires_auth(client): + for path in ("/api/health", "/api/operations", "/live/status", "/api/logs"): + assert client.get(path).status_code == 401 + + +def test_live_status(logged_in): + data = logged_in.get("/live/status").json() + assert data["ok"] is True + assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"} diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..06d4630 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,49 @@ +import pytest + +import auth + +# SHA-512 crypt hash of "s3cret" with salt "virtualitysalt". +SHA512_HASH = "$6$virtualitysalt$" + + +@pytest.fixture() +def password_hash(): + computed = auth.crypt_hash("s3cret", SHA512_HASH) + assert computed and computed.startswith(SHA512_HASH) + return computed + + +def write_shadow(tmp_path, monkeypatch, entries): + shadow = tmp_path / "shadow" + shadow.write_text("".join(f"{user}:{pw}:19000:0:99999:7:::\n" for user, pw in entries)) + monkeypatch.setattr(auth, "SHADOW_FILE", shadow) + monkeypatch.setattr(auth, "_stdlib_spwd", None) + + +def test_libcrypt_fallback_matches(password_hash, monkeypatch): + monkeypatch.setattr(auth, "_stdlib_crypt", None) + assert auth.crypt_hash("s3cret", password_hash) == password_hash + assert auth.crypt_hash("wrong", password_hash) != password_hash + + +def test_verify_password_with_shadow_file(password_hash, tmp_path, monkeypatch): + write_shadow(tmp_path, monkeypatch, [("root", "*"), ("admin", password_hash), ("locked", "!" + password_hash)]) + monkeypatch.setattr(auth, "_stdlib_crypt", None) + assert auth.verify_password("admin", "s3cret") + assert not auth.verify_password("admin", "wrong") + assert not auth.verify_password("admin", "") + assert not auth.verify_password("root", "anything") + assert not auth.verify_password("locked", "s3cret") + assert not auth.verify_password("missing", "s3cret") + + +def test_throttle_locks_and_resets(): + throttle = auth.LoginThrottle(max_failures=3, window_seconds=60, lockout_seconds=60) + for _ in range(2): + throttle.record_failure("1.2.3.4") + assert throttle.retry_after("1.2.3.4") == 0 + throttle.record_failure("1.2.3.4") + assert throttle.retry_after("1.2.3.4") > 0 + assert throttle.retry_after("5.6.7.8") == 0 + throttle.record_success("1.2.3.4") + assert throttle.retry_after("1.2.3.4") == 0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..ca9c0c3 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,67 @@ +import json +from pathlib import Path + +import pytest + +import network_core +import restore_network +import update_core + + +def test_parse_port_range(): + assert network_core.parse_port_range("80") == (80, 80) + assert network_core.parse_port_range("10000-10010") == (10000, 10010) + assert network_core.parse_port_range("10000:10010") == (10000, 10010) + for bad in ("", "0", "70000", "20-10", "abc", "80;rm"): + with pytest.raises(network_core.NetworkError): + network_core.parse_port_range(bad) + + +def test_add_port_forward_rejects_overlap(data_dirs): + network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "8000-8010", "tcp") + with pytest.raises(network_core.NetworkError): + network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "tcp") + network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "udp") + + +def test_add_port_forward_rejects_mismatched_ranges(data_dirs): + with pytest.raises(network_core.NetworkError): + network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "80", "tcp") + + +def test_render_nft_rules_range(data_dirs): + network_core.add_port_forward("web01", "192.168.100.51", "10000-10002", "20000-20002", "udp") + rules = network_core.render_nft_rules() + assert 'iifname "eth0" udp dport 10000-10002 dnat to 192.168.100.51:20000-20002' in rules + assert "masquerade" in rules + + +def test_restore_network_noop_without_config(data_dirs, capsys): + assert restore_network.main() == 0 + assert "nothing to restore" in capsys.readouterr().out + + +def test_restore_network_applies_rules(data_dirs): + data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([ + {"id": "1", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}, + ])) + assert restore_network.main() == 0 + assert "dport 2222 dnat to 192.168.100.51:22" in (data_dirs["nft"] / "virtuality.nft").read_text() + + +def test_version_tuple_ordering(): + assert update_core.version_tuple("0.9.6") < update_core.version_tuple("0.10.0") + assert update_core.version_tuple("v1.0") == update_core.version_tuple("1.0.0") + + +def test_missing_versions(): + manifest = {"versions": [{"version": v} for v in ("0.9.5", "0.9.6", "0.10.0", "0.10.1")]} + missed = update_core.missing_versions("0.9.6", "0.10.1", manifest) + assert [item["version"] for item in missed] == ["0.10.0", "0.10.1"] + + +def test_versions_manifest_matches_version_file(): + root = Path(__file__).resolve().parents[1] + version = (root / "VERSION").read_text().strip() + manifest = json.loads((root / "updates" / "versions.json").read_text()) + assert version in [item["version"] for item in manifest["versions"]] diff --git a/updates/versions.json b/updates/versions.json index b666258..c4e090a 100644 --- a/updates/versions.json +++ b/updates/versions.json @@ -1,5 +1,27 @@ { "versions": [ + { + "version": "0.10.0", + "date": "2026-09-25", + "title": "Система была подготовлена к продакшену и сборке образа", + "summary": "Web-панель больше не собирается патчами во время установки: итоговый код хранится в репозитории и покрыт тестами. Исправлены ошибки создания VM и отображения автозапуска, усилена безопасность входа, настройки ноды сохраняются между обновлениями. Добавлена сборка установочного ISO-образа Virtuality на базе Ubuntu Server 24.04.", + "features": [ + "Исправлено падение создания VM из-за отсутствующего импорта platform", + "На странице VM автозапуск теперь показывается корректно", + "Сообщения об операциях с VM корректно кодируются в адресе страницы", + "Проверка пароля работает на Python 3.13+, где удалены модули crypt и spwd", + "Добавлены ограничение попыток входа, срок жизни сессии, защита от cross-origin POST и заголовки безопасности", + "Добавлен публичный /healthz для мониторинга", + "Правила проброса портов восстанавливаются после перезагрузки сервера", + "Порт, пользователь панели и режим автообновления сохраняются в /var/lib/virtuality/config/web.env и больше не сбрасываются при обновлении", + "Обновление из панели больше не прерывается перезапуском virtuality-web", + "Автообновление можно отключить: VIRTUALITY_AUTO_UPDATE=0", + "Python-зависимости закреплены полностью и могут ставиться офлайн", + "Добавлена сборка установочного ISO: image/build-iso.sh", + "Добавлены автотесты и GitHub Actions", + "Версия обновлена до 0.10.0" + ] + }, { "version": "0.8.61", "date": "2026-05-07",