Версия проекта была обновлена до 0.10.0: тесты, CI и документация образа

- tests/: 55 тестов web-панели, авторизации, сети и обновлений,
  все страницы рендерятся на замоканных virsh/systemctl;
- GitHub Actions: pyflakes + pytest на Python 3.11–3.13, shellcheck,
  сборка ISO amd64/arm64 по тегу v* или вручную;
- Makefile: make check / make test / make iso;
- docs/IMAGE.md и README: установка с ISO, продакшен-настройки,
  разработка;
- updates/versions.json: описание версии 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 06:36:26 +00:00
родитель 85648739dc
Коммит 110a37f435
13 изменённых файлов: 749 добавлений и 5 удалений
+35
Просмотреть файл
@@ -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
+45
Просмотреть файл
@@ -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
+34
Просмотреть файл
@@ -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 {} +
+57 -4
Просмотреть файл
@@ -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.
---
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.9.6
0.10.0
+142
Просмотреть файл
@@ -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`.
+3
Просмотреть файл
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -p no:cacheprovider
+4
Просмотреть файл
@@ -0,0 +1,4 @@
# Development and CI tools
pytest==8.3.4
httpx==0.28.1
pyflakes==3.2.0
+133
Просмотреть файл
@@ -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 = """<domain type='kvm'>
<name>web01</name>
<memory unit='KiB'>2097152</memory>
<vcpu placement='static'>2</vcpu>
<os><type arch='x86_64' machine='pc-q35'>hvm</type><boot dev='hd'/></os>
<devices>
<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>
</devices>
</domain>
"""
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
+157
Просмотреть файл
@@ -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"}
+49
Просмотреть файл
@@ -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
+67
Просмотреть файл
@@ -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"]]
+22
Просмотреть файл
@@ -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",