diff --git a/.dockerignore b/.dockerignore index dcc01a87..450efe7a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,8 +16,15 @@ **/env* **/.env* -# Local databases and runtime data +# Local databases and runtime data (secret key, admin password, update backups) +backups **/*.sqlite3* +data +**/*.bak +**/*~ +**/*.swp +**/*.swo +**/staticfiles **/*.log **/console/cert.pem* **/dhcpd.* diff --git a/.github/linters/.hadolint.yaml b/.github/linters/.hadolint.yaml deleted file mode 100644 index 79bdabbe..00000000 --- a/.github/linters/.hadolint.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ignored: - - DL3008 - - DL3013 - - DL3042 - - SC1091 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 1c5421d4..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,61 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -name: "CodeQL" - -on: - push: - branches: [master] - pull_request: - branches-ignore: [master] - schedule: - - cron: '0 21 * * 3' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['javascript', 'python'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 4df1905c..f5456c80 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,92 +1,32 @@ -########################### -########################### -## Linter GitHub Actions ## -########################### -########################### +name: Lint -name: Lint Code Base - -# -# Documentation: -# https://help.github.com/en/articles/workflow-syntax-for-github-actions -# - -############################# -# Start the job on all push # -############################# +# a branch with a pull request (also one stacked on another) is tested by its +# pull_request run; push runs for every branch tested each commit twice on: push: branches: [master] pull_request: - branches-ignore: [master] - -############### -# Set the Job # -############### +# the jobs only read the code +permissions: + contents: read + jobs: - build: - name: Lint Code Base - # Set the agent to run on + ruff: + name: Ruff runs-on: ubuntu-latest - - ################## - # Load all steps # - ################### steps: - ########################## - # Checkout the code base # - ########################## - - name: Checkout Code - uses: actions/checkout@v4 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 + - name: Checkout Code + uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' - - name: Install Required packages - run: | - sudo apt-get update -qq - sudo apt-get install -y python3-venv python3-virtualenv libvirt-dev libldap2-dev libsasl2-dev python3-lxml zlib1g-dev libxslt1-dev pkg-config gcc libssl-dev - - - name: Create & Activate VENV - run: | - python3 -m venv venv - echo "$GITHUB_WORKSPACE/venv/bin" >> "$GITHUB_PATH" - - - name: Install dependencies - run: | - pip3 install --upgrade pip setuptools wheel - if [ -f dev/requirements.txt ]; then pip3 install -r dev/requirements.txt; else pip3 install -r conf/requirements.txt; fi - ################################ - # Run Linter against code base # - ################################ - - name: Lint Code Base - uses: docker://github/super-linter:latest - env: - FILTER_REGEX_EXCLUDE: .*(static|scss|venv|locale|templates)/.* - DEFAULT_BRANCH: master - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VALIDATE_ALL_CODEBASE: false - VALIDATE_MARKDOWN: false - VALIDATE_HTML: false - VALIDATE_JSCPD: false - VALIDATE_ANSIBLE: false - VALIDATE_CLOJURE: false - VALIDATE_COFFEE: false - VALIDATE_DART: false - VALIDATE_GO: false - VALIDATE_JSX: false - VALIDATE_KOTLIN: false - VALIDATE_POWERSHELL: false - VALIDATE_PERL: false - VALIDATE_PHP: false - VALIDATE_RAKU: false - VALIDATE_RUBY: false - VALIDATE_TSX: false - VALIDATE_TERRAFORM: false + # Same ruff version as dev/requirements.txt + - name: Install Ruff + run: pip install "$(grep '^ruff==' dev/requirements.txt)" + - name: Run Ruff + run: ruff check --output-format=github . diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e71d944..baaf0b8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,19 +1,49 @@ name: Test Suite +# a branch with a pull request (also one stacked on another) is tested by its +# pull_request run; push runs for every branch tested each commit twice on: push: branches: [master] pull_request: - branches: [master] + +# the jobs only read the code +permissions: + contents: read jobs: test: - name: Python ${{ matrix.python-version }} + name: Python ${{ matrix.python-version }} / ${{ matrix.database }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + database: [sqlite] + include: + - python-version: '3.12' + database: postgresql + + # Used by the postgresql leg only; settings fall back to sqlite when + # WEBVIRTCLOUD_DB_HOST is empty. + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: webvirtcloud + POSTGRES_PASSWORD: webvirtcloud + POSTGRES_DB: webvirtcloud + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + WEBVIRTCLOUD_DB_HOST: ${{ matrix.database == 'postgresql' && 'localhost' || '' }} + WEBVIRTCLOUD_DB_PASSWORD: webvirtcloud steps: - name: Checkout Code @@ -24,28 +54,30 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: 'conf/requirements.txt' + cache-dependency-path: | + conf/requirements.txt + dev/requirements.txt + # Build headers for the sdist-only bindings (libvirt-python, python-ldap) + # A runner's package mirror sometimes stops answering: time out and + # retry instead of hanging until the job's 6-hour limit - name: Install System Dependencies + timeout-minutes: 10 run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends \ + APT="-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30" + sudo apt-get $APT update -qq + sudo apt-get $APT install -y --no-install-recommends \ libvirt-dev \ libldap2-dev \ libsasl2-dev \ - python3-lxml \ - zlib1g-dev \ - libxslt1-dev \ pkg-config \ gcc \ - libssl-dev \ - libxml2-dev + gettext - name: Install Python Dependencies run: | - python -m pip install --upgrade pip setuptools wheel - pip install -r conf/requirements.txt - if [ -f dev/requirements.txt ]; then pip install -r dev/requirements.txt; fi + python -m pip install --upgrade pip setuptools + pip install -r dev/requirements.txt - name: Configure WebVirtCloud Settings run: | @@ -53,14 +85,134 @@ jobs: SECRET_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(50))') sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py + - name: Check For Missing Migrations + run: python manage.py makemigrations --check --dry-run + - name: Run Django Migrations - run: | - python manage.py migrate --noinput + run: python manage.py migrate --noinput - - name: Run Django Test Suite + # Each catalog of LANGUAGES translates every message, and its compiled + # .mo is the .po's: a changed text needs its translations + - name: Check Translation Catalogs + if: matrix.python-version == '3.12' && matrix.database == 'sqlite' run: | - python manage.py test + python manage.py makemessages -l tr -l zh --no-obsolete --ignore=staticfiles -v0 + for dir in locale/tr/LC_MESSAGES locale/zh/LC_MESSAGES; do + msgfmt --check -o /tmp/django.mo "$dir/django.po" + missing=$(msgattrib --untranslated "$dir/django.po"; msgattrib --only-fuzzy "$dir/django.po") + if [ -n "$missing" ]; then echo "$missing"; echo "::error file=$dir/django.po::untranslated or fuzzy messages"; exit 1; fi + cmp <(msgunfmt "$dir/django.mo") <(msgunfmt /tmp/django.mo) || { echo "::error file=$dir/django.mo::not compiled from django.po (compilemessages)"; exit 1; } + done - - name: Run vrtManager Unit Tests + # Includes the vrtManager tests; fails below the coverage floor in pyproject.toml + - name: Run Test Suite run: | - python -m unittest discover -s vrtManager -p "test_*.py" + coverage run manage.py test + coverage report --skip-covered + + # The project test runner (webvirtcloud/test_runner.py) on the Docker + # image's Python, whose default start method its workers need changed + - name: Run Test Suite In Parallel + if: matrix.python-version == '3.14' && matrix.database == 'sqlite' + run: python manage.py test --parallel + + # The per-file report of one leg, to see what a change left untested + - name: Store Coverage Report + if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite' + run: coverage html -d coverage-html --fail-under=0 + + - name: Upload Coverage Report + if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite' + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: coverage-html + + - name: Validate OpenAPI Schema + run: python manage.py spectacular --validate --fail-on-warn --file /dev/null + + # Security warnings (HSTS, secure cookies) depend on the deployment; + # gate on errors only. + - name: Deployment Checks + run: python manage.py check --deploy --fail-level ERROR + + # What installations get, resolved for this Python; not the CI tools + # (their fixes can need a newer Python than the leg's, as filelock's did) + - name: Audit Shipped Packages + run: pip-audit -r conf/requirements.txt -r conf/requirements-ldap.txt + + docker: + name: Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + # Files that exist in a used checkout and must never enter the image + - name: Plant Secret Sentinels + run: | + mkdir -p data + for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \ + 'webvirtcloud/settings.py~' webvirtcloud/.settings.py.swp; do + echo sentinel > "$f" + done + + - name: Build Image + run: docker build -t webvirtcloud:ci . + + - name: No Runtime Data In Image + run: | + docker run --rm --entrypoint sh webvirtcloud:ci -c ' + cd /srv/webvirtcloud + for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \ + "webvirtcloud/settings.py~" webvirtcloud/.settings.py.swp; do + if [ -e "$f" ]; then echo "$f is in the image"; exit 1; fi + done' + + # The default profile, and the HTTPS profile behind a TLS proxy + - name: Start Containers + run: | + docker run -d --name wvc -p 127.0.0.1:8080:80 webvirtcloud:ci + docker run -d --name wvc-https -e WEBVIRTCLOUD_HTTPS=1 webvirtcloud:ci + + - name: Wait Until Healthy + run: | + for c in wvc wvc-https; do + for i in $(seq 40); do + [ "$(docker inspect -f '{{.State.Health.Status}}' $c)" = healthy ] && continue 2 + sleep 5 + done + docker inspect -f '{{json .State.Health}}' $c || true + docker exec $c sv status /etc/service/nginx /etc/service/webvirtcloud /etc/service/novnc || true + docker logs $c || true + exit 1 + done + + - name: Import Compiled Dependencies + run: docker exec wvc /srv/webvirtcloud/venv/bin/python3 -c "import libvirt, ldap, lxml.etree, psycopg" + + # Static URLs carry no version: browsers must revalidate them + - name: Static Files Revalidated + run: | + url=http://127.0.0.1:8080/static/css/themes/zephyr.min.css + header() { tr -d '\r' | awk -v h="$1" 'tolower($0) ~ "^" h ":" {sub(/^[^:]*: */, ""); print}'; } + h=$(curl -s -m 5 -D - -o /dev/null "$url") + [ "$(echo "$h" | header cache-control)" = no-cache ] || { echo "$h"; exit 1; } + etag=$(echo "$h" | header etag) + [ -n "$etag" ] + h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url") + echo "$h" | head -1 | grep -q " 304" || { echo "$h"; exit 1; } + [ "$(echo "$h" | header cache-control)" = no-cache ] + docker exec wvc sh -c 'echo "/* changed */" >> /srv/webvirtcloud/staticfiles/css/themes/zephyr.min.css' + h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url") + echo "$h" | head -1 | grep -q " 200" || { echo "$h"; exit 1; } + new=$(echo "$h" | header etag) + [ -n "$new" ] && [ "$new" != "$etag" ] + + # The browser reaches the console through nginx on the panel's port + - name: Console Through Nginx Only + run: | + [ "$(docker inspect -f '{{json .Config.ExposedPorts}}' webvirtcloud:ci)" = '{"80/tcp":{}}' ] + code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGVzdGtleXRlc3RrZXk9PQ==' http://127.0.0.1:8080/novncd/ || true) + [ "$code" = 101 ] || { echo "websocket upgrade through nginx: $code"; exit 1; } diff --git a/.gitignore b/.gitignore index bc289ab1..7a278dce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,29 @@ venv2 .webvirtcloud *.pyc db.sqlite3* +test_db*.sqlite3* console/cert.pem* tags dhcpd.* webvirtcloud/settings.py -*migrations/* .coverage htmlcov *.log templates/webvirtcloud.code-workspace DEPENDENCY_UPGRADE_PLAN.md +AGENTS.md +ROADMAP.md +.agents + +# Runtime data (secret key, etc.) +data/ + +# collectstatic output +/staticfiles/ + +# Local planning and agent workspace (superpowers) +/docs/superpowers/ +/.superpowers/ + +# Panel backups (manage.py backup) +/backups/ diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 024baab1..00000000 --- a/.gitpod.yml +++ /dev/null @@ -1,5 +0,0 @@ -image: gitpod/workspace-full - -tasks: - - init: 'echo "TODO: Replace with init/build command"' - command: 'echo "TODO: Replace with command to start project"' diff --git a/Dockerfile b/Dockerfile index be297797..300208ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,53 +1,57 @@ -FROM phusion/baseimage:noble-1.0.2 - -EXPOSE 80 -EXPOSE 6080 - -# Use baseimage-docker's init system. -CMD ["/sbin/my_init"] - -RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean +FROM phusion/baseimage:resolute-1.0.23 AS build +# Compilers and headers for libvirt-python and python-ldap; they stay in this stage. # hadolint ignore=DL3008 RUN apt-get update -qqy \ && DEBIAN_FRONTEND=noninteractive apt-get -qyy install \ --no-install-recommends \ - git \ python3-venv \ - python3-pip \ python3-dev \ - python3-lxml \ - python3-libvirt \ libvirt-dev \ zlib1g-dev \ - nginx \ pkg-config \ gcc \ libldap2-dev \ libssl-dev \ libsasl2-dev \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +WORKDIR /srv/webvirtcloud +COPY conf/requirements.txt conf/requirements-ldap.txt conf/ +# hadolint ignore=DL3013,DL3042 +RUN python3 -m venv venv \ + && venv/bin/pip install --no-cache-dir -U pip wheel \ + && venv/bin/pip install --no-cache-dir -r conf/requirements.txt -r conf/requirements-ldap.txt + +FROM phusion/baseimage:resolute-1.0.23 + +EXPOSE 80 + +# Use baseimage-docker's init system. my_init force-stops services after +# KILL_PROCESS_TIMEOUT seconds (default 30); the worker drains jobs for 60 s. +ENV KILL_PROCESS_TIMEOUT=80 +CMD ["/sbin/my_init"] + +RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean + +# Shared libraries of the compiled wheels, and nginx; no compilers or headers. +# hadolint ignore=DL3008 +RUN apt-get update -qqy \ + && DEBIAN_FRONTEND=noninteractive apt-get -qyy install \ + --no-install-recommends \ + python3 \ + nginx \ + libvirt0 \ libsasl2-modules \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Setup webvirtcloud WORKDIR /srv/webvirtcloud - -# Install Python dependencies first with system-site-packages to leverage prebuilt bindings -COPY conf/requirements.txt conf/requirements.txt -# hadolint ignore=DL3013,DL3042,SC1091 -RUN python3 -m venv --system-site-packages venv && \ - . venv/bin/activate && \ - pip3 install --no-cache-dir -U pip wheel && \ - pip3 install --no-cache-dir -r conf/requirements.txt - -# Copy application source +COPY --from=build /srv/webvirtcloud/venv venv COPY . /srv/webvirtcloud # Run collectstatic with temporary dummy key, then remove temporary settings file -# hadolint ignore=SC1091 -RUN . venv/bin/activate && \ - cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \ - SECRET_KEY="build-dummy-key-only-for-collectstatic" python3 manage.py collectstatic --noinput && \ +RUN cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \ + SECRET_KEY="build-dummy-key-only-for-collectstatic" venv/bin/python3 manage.py collectstatic --noinput && \ rm -f webvirtcloud/settings.py && \ chown -R www-data:www-data /srv/webvirtcloud @@ -63,19 +67,33 @@ RUN mkdir -p /etc/my_init.d \ /etc/service/nginx \ /etc/service/nginx-log-forwarder \ /etc/service/webvirtcloud \ - /etc/service/novnc + /etc/service/webvirtcloud-worker \ + /etc/service/novnc \ + /etc/service/socketiod COPY conf/runit/10_webvirtcloud_init.sh /etc/my_init.d/10_webvirtcloud_init.sh COPY conf/runit/nginx /etc/service/nginx/run COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run COPY conf/runit/novncd.sh /etc/service/novnc/run +COPY conf/runit/socketiod.sh /etc/service/socketiod/run COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run +COPY conf/runit/webvirtcloud-worker.sh /etc/service/webvirtcloud-worker/run +COPY --chmod=644 conf/runit/backup.cron /etc/cron.d/webvirtcloud-backup +COPY --chmod=755 conf/runit/healthcheck /usr/local/bin/webvirtcloud-healthcheck RUN chmod +x /etc/my_init.d/10_webvirtcloud_init.sh \ /etc/service/nginx/run \ /etc/service/nginx-log-forwarder/run \ /etc/service/novnc/run \ - /etc/service/webvirtcloud/run + /etc/service/socketiod/run \ + /etc/service/webvirtcloud/run \ + /etc/service/webvirtcloud-worker/run -# Declare mountable data directory for persistent SQLite and SSH keys -VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh"] +# The panel's page and every service (conf/runit/healthcheck). X-Forwarded-Proto: +# https keeps the probe from being redirected when the HTTPS profile is on +# (nginx passes the header on; see conf/nginx). +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD /usr/local/bin/webvirtcloud-healthcheck + +# Declare mountable data directory for persistent SQLite, SSH keys and TLS certificates, and their backups +VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh", "/var/www/.pki", "/srv/webvirtcloud/backups"] WORKDIR /srv/webvirtcloud diff --git a/README.md b/README.md index 22c81c92..85eb731a 100644 --- a/README.md +++ b/README.md @@ -1,429 +1,419 @@ -[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/retspen/webvirtcloud) - # WebVirtCloud -###### Python >=3.10 & Django 4.2 LTS (tested on Python 3.10 – 3.12) +###### Python >=3.10 & Django 5.2 LTS (tested on Python 3.10 – 3.14) -## Description +WebVirtCloud is a **web visualization and management layer** for **libvirt**-based virtualization infrastructure. -WebVirtCloud is a virtualization web interface for administrators and users. It allows delegating virtual machines to users with role-based permissions. A built-in noVNC / SPICE console presents a full graphical interface to the guest domain. KVM is currently the supported hypervisor. +> **libvirt is the single source of truth.** WebVirtCloud reads, displays and triggers actions on libvirt state, but it is never the primary orchestrator. -## Features -* QEMU/KVM Hypervisor Management -* QEMU/KVM Instance Management - Create, Delete, Update -* Hypervisor & Instance web-based real-time stats -* Manage Multiple QEMU/KVM Hypervisors -* Manage Hypervisor Datastore pools and storage volumes -* Manage Hypervisor Networks and interfaces -* Instance Console Access with Web Browsers (noVNC & SPICE) -* Libvirt API-based web management UI -* User-based Authorization, Authentication, and 2FA (OTP) -* User can add SSH public key to root in Instance -* User can change root password in Instance -* Supports cloud-init datasource interface -* REST API with OpenAPI 3.0 (Swagger & ReDoc) documentation +- An administrator can connect to libvirt directly with **`virt-manager`** or **`virsh`** and create, delete, rename or reconfigure virtual machines. WebVirtCloud detects these changes and updates its database accordingly. +- Virtual machines keep running **even when WebVirtCloud is stopped**; the application only reflects the current state of libvirt. +- WebVirtCloud is not a full automation platform like Proxmox. Its goal is to expose libvirt's functionality through the browser in an accessible, role-based and secure way. -## Quick Install with Installer (Beta) - -Install an OS and run specified commands. Installer supported OSes: Ubuntu 20.04/22.04/24.04, Debian 10/11/12, Rocky/Alma/OEL/RHEL 9/10, openSUSE Leap 15.x / Tumbleweed, and SLES 15. -It can be installed on a virtual machine, physical host or on a KVM host. - -```bash -# Using curl: -curl -fsSL -O https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh -# Or using wget: -# wget https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh - -chmod 744 install.sh -# run with sudo or root user -./install.sh +``` +┌─────────────────────────────────────────────────────┐ +│ Administrator / User │ +└────────────â”Ŧ───────────────────â”Ŧ────────────────────┘ + │ Web browser │ virt-manager / virsh + â–ŧ â–ŧ +┌────────────────────┐ ┌─────────────────────────────┐ +│ WebVirtCloud │ │ libvirt API (direct) │ +│ (web UI layer) │◄─┤ — separate, independent │ +└────────â”Ŧ───────────┘ └──────────────â”Ŧ──────────────┘ + │ libvirt API │ + â–ŧ â–ŧ +┌──────────────────────────────────────────────────────┐ +│ libvirtd (hypervisor daemon) │ +│ QEMU / KVM │ +└──────────────────────────────────────────────────────┘ ``` -## Docker Deployment (Docker Compose) +WebVirtCloud and `virt-manager`/`virsh` can manage the same libvirt host **concurrently and independently**. WebVirtCloud picks up external changes the next time the instance list page is loaded. -Run WebVirtCloud in a container with persistent volumes for data and SSH keys: +## Features + +* QEMU/KVM hypervisor management (multiple compute nodes), with each host's capacity and what its VMs were given +* Virtual machine lifecycle: create, delete, power management, clone (also of a running VM, with libvirt 11.10 or later), migrate (with or without shared storage), disk moves between pools +* Long operations (create, clone, delete, snapshots, migration, disk moves, ISO upload) run as jobs that the page follows and can cancel +* External snapshots (memory optional, guest file systems frozen through the agent) +* **External change detection:** VMs created, deleted or renamed via `virt-manager` or `virsh` are reconciled into the database automatically +* Real-time hypervisor and VM statistics (CPU, RAM, disk, network) +* Storage pool and volume management +* Network (with DHCP leases), interface, network filter and secret management +* PCI and USB devices of the host given to VMs +* UEFI Secure Boot and TPM 2.0 for new VMs, as Windows 11 needs +* Browser-based VNC console (noVNC) and serial console +* Role-based access control: users only see the VMs assigned to them, within their quota +* 2FA (OTP / TOTP) and failed-login lockout +* SSH public key and root password management (guest agent for a running VM, gstfsd for a shut-off one) +* Finding VMs by IP or MAC address, and a VM report as an Excel file +* An audit log of who changed what, with its result +* Cloud-init datasource interface (OpenStack metadata compatible) and NoCloud seed media +* REST API — OpenAPI 3.0 (Swagger & ReDoc), with API tokens +* LDAP / Active Directory integration (optional) +* Daily backups of the panel, with an optional off-site copy (restic over SFTP) +* English, Turkish and Simplified Chinese + +## Contents + +1. [Installation](#installation): [installer](#installer), [Docker](#docker), [manual](#manual-installation), [first login](#first-login) +2. [Adding a Compute Node](#adding-a-compute-node) +3. [Configuration](#configuration): [environment variables](#environment-variables), [HTTPS and reverse proxy](#https-and-reverse-proxy), [cloud-init datasource](#cloud-init-datasource), [LDAP](#ldap), [OTP](#two-factor-login-otp), [serial console](#serial-console) +4. [Backups](#backups): [off-site copy](#off-site-copy) +5. [Updating](#updating): [Docker](#docker-1), [installer and manual](#installer-and-manual-installation), [restore and rollback](#restore-and-rollback), [moving to a new machine](#moving-to-a-new-machine), [behavior changes](#behavior-changes) +6. [Usage](#usage): [users and permissions](#users-roles-and-permissions), [REST API](#rest-api), [screenshots](#screenshots) +7. [Development](#development) + +## Installation + +The installer supports Ubuntu 22.04 / 24.04 / 26.04, Debian 12 / 13, RHEL / Rocky Linux / AlmaLinux / Oracle Linux 10, openEuler 24.03 LTS, openSUSE Leap 15.x / Tumbleweed and SLES 15; the panel runs on a virtual machine, a physical host or a KVM host itself and needs Python 3.10 or newer. CI tests Python 3.10–3.14 on Ubuntu (sqlite, and PostgreSQL on 3.12) and the Docker image; the distro installs are not tested in CI. Pick one of the three ways below, then do the [first login](#first-login). + +### Installer + +```bash +curl -fsSL -O https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh +chmod 744 install.sh +sudo ./install.sh +``` + +The installer writes the server's names and IPs to `ALLOWED_HOSTS`. Running it again keeps `settings.py`, the database, the secret key and the main nginx config, but replaces `/etc/nginx/conf.d/webvirtcloud.conf` and the systemd units; back up your own lines in the nginx config (TLS) first. Put your own service settings in `/etc/default/webvirtcloud` or in drop-ins (`systemctl edit webvirt-cloud`), which a re-run keeps. + +### Docker ```bash # 1. Clone repository: git clone https://github.com/retspen/webvirtcloud cd webvirtcloud -# 2. Start services: +# 2. Set the names/IPs the panel is reached by (git pull never touches this file): +cat > docker-compose.override.yml <<'EOF' +services: + webvirtcloud: + environment: + - ALLOWED_HOSTS=wvc.example.com,192.0.2.10 +EOF + +# 3. Start services: docker compose up -d ``` -Access the panel at `http://` and noVNC console at port `6080`. +The panel and its console are at `http://`. The database and secret key live in the `webvirtcloud-data` volume, the SSH keys in `webvirtcloud-ssh`, the TLS client certificates in `webvirtcloud-pki`, the [backups](#backups) in `webvirtcloud-backups`. Other settings go in the same `environment:` list, see [Environment variables](#environment-variables). -## Manual Installation +### Manual installation -### Generate secret key +Run the steps from a root shell (`sudo -i`). In step 3, replace the example `ALLOWED_HOSTS` with the names/IPs the panel (and the cloud-init datasource) is reached by. -You should generate SECRET_KEY after cloning repository. Then put it into webvirtcloud/settings.py. +The virtualenv is created with `--system-site-packages`: `libvirt-python` comes from the distro packages when its version satisfies `conf/requirements.txt`; otherwise pip builds it, which needs the `-dev`/`-devel` packages listed below. `lxml` is reused the same way; everything else comes from pip. + +#### Ubuntu 22.04 / 24.04 / 26.04, Debian 12 / 13 ```bash -python3 -c 'import secrets; print(secrets.token_urlsafe(50))' -``` +# 1. System packages +apt-get update && apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt libvirt-dev zlib1g-dev libxslt1-dev nginx libsasl2-modules gcc pkg-config python3-guestfs libssl-dev -### Ubuntu 20.04 / 22.04 / 24.04 LTS & Debian 11 / 12 - -```bash -# 1. Install system prerequisites -sudo apt-get update && sudo apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs libsasl2-dev libldap2-dev libssl-dev - -# 2. Clone repository to /srv/webvirtcloud -sudo git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud +# 2. Code +git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud cd /srv/webvirtcloud -# 3. Configure settings +# 3. Settings, allowed hosts and secret key cp webvirtcloud/settings.py.template webvirtcloud/settings.py -SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))') -sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py +sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py +mkdir -p data && python3 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key -# 4. Deploy service configurations -sudo cp conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d/ -sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -sudo rm -f /etc/nginx/sites-enabled/default - -# 5. Create virtual environment and install dependencies +# 4. Virtualenv and dependencies python3 -m venv --system-site-packages venv source venv/bin/activate pip install -r conf/requirements.txt -# 6. Database migrations and static files +# 5. Database and static files (the first migrate creates the "admin" user) python3 manage.py migrate python3 manage.py collectstatic --noinput -# 7. Set permissions and start services -sudo chown -R www-data:www-data /srv/webvirtcloud -sudo systemctl restart nginx supervisor +# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup) and nginx +cp conf/systemd/webvirt-{cloud,novncd,socketiod,worker,backup}.service conf/systemd/webvirt-backup.timer /etc/systemd/system/ +cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +rm -f /etc/nginx/sites-enabled/default + +# 7. Ownership, private files, start (the groups let the services reach a local libvirt and KVM) +chown -R www-data:www-data /srv/webvirtcloud +usermod -a -G libvirt,kvm www-data 2>/dev/null || true # on a KVM host +chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3 +systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer +nginx -t && systemctl restart nginx ``` ---- - -### RHEL 8 / 9 / 10 / Rocky Linux / AlmaLinux +#### RHEL / Rocky Linux / AlmaLinux / Oracle Linux 10 ```bash -# 1. Install EPEL and system prerequisites -sudo dnf -y install epel-release -sudo dnf -y install git python3-devel libvirt-devel python3-libvirt python3-ldap python3-lxml cyrus-sasl-devel cyrus-sasl-md5 openldap-devel openssl-devel glibc gcc nginx supervisor python3-libguestfs iproute-tc +# 1. System packages (libvirt-devel is in the CRB repository) +dnf -y install epel-release && crb enable +dnf -y install git python3-devel libvirt-devel python3-libvirt python3-lxml cyrus-sasl-md5 openssl-devel glibc gcc nginx python3-libguestfs iproute-tc policycoreutils-python-utils -# 2. Clone repository to /srv/webvirtcloud -sudo git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud +# 2. Code +git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud cd /srv/webvirtcloud -# 3. Configure settings +# 3. Settings, allowed hosts and secret key cp webvirtcloud/settings.py.template webvirtcloud/settings.py -SECRET_KEY=$(python3 conf/runit/secret_generator.py) -sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py +sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py +mkdir -p data && python3 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key -# 4. Create virtual environment and install dependencies +# 4. Virtualenv and dependencies python3 -m venv --system-site-packages venv source venv/bin/activate pip install -r conf/requirements.txt -# 5. Database migrations and static files +# 5. Database and static files (the first migrate creates the "admin" user) python3 manage.py migrate python3 manage.py collectstatic --noinput -# 6. Configure Supervisor -sudo tee /etc/supervisord.d/webvirtcloud.ini > /dev/null << 'EOF' -[program:webvirtcloud] -command=/srv/webvirtcloud/venv/bin/gunicorn webvirtcloud.wsgi:application -c /srv/webvirtcloud/gunicorn.conf.py -directory=/srv/webvirtcloud -user=nginx -autostart=true -autorestart=true -redirect_stderr=true +# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup, as nginx) and nginx +for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done +cp conf/systemd/webvirt-backup.timer /etc/systemd/system/ +cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +# make sure the default server block in /etc/nginx/nginx.conf does not also listen on port 80 -[program:novncd] -command=/srv/webvirtcloud/venv/bin/python3 /srv/webvirtcloud/console/novncd -directory=/srv/webvirtcloud -user=nginx -autostart=true -autorestart=true -redirect_stderr=true -EOF - -# 7. Configure Nginx -sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -# Ensure the default server block in /etc/nginx/nginx.conf does not conflict with webvirtcloud.conf - -# 8. Set permissions, SELinux, and Firewall -sudo chown -R nginx:nginx /srv/webvirtcloud -sudo semanage fcontext -a -t httpd_sys_content_t "/srv/webvirtcloud(/.*)" 2>/dev/null || true -sudo restorecon -R /srv/webvirtcloud 2>/dev/null || true -sudo setsebool -P httpd_can_network_connect on 2>/dev/null || true - -sudo firewall-cmd --add-service=http --permanent 2>/dev/null || true -sudo firewall-cmd --add-port=6080/tcp --permanent 2>/dev/null || true -sudo firewall-cmd --reload 2>/dev/null || true - -# 9. Start and enable services -sudo systemctl enable --now nginx supervisord -sudo systemctl restart nginx supervisord +# 7. Ownership, private files, SELinux, firewall, start (the groups let the services reach a local libvirt and KVM) +chown -R nginx:nginx /srv/webvirtcloud +usermod -a -G libvirt,kvm nginx 2>/dev/null || true # on a KVM host +chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3 +# nginx reads the static files, systemd runs the virtualenv's programs +semanage fcontext -a -t httpd_sys_content_t "/srv/webvirtcloud(/.*)?" 2>/dev/null || true +semanage fcontext -a -t bin_t "/srv/webvirtcloud/venv/bin(/.*)?" 2>/dev/null || true +restorecon -R /srv/webvirtcloud 2>/dev/null || true +setsebool -P httpd_can_network_connect on 2>/dev/null || true +firewall-cmd --add-service=http --permanent 2>/dev/null || true +firewall-cmd --reload 2>/dev/null || true +systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer +systemctl enable nginx && nginx -t && systemctl restart nginx ``` ---- - -### openSUSE Leap 15.x / Tumbleweed / SLES 15 +#### openSUSE Leap 15.x / Tumbleweed, SLES 15 ```bash -# 1. Install system prerequisites (Python 3.11 stack and C bindings) -sudo zypper --non-interactive install -y git hostname python311 python311-base python311-devel python311-pip python311-libvirt-python python311-lxml python311-ldap libvirt-devel cyrus-sasl-devel libopenssl-devel gcc pkg-config nginx +# 1. System packages (Python 3.11 stack) +zypper --non-interactive install -y git hostname python311 python311-base python311-devel python311-pip python311-libvirt-python python311-lxml libvirt-devel libopenssl-devel gcc pkg-config nginx -# 2. Clone repository to /srv/webvirtcloud -sudo git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud +# 2. Code +git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud cd /srv/webvirtcloud -# 3. Configure settings +# 3. Settings, allowed hosts and secret key cp webvirtcloud/settings.py.template webvirtcloud/settings.py -SECRET_KEY=$(python3.11 conf/runit/secret_generator.py) -sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py +sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py +mkdir -p data && python3.11 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key -# 4. Create virtual environment and install dependencies +# 4. Virtualenv and dependencies python3.11 -m venv --system-site-packages venv source venv/bin/activate pip install -r conf/requirements.txt -# 5. Database migrations and static files +# 5. Database and static files (the first migrate creates the "admin" user) python3 manage.py migrate python3 manage.py collectstatic --noinput -# 6. Configure Nginx and Supervisor -sudo cp conf/nginx/suse_nginx.conf /etc/nginx/vhosts.d/webvirtcloud.conf 2>/dev/null || sudo cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ -sudo chown -R nginx:nginx /srv/webvirtcloud +# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup, as nginx) and nginx +for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done +cp conf/systemd/webvirt-backup.timer /etc/systemd/system/ +cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/ +# make sure the default server block in /etc/nginx/nginx.conf does not also listen on port 80 -# 7. Start services -sudo systemctl enable --now nginx -sudo systemctl restart nginx +# 7. Ownership, private files, start (the groups let the services reach a local libvirt and KVM) +chown -R nginx:nginx /srv/webvirtcloud +usermod -a -G libvirt,kvm nginx 2>/dev/null || true # on a KVM host +chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3 +systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer +systemctl enable nginx && nginx -t && systemctl restart nginx ``` ---- +### First login -## Local Development Setup - -For developers working locally on WebVirtCloud without running full production services: - -### Rocky Linux / RHEL / Fedora -```bash -# 1. Install system prerequisites and precompiled bindings -sudo dnf -y install python3-devel libvirt-devel python3-libvirt python3-ldap python3-lxml gcc git - -# 2. Create virtual environment with system site packages (enables zero-compilation install) -python3 -m venv --system-site-packages .venv -source .venv/bin/activate - -# 3. Install Python dependencies -pip install -r conf/requirements.txt -pip install -r dev/requirements.txt - -# 4. Initialize configuration and run local dev server -cp webvirtcloud/settings.py.template webvirtcloud/settings.py -sed -i -E 's/SECRET_KEY = .*/SECRET_KEY = "'$(python3 conf/runit/secret_generator.py)'"/' webvirtcloud/settings.py -python manage.py migrate -python manage.py runserver 0.0.0.0:8000 -``` - -### Ubuntu / Debian -```bash -# 1. Install system prerequisites -sudo apt-get update && sudo apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt libvirt-dev zlib1g-dev libldap2-dev libsasl2-dev gcc pkg-config - -# 2. Create virtual environment -python3 -m venv --system-site-packages .venv -source .venv/bin/activate - -# 3. Install Python dependencies -pip install -r conf/requirements.txt -pip install -r dev/requirements.txt - -# 4. Initialize configuration and run local dev server -cp webvirtcloud/settings.py.template webvirtcloud/settings.py -sed -i -E 's/SECRET_KEY = .*/SECRET_KEY = "'$(python3 conf/runit/secret_generator.py)'"/' webvirtcloud/settings.py -python manage.py migrate -python manage.py runserver 0.0.0.0:8000 -``` - -### openSUSE Leap 15.x / Tumbleweed / SLES 15 -```bash -# 1. Install system prerequisites -sudo zypper --non-interactive install -y git hostname python311 python311-base python311-devel python311-pip python311-libvirt-python python311-lxml python311-ldap libvirt-devel cyrus-sasl-devel libopenssl-devel gcc pkg-config - -# 2. Create virtual environment with system site packages -python3.11 -m venv --system-site-packages .venv -source .venv/bin/activate - -# 3. Install Python dependencies -pip install -r conf/requirements.txt -pip install -r dev/requirements.txt - -# 4. Initialize configuration and run local dev server -cp webvirtcloud/settings.py.template webvirtcloud/settings.py -sed -i -E 's/SECRET_KEY = .*/SECRET_KEY = "'$(python3.11 conf/runit/secret_generator.py)'"/' webvirtcloud/settings.py -python manage.py migrate -python manage.py runserver 0.0.0.0:8000 -``` - -## Compute Node (Hypervisor) Setup - -To configure a physical server or virtual machine as a KVM compute node to be managed by WebVirtCloud: - -### 1. Install KVM and Libvirt via Bootstrap Script - -WebVirtCloud includes an automated bootstrap script supporting Ubuntu 20.04/22.04/24.04, Debian 10/11/12, RHEL/Rocky/Alma 8/9/10, openSUSE Leap 15.x / Tumbleweed, and SLES 15: +The first `migrate` creates a superuser named `admin` with a random password, written to `data/admin_password` (mode 0600): ```bash -# Run bootstrap script directly via curl: -curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/dev/libvirt-bootstrap.sh | sudo sh - -# Or run locally from a cloned repository: -sudo ./dev/libvirt-bootstrap.sh +sudo cat /srv/webvirtcloud/data/admin_password # installer or manual installation +docker compose exec webvirtcloud cat data/admin_password # Docker ``` -### 2. Configure SSH Connection Between Panel and Compute Node +Sign in at `http://`; you must set a new password at the first login, and the file is then deleted. To choose the name or password yourself, set `ADMIN_USERNAME` and/or `ADMIN_PASSWORD` in the environment before that first `migrate`; a password you set is not written to the file and need not be changed. A lost password is reset with `python3 manage.py changepassword admin` (Docker: `docker compose exec webvirtcloud /sbin/setuser www-data venv/bin/python3 manage.py changepassword admin`). -On the WebVirtCloud panel host, generate an SSH key for the web service user (`www-data` on Debian/Ubuntu, `nginx` on RHEL/openSUSE): +## Adding a Compute Node + +A compute node is a KVM host the panel manages over libvirt. + +1. Install KVM and libvirt. The bootstrap script handles Ubuntu, Debian, CentOS, Fedora, Rocky Linux and AlmaLinux 10, openEuler 24.03 LTS, openSUSE and SLES. On RHEL, which needs EPEL from its release RPM, install the `qemu-kvm`, `libvirt` and `python3-libguestfs` (for gstfsd, step 3) packages yourself and enable `virtqemud.socket` (plus `virtproxyd-tcp.socket` for TCP): + + ```bash + curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/dev/libvirt-bootstrap.sh | sudo sh + # or, from a cloned repository: sudo sh ./dev/libvirt-bootstrap.sh + ``` + + The new VM form offers UEFI, UEFI Secure Boot (with Microsoft's keys, for Windows 11) and a TPM 2.0 only when the host's libvirt finds the UEFI firmware and swtpm: install `ovmf swtpm-tools` (Debian / Ubuntu) or `edk2-ovmf swtpm swtpm-tools` (RHEL family, openEuler) if they are missing. + +2. For SSH connections, give the user the panel runs as an SSH key and copy it to the compute node. **Use only the block for your installation type:** a key set up with the host commands is not seen by the panel in Docker. `ssh-copy-id` asks for the compute node's root password once. `accept-new` accepts a new host's key on first connect and refuses a changed one. + + **Installer or manual installation**, on the panel host: + + ```bash + U=www-data # the web service user: www-data on Debian/Ubuntu, nginx on RHEL/openSUSE + H=$(getent passwd $U | cut -d: -f6) + sudo install -d -m 700 -o $U -g $U $H/.ssh + sudo -u $U ssh-keygen -t ed25519 -N "" -f $H/.ssh/id_ed25519 + printf 'Host *\n StrictHostKeyChecking accept-new\n' | sudo -u $U tee $H/.ssh/config > /dev/null + sudo chmod 600 $H/.ssh/config + sudo -u $U ssh-copy-id root@ + sudo -u $U ssh root@ true # no password or prompt: ready + ``` + + **Docker**, on the Docker host from the directory with `docker-compose.yml`; the commands run inside the container (always as `www-data`: the keys are kept in the `webvirtcloud-ssh` volume, and a key made as root is not used by the panel): + + ```bash + docker compose exec -u www-data webvirtcloud ssh-keygen -t ed25519 -N "" -f /var/www/.ssh/id_ed25519 + docker compose exec -u www-data webvirtcloud sh -c 'printf "Host *\n StrictHostKeyChecking accept-new\n" > ~/.ssh/config && chmod 600 ~/.ssh/config' + docker compose exec -u www-data webvirtcloud ssh-copy-id root@ + docker compose exec -u www-data webvirtcloud ssh root@ true # no password or prompt: ready + ``` + + For TCP connections, the bootstrap script makes libvirt ask for a SASL login. Create it on the compute node and enter it, with its password, on the Computes page (`saslpasswd2` is in `sasl2-bin` on Debian / Ubuntu and in `cyrus-sasl` on the RHEL family): + + ```bash + sudo saslpasswd2 -a libvirt # asks for its password + ``` + + For TLS connections, put the CA certificate and the panel's client certificate and key in the panel user's `~/.pki/libvirt/` as `cacert.pem`, `clientcert.pem` and `clientkey.pem`, readable only by that user: `$H/.pki/libvirt/` with the installer; in Docker `/var/www/.pki/libvirt/` (the `webvirtcloud-pki` volume), owned by `www-data`. On the compute node, install the same CA certificate and a server certificate whose subjectAltName is the address the panel uses, then let libvirt listen for TLS: + + ```bash + sudo install -D -m 644 cacert.pem /etc/pki/CA/cacert.pem + sudo install -D -m 644 servercert.pem /etc/pki/libvirt/servercert.pem + sudo install -D -m 600 serverkey.pem /etc/pki/libvirt/private/serverkey.pem + # RHEL family 10 (the modular daemons: the bootstrap enabled virtproxyd-tcp.socket): + sudo systemctl stop virtproxyd.service; sudo systemctl enable --now virtproxyd-tls.socket + # the other systems (libvirtd): + sudo systemctl stop libvirtd.service; sudo systemctl enable --now libvirtd-tls.socket && sudo systemctl start libvirtd.service + ``` + +3. Install or update the `gstfsd` daemon on the compute node (the bootstrap script does this too). It sets the root password and SSH key of a shut-off VM: + + ```bash + curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/conf/daemon/gstfsd | sudo tee /usr/local/bin/gstfsd > /dev/null + sudo chmod +x /usr/local/bin/gstfsd + curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/conf/systemd/webvirt-gstfsd.service | sudo tee /etc/systemd/system/webvirt-gstfsd.service > /dev/null + # its key, once: keep the file when you update gstfsd + [ -s /etc/default/webvirt-gstfsd ] || sudo sh -c 'umask 077 && echo "GSTFSD_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")" > /etc/default/webvirt-gstfsd' + sudo chown root:root /etc/default/webvirt-gstfsd && sudo chmod 600 /etc/default/webvirt-gstfsd + sudo systemctl daemon-reload && sudo systemctl enable webvirt-gstfsd && sudo systemctl restart webvirt-gstfsd + sudo cat /etc/default/webvirt-gstfsd # the key for step 4 + ``` + + gstfsd does only what the panel signs with this key. It listens on 127.0.0.1:16510 only, which serves a panel on the compute itself that adds it as `localhost` or `127.0.0.1`. A panel on another machine or in a container (the Docker setup, also on the compute) reaches it at the compute's address; let it in: + + ```bash + sudo sed -i '/^GSTFSD_BIND_HOST=/d; /^GSTFSD_ALLOW_REMOTE=/d' /etc/default/webvirt-gstfsd + printf 'GSTFSD_BIND_HOST=0.0.0.0\nGSTFSD_ALLOW_REMOTE=1\n' | sudo tee -a /etc/default/webvirt-gstfsd > /dev/null + sudo systemctl restart webvirt-gstfsd + ``` + + Its traffic is not encrypted, and a password hash crosses it: allow TCP 16510 only from the panel's IP, on a network you trust. + + A running VM gets them through its QEMU guest agent instead, without gstfsd. On RHEL-family guests, SELinux lets the agent add SSH keys only after `sudo setsebool -P virt_qemu_ga_manage_ssh on` inside the guest. + + A compute that ran gstfsd under supervisor: remove its program first (`/etc/supervisor/conf.d/gstfsd.conf` on Debian / Ubuntu, `/etc/supervisord.d/gstfsd.ini` on RHEL / openSUSE), run `sudo supervisorctl update`, and remove supervisor once nothing else uses it. + +4. Add the compute on the panel's Computes page, with the `GSTFSD_KEY` value from step 3 as its gstfsd key. + +Firewall: the compute's VNC ports (`5900`–`65535`) must accept connections **only** from the panel's IP, never from public networks. + +### Migration + +- **Shared storage:** define it as a storage pool on every host. A VM migrates when its disk, backing and ISO files and block devices are volumes of the destination's pools. +- **Address:** a migrating VM goes to the destination's address on the Computes page; for a dedicated migration network, set the compute's **Migration address** on its edit page. +- **Without shared storage:** after choosing the destination, the **Disks** list shows each disk and the destination pools that can take a copy (directory, filesystem and NFS pools; a pool with a file of the same name refuses it); a disk on storage both hosts share can stay there. Plain raw or qcow2 disks without a backing file are copied, through the destination's migration port range (49152-49215 by default). **Delete the source files of copied disks** removes them after the migration, unless another VM or volume uses them. +- **Shut-off VMs:** a shut-off VM moves offline, with its NVRAM file (UEFI). When its disks are copied or it has a TPM, it is started paused for the migration instead (its CPUs never run) and shut off on the destination. + +If libvirt warns `Host SMBIOS information is not available`, install `dmidecode` (`apt-get`, `dnf` or `zypper install -y dmidecode`) and restart `libvirtd`. + +## Configuration + +### Environment variables + +| Variable | Effect | +| --- | --- | +| `ALLOWED_HOSTS` | extra host names/IPs the panel answers to (comma-separated, `*` for any); other hosts get 400 | +| `CSRF_TRUSTED_ORIGINS` | extra trusted origins, e.g. `https://wvc.example.com` | +| `WEBVIRTCLOUD_HTTPS=1` | HTTPS profile, see [HTTPS and reverse proxy](#https-and-reverse-proxy) | +| `WEBVIRTCLOUD_SERIAL_CONSOLE=1` | serial console, see [Serial console](#serial-console) | +| `WEBVIRTCLOUD_DB_HOST` | use PostgreSQL instead of sqlite (new installs); `WEBVIRTCLOUD_DB_PORT`/`_NAME`/`_USER`/`_PASSWORD` default to 5432/webvirtcloud/webvirtcloud/empty | +| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS` | SMTP for e-mail OTP | +| `WEBVIRTCLOUD_LOG_FILE` | log file (default `data/webvirtcloud.log`, rotated at 10 MB × 5) | +| `SECRET_KEY` | Django secret key (default: the `data/secret_key` file) | +| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | the first superuser, read by the first `migrate` only, see [First login](#first-login) | + +Where to set them: + +- **Docker:** the `environment:` list of `docker-compose.override.yml`; every process in the container gets it. +- **Installer and manual installation:** edit `webvirtcloud/settings.py`, or put them in `/etc/default/webvirtcloud` (root, mode 600), one `NAME='value'` per line (single quotes: systemd and the shell then read the same value); the panel's services read it. Then `systemctl restart webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker`, and load the file before running `manage.py`: `set -a; . /etc/default/webvirtcloud; set +a`. + +Some options exist only in `settings.py` (LDAP, `OTP_ENABLED`, `LOGIN_TRUSTED_PROXIES`). Docker builds that file from the template in each new container; to keep such options there, mount your own copy and update it from the template after each update: ```bash -# Generate key (Debian/Ubuntu example using www-data): -sudo -u www-data ssh-keygen -t ed25519 -sudo -u www-data tee ~www-data/.ssh/config > /dev/null << 'EOF' -Host * - StrictHostKeyChecking no -EOF -sudo chmod 600 ~www-data/.ssh/config - -# Copy public key to the compute node root user: -sudo -u www-data ssh-copy-id root@ +docker compose exec webvirtcloud cat webvirtcloud/settings.py > settings.py # then edit it ``` -### 3. Install or Update `gstfsd` Daemon +add it to `docker-compose.override.yml` and run `docker compose up -d`: -The `gstfsd` daemon provides guest filesystem inspection and stats on hypervisors: - -```bash -curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/conf/daemon/gstfsd | sudo tee /usr/local/bin/gstfsd > /dev/null -sudo chmod +x /usr/local/bin/gstfsd -sudo systemctl restart supervisor 2>/dev/null || sudo systemctl restart supervisord +```yaml +services: + webvirtcloud: + volumes: + - ./settings.py:/srv/webvirtcloud/webvirtcloud/settings.py ``` -### 4. Troubleshooting: Host SMBIOS Warning +The container makes the file the panel user's (uid 33) with mode 600; edit it as root. -If you see the warning `Unsupported configuration: Host SMBIOS information is not available`, install `dmidecode` and restart libvirt: +gunicorn runs at most 8 workers (fewer on hosts with under 4 CPUs) of 4 threads each; to change it, set `GUNICORN_CMD_ARGS=--workers N` in a drop-in of `webvirt-cloud` (`systemctl edit webvirt-cloud`: `[Service]` and `Environment="GUNICORN_CMD_ARGS=--workers N"`) or in `docker-compose.override.yml`. -```bash -# Debian / Ubuntu: -sudo apt-get install -y dmidecode && sudo systemctl restart libvirtd +### HTTPS and reverse proxy -# RHEL / Rocky / AlmaLinux: -sudo dnf install -y dmidecode && sudo systemctl restart libvirtd +For a panel served over HTTPS: -# openSUSE / SLES: -sudo zypper install -y dmidecode && sudo systemctl restart libvirtd -``` +- set `WEBVIRTCLOUD_HTTPS=1`: session and CSRF cookies become HTTPS-only, HTTP is redirected to HTTPS (except `/datasource/`, which VMs fetch over HTTP), and HSTS is sent for one year; +- add the HTTPS origin to `CSRF_TRUSTED_ORIGINS`, e.g. `CSRF_TRUSTED_ORIGINS=https://wvc.example.com` and `WEBVIRTCLOUD_HTTPS=1` in `/etc/default/webvirtcloud`. -> **Security Notice (Compute Node Firewall):** -> Libvirt compute nodes listen on VNC/SPICE ports (`5900`–`65535`) to allow WebVirtCloud to proxy graphical consoles. Ensure your firewall (`ufw`, `firewalld`, or `iptables`) restricts these ports to accept connections **only** from the WebVirtCloud panel IP, and never exposes them directly to public networks. +TLS can end at the bundled nginx or at a proxy in front of it; such a proxy must send `X-Forwarded-Proto: https`. Failed logins are counted per client address, which the bundled nginx passes in `X-Real-IP`. A proxy that replaces the bundled nginx must set `X-Real-IP` itself; one on another machine (gunicorn bound beyond 127.0.0.1) must also be in `LOGIN_TRUSTED_PROXIES` in `settings.py`. With a proxy in front of the bundled nginx, configure nginx's `set_real_ip_from` / `real_ip_header` for that proxy, or all clients share its address. ---- +Every page is sent with `X-Frame-Options: DENY` and `Content-Security-Policy: script-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'`: the panel runs only scripts it serves itself, cannot be shown in a frame, and its forms post only to its own origin. -## Configuration & Operational Notes +### Cloud-init datasource -### Default Credentials - -After initial installation, sign in to the web panel at `http://`: - -```text -Username: admin -Password: admin -``` -> **Security Notice:** Change the default administrator password immediately after first login. - -### Alternative: Running novncd via runit (Debian) - -As an alternative to Supervisor, Debian systems can manage `novncd` via `runit`: - -```bash -sudo apt install -y runit runit-systemd -sudo mkdir -p /etc/service/novncd/ -sudo ln -s /srv/webvirtcloud/conf/runit/novncd.sh /etc/service/novncd/run -sudo systemctl start runit.service -``` - -### Cloud-Init Datasource - -WebVirtCloud can serve cloud-init metadata (root SSH keys and hostname) to guest instances: +WebVirtCloud serves cloud-init metadata to guest instances: the hostname, and the SSH keys of the VM's users for the image's default user. The host in the URL must be in `ALLOWED_HOSTS`: ```yaml datasource: OpenStack: - metadata_urls: [ "http://webvirtcloud.domain.com/datasource" ] + metadata_urls: [ "http://wvc.example.com/datasource" ] ``` -### Reverse-Proxy & Port Forwarding +Without the network: tick **Cloud-init seed** (Advanced) when creating a VM. The VM gets a CD-ROM with its hostname and your SSH keys (a NoCloud seed, `cidata-.iso` in the disk's directory pool), which the image's cloud-init reads at first boot; it is deleted with the VM's disks. -If WebVirtCloud runs behind a reverse proxy terminating SSL or forwarding port 80/443, configure `WS_PUBLIC_PORT` in `webvirtcloud/settings.py` (default: 6080): +### LDAP -```python -WS_PUBLIC_PORT = 80 # or 443 -``` +The options below are set in `webvirtcloud/settings.py` (Docker: see [Environment variables](#environment-variables) for keeping your own). Variants for Active Directory and OpenLDAP are shown; this is a minimal config, see the [django-auth-ldap documentation](https://django-auth-ldap.readthedocs.io) for more. -## How To Update +The Docker image includes the LDAP packages. On an installer or manual install, add them to the virtualenv first; pip builds `python-ldap` against these headers: ```bash -# Go to Installation Directory -cd /srv/webvirtcloud -source venv/bin/activate -git pull -pip3 install -U -r conf/requirements.txt -python3 manage.py migrate -python3 manage.py collectstatic --noinput -sudo service supervisor restart +apt-get -y install libldap2-dev libsasl2-dev # Ubuntu / Debian +dnf -y install openldap-devel cyrus-sasl-devel # RHEL / Rocky / AlmaLinux +zypper --non-interactive install -y openldap2-devel cyrus-sasl-devel # openSUSE +/srv/webvirtcloud/venv/bin/pip3 install -r /srv/webvirtcloud/conf/requirements-ldap.txt ``` -> **Note on Settings Upgrade:** -> When upgrading from earlier versions using `drf-yasg`, update your `webvirtcloud/settings.py`: -> 1. In `INSTALLED_APPS`, replace `'drf_yasg'` with `'drf_spectacular'` and `'drf_spectacular_sidecar'`. -> 2. Ensure the `REST_FRAMEWORK` and `SPECTACULAR_SETTINGS` configuration blocks are present (see `webvirtcloud/settings.py.template`). - -## Running Tests - -WebVirtCloud includes unit tests for both Django models/views and the `vrtManager` libvirt abstraction layer. The test suite uses isolated mock drivers by default and does not require a live KVM hypervisor. - -### 1. Setup Virtual Environment -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r conf/requirements.txt -pip install -r dev/requirements.txt -``` - -### 2. Run Test Suite -```bash -# Run Django test suite (accounts, admin, instances, logs, etc.): -python manage.py test - -# Run vrtManager unit tests: -python -m unittest discover -s vrtManager -p "test_*.py" -``` - -> **Live Hypervisor Testing (Optional):** -> To run tests against a live libvirt host instead of standalone mocks, set the `WEBVIRTCLOUD_TEST_LIBVIRT_URI` environment variable before running tests: -> ```bash -> export WEBVIRTCLOUD_TEST_LIBVIRT_URI="qemu+ssh://root@compute1/system" -> python manage.py test -> ``` - -## LDAP Configuration - -The config options below can be changed in `webvirtcloud/settings.py` file. Variants for Active Directory and OpenLDAP are shown. This is a minimal config to get LDAP running, for further info read the [django-auth-ldap documentation](https://django-auth-ldap.readthedocs.io). - -Enable LDAP +Enable the LDAP backend: ```bash sudo sed -i "s~#\"django_auth_ldap.backend.LDAPBackend\",~\"django_auth_ldap.backend.LDAPBackend\",~g" /srv/webvirtcloud/webvirtcloud/settings.py ``` -Set the LDAP server name and bind DN +Set the LDAP server and bind DN: ```python # Active Directory @@ -433,11 +423,11 @@ AUTH_LDAP_BIND_PASSWORD = "password" # OpenLDAP AUTH_LDAP_SERVER_URI = "ldap://example.com" -AUTH_LDAP_BIND_DN = "CN=username,CN=Users,OU=example,OU=com" +AUTH_LDAP_BIND_DN = "cn=username,ou=users,dc=example,dc=com" AUTH_LDAP_BIND_PASSWORD = "password" ``` -Set the user filter and user and group search base and filter +Set the user and group search base and filter: ```python # Active Directory @@ -451,21 +441,21 @@ AUTH_LDAP_GROUP_TYPE = NestedActiveDirectoryGroupType() # OpenLDAP AUTH_LDAP_USER_SEARCH = LDAPSearch( - "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(cn=%(user)s)" + "ou=users,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" ) AUTH_LDAP_GROUP_SEARCH = LDAPSearch( - "CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(objectClass=groupOfUniqueNames)" + "ou=groups,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(objectClass=groupOfUniqueNames)" ) AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType() # import needs to be changed at the top of settings.py ``` -Set group which is required to access WebVirtCloud. You may set this to `False` to disable this filter. +Set the group required to access WebVirtCloud (`False` disables this filter): ```python AUTH_LDAP_REQUIRE_GROUP = "CN=WebVirtCloud Access,CN=Users,DC=example,DC=com" ``` -Populate user fields with values from LDAP +Populate user fields from LDAP: ```python AUTH_LDAP_USER_FLAGS_BY_GROUP = { @@ -479,34 +469,488 @@ AUTH_LDAP_USER_ATTR_MAP = { } ``` -Now when you login with an LDAP user it will be assigned the rights defined. The user will be authenticated then with LDAP and authorized through the WebVirtCloud permissions. +An LDAP user is authenticated by LDAP and authorized through the WebVirtCloud permissions. To move a user from LDAP to WebVirtCloud, change its password in the UI and remove it from the LDAP group. -If you'd like to move a user from ldap to WebVirtCloud, just change its password from the UI and (eventually) remove from the group in LDAP. +### Two-factor login (OTP) +Set `OTP_ENABLED = True` in `settings.py` to ask for a one-time code at login. It is per user: a user without a confirmed device signs in with the password alone. -## REST API (OpenAPI 3.0) +The QR code is mailed as a link to a page that shows it, good for one hour: the mail does not hold the code's secret. Email OTP QR code on a user's page (Users) sends it; Lost OTP? on the login page sends it to an enrolled user who gives their address. -WebVirtCloud provides a REST API powered by Django REST Framework and documented via `drf-spectacular`. +### Serial console -You can access the interactive API documentation and schema endpoints in your browser: +A VM without graphics (no `` device, a ``) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set `WEBVIRTCLOUD_SERIAL_CONSOLE=1` (Docker: in `docker-compose.override.yml`, then `docker compose up -d`) or `SERIAL_CONSOLE_ENABLED = True` in `settings.py` and `systemctl restart webvirt-cloud webvirt-socketiod`. -* **Swagger UI:** `http:///swagger/` -* **ReDoc UI:** `http:///redoc/` -* **OpenAPI 3.0 Schema:** `http:///api/schema/` (download schema in JSON or YAML format) +- The VM page's Settings → Console switches a shut-off VM between VNC and serial (Console type). A new VNC console takes the form's other fields; on a TCP or TLS compute it needs Listen on: All interfaces (SSH and socket computes reach a localhost console). +- The guest must use its serial port: a login prompt needs `systemctl enable --now serial-getty@ttyS0`, boot messages need the kernel parameter `console=ttyS0,115200`. The console shows only what the guest writes after it opens; press Enter for a new prompt. +- socketiod listens on 127.0.0.1:6081; the page reaches it through nginx's `/socket.io/` location on the panel's own address. +- It accepts the console page's signed token, as noVNC does: whoever gets that token within 120 s gets the console. An open console closes within 10 s once its user loses access to the VM. +- One console per VM, at most 20 at a time; a second one is refused with the reason, except a superuser's, which takes the console over. +- novncd and socketiod log to the journal (`journalctl -u webvirt-novncd -u webvirt-socketiod`) or, in Docker, to `docker logs`: a console that was refused or could not connect says why there. -## Screenshots +## Backups -| Instance Detail | -|:---:| -| ![Instance Detail](doc/images/instance.PNG) | +The panel backs itself up every day at 03:15 (cron in Docker, `webvirt-backup.timer` otherwise) into a new directory under `/srv/webvirtcloud/backups`, in Docker the `webvirtcloud-backups` volume, and keeps the newest 7. A backup holds: -| Grouped Instances | Non-Grouped Instances | +- `db.sqlite3`: a consistent copy taken while the panel runs, checked with `PRAGMA integrity_check`; with PostgreSQL it is left out, so back up that database yourself +- `secret_key`, `node_id` and `settings.py` +- `ssh/`: the SSH keys of the panel's user, which the SSH computes accept +- `pki/`: the TLS client certificates of the panel's user, which the TLS computes accept +- `commit`, outside Docker + +The database holds compute passwords and keys: the directories are readable only by the panel's user. They are on the same host as the panel; the [off-site copy](#off-site-copy) keeps them on another server too. A failed backup is reported in `docker logs` or by `systemctl status webvirt-backup` and deletes no older backup. + +Take one now: + +```bash +docker compose exec webvirtcloud /sbin/setuser www-data venv/bin/python3 manage.py backup # Docker +systemctl start webvirt-backup && journalctl -u webvirt-backup -n 3 # otherwise +``` + +To restore one, follow [Restore and rollback](#restore-and-rollback) with `b` set to its directory. In Docker, copy it out of the volume first (`docker compose cp webvirtcloud:/srv/webvirtcloud/backups/webvirtcloud-backup-YYYYMMDD-HHMMSS ..`) and leave out the `git checkout` and `build` lines: the backup has no commit. To move the panel to a new machine, see [Moving to a new machine](#moving-to-a-new-machine). + +### Off-site copy + +`webvirt-offsite.timer` copies the backups every day at 03:45 with [restic](https://restic.net) over SFTP to another server, encrypted with a password that only you and the panel's host hold, and keeps 7 daily, 4 weekly and 6 monthly copies there. It runs on the panel's host as root, also with Docker. It fails, and `journalctl -u webvirt-offsite` says why, when the newest backup is older than 26 hours or the copy does not finish. + +On the other server, create the user the copies go to (`wvcbackup` on `backup.example.com` below): + +```bash +useradd -m wvcbackup && install -d -o wvcbackup -m 700 ~wvcbackup/.ssh +``` + +On the panel's host, from a root shell in the panel's checkout (`/srv/webvirtcloud`, or the clone Docker runs from): + +1. Install restic and curl (RHEL: `dnf -y install epel-release && dnf -y install restic`, openSUSE: `zypper -n install restic curl`) and the job: + + ```bash + apt-get -y install restic curl + install -m 755 conf/webvirt-offsite.sh /usr/local/sbin/webvirt-offsite + cp conf/systemd/webvirt-offsite.service conf/systemd/webvirt-offsite.timer /etc/systemd/system/ + ``` + +2. Give the host a key of its own for the other server: + + ```bash + install -d -m 700 /root/.ssh + ssh-keygen -q -t ed25519 -N '' -f /root/.ssh/webvirtcloud-offsite + cat >> /root/.ssh/config <<'EOF' + Host webvirtcloud-offsite + HostName backup.example.com + User wvcbackup + IdentityFile /root/.ssh/webvirtcloud-offsite + EOF + ``` + + Add `/root/.ssh/webvirtcloud-offsite.pub` to `~wvcbackup/.ssh/authorized_keys` on the other server (owned by `wvcbackup`, mode 600), then connect once to record the server's key: + + ```bash + ssh -o StrictHostKeyChecking=accept-new webvirtcloud-offsite true + ``` + +3. Write the settings with a new password and create the repository. With Docker, set `WVC_BACKUPS` to the volume, `$(docker volume inspect -f '{{.Mountpoint}}' webvirtcloud_webvirtcloud-backups)`. `WVC_HEARTBEAT` is optional: a URL called after each good copy, such as a [healthchecks.io](https://healthchecks.io) check, which tells you when a day passes without one. + + ```bash + install -m 600 /dev/null /etc/webvirtcloud-offsite.env + cat > /etc/webvirtcloud-offsite.env < "$b/commit" && +git checkout master && git pull && +docker compose build && +docker compose stop -t 95 && +docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \ + -c "import sqlite3,sys,tempfile; t=tempfile.mktemp(); sqlite3.connect('data/db.sqlite3').backup(sqlite3.connect(t)); sys.stdout.buffer.write(open(t,'rb').read())" > "$b/db.sqlite3.part" && +mv "$b/db.sqlite3.part" "$b/db.sqlite3" && +docker compose up -d +``` + +### Moving from supervisor to systemd + +Versions up to v3.0.0 ran the panel under supervisor. Once, before the update steps below, stop it there, keep its config and remove it: + +```bash +supervisorctl stop webvirtcloud novncd socketiod webvirt-worker +mv /etc/supervisor/conf.d/webvirtcloud.conf /root/webvirtcloud-supervisor.conf # Debian / Ubuntu +mv /etc/supervisord.d/webvirtcloud.ini /root/webvirtcloud-supervisor.conf # RHEL / openSUSE +supervisorctl update +``` + +Move the `environment=` values of that config into `/etc/default/webvirtcloud` (root, mode 600), one `NAME='value'` per line; leave out the worker's `WEBVIRTCLOUD_LOG_FILE`. Step 4 installs and starts the systemd units. Remove supervisor once nothing else uses it; a compute moves gstfsd as in [Adding a Compute Node](#adding-a-compute-node), step 3. + +### Installer and manual installation + +`git pull` never overwrites `webvirtcloud/settings.py` or `/etc/nginx/conf.d/webvirtcloud.conf`; steps 2 and 4 bring them up to date. Run the steps from a root shell; they use `$b`, so in a new shell set it again to the backup directory. + +1. Stop the panel, back up and pull: + + ```bash + systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker + # once: root may use the repo, which belongs to the service user + git config --global --add safe.directory /srv/webvirtcloud + b=/root/webvirtcloud-backup-$(date +%Y%m%d-%H%M%S) + cd /srv/webvirtcloud && + ! systemctl is-active --quiet webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker && + mkdir -m 700 "$b" && + venv/bin/python3 -c "import sqlite3,sys; sqlite3.connect('db.sqlite3').backup(sqlite3.connect(sys.argv[1]))" "$b/db.sqlite3.part" && + mv "$b/db.sqlite3.part" "$b/db.sqlite3" && + cp -p webvirtcloud/settings.py /etc/nginx/conf.d/webvirtcloud.conf "$b"/ && + { [ ! -f /etc/default/webvirtcloud ] || cp -p /etc/default/webvirtcloud "$b"/; } && + git rev-parse HEAD > "$b/commit" && + git ls-files -z --others --exclude-standard -- '*/migrations/*.py' | tar --null -T - --remove-files -cf "$b/local-migrations.tar" && + git checkout -- $(git ls-files -m -- static dev/scss install.sh webvirtcloud.sh) && + git checkout master && git pull + ``` + + Older installers collected static files into the tracked `static/` and deleted the tracked `install.sh` and `webvirtcloud.sh`; the second-to-last line puts those files back, so that `git pull` can update them. + +2. Rebuild `settings.py` from the template (the file keeps its owner and mode), then put back your own values; the `diff` lists them: + + ```bash + cat webvirtcloud/settings.py.template > webvirtcloud/settings.py + diff "$b/settings.py" webvirtcloud/settings.py + ``` + + Typical ones are `TIME_ZONE`, `WS_*`, `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, LDAP and, with PostgreSQL, your `DATABASES`: the template's is sqlite, so without it `migrate` would make a new, empty sqlite database and the panel would start on that. Two need care: + - a literal `SECRET_KEY = "..."` (older installers wrote it there): put the value into `data/secret_key` instead (`mkdir -p data` first if there is no `data/`). With no key, `migrate` in step 3 stops with "The SECRET_KEY setting must not be empty" and every page fails; with another key, every user is signed out; + - `SHOW_PROFILE_EDIT_PASSWORD`: keep it for this one `migrate` if you have it (migration `accounts.0008` reads it), then delete it. + +3. Install, migrate, and fix ownership and modes (`nginx:nginx` instead of `www-data:www-data` on RHEL / openSUSE). With LDAP, also run `venv/bin/pip3 install -U -r conf/requirements-ldap.txt` (headers: [LDAP](#ldap)): + + ```bash + venv/bin/pip3 install -U -r conf/requirements.txt && + { ! tar -tf "$b/local-migrations.tar" | grep -qx accounts/migrations/0006_alter_userattributes_id_alter_userinstance_id_and_more.py || + venv/bin/python3 manage.py migrate accounts 0005 --fake; } && + venv/bin/python3 manage.py migrate && + venv/bin/python3 manage.py collectstatic --noinput && + chown -R www-data:www-data /srv/webvirtcloud && + chmod -R go-rwx data webvirtcloud/settings.py db.sqlite3 + ``` + + Installers from before September 2025 generated migrations of their own; step 1 moved them into `local-migrations.tar`. One of them has the name of a tracked migration, which `migrate` would skip; the second line marks it unapplied so that the tracked one runs. + +4. Update the nginx config the same way as `settings.py`: + + ```bash + cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/webvirtcloud.conf + diff "$b/webvirtcloud.conf" /etc/nginx/conf.d/webvirtcloud.conf + ``` + + Put back your own lines (`server_name`, TLS, the noVNC port). Install the systemd units; your own service settings stay in `/etc/default/webvirtcloud` and in drop-ins. Debian / Ubuntu: + + ```bash + cp conf/systemd/webvirt-{cloud,novncd,socketiod,worker,backup}.service conf/systemd/webvirt-backup.timer /etc/systemd/system/ + ``` + + RHEL / openSUSE: + + ```bash + for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done + cp conf/systemd/webvirt-backup.timer /etc/systemd/system/ + ``` + + Then reload nginx and start the panel: + + ```bash + nginx -t && systemctl reload nginx && systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer + ``` + +Older versions collected static files into `static/`; `git clean -n static/` lists the leftovers, which can be deleted. + +### Restore and rollback + +Restoring puts back the database as it was at the backup; changes made since are lost. Migrations are not reversed. This covers sqlite; restore a PostgreSQL database from your own backup before rolling back the code. Set `b` to the backup directory to restore. To restore the database without rolling back the code, leave out the `build` line and every line that takes another file from `$b`: the commit, `local-migrations.tar`, `settings.py`, the nginx config and `/etc/default/webvirtcloud`. + +Docker, in the directory you installed from: + +```bash +cd webvirtcloud && +b=../webvirtcloud-backup-YYYYMMDD-HHMMSS && +docker compose stop -t 95 && +docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \ + -c "import os,sqlite3,sys,tempfile; t=tempfile.mktemp(); open(t,'wb').write(sys.stdin.buffer.read()); [os.remove(p) for p in ('data/db.sqlite3-journal','data/db.sqlite3-wal','data/db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(t).backup(sqlite3.connect('data/db.sqlite3'))" < "$b/db.sqlite3" && +git checkout "$(cat "$b/commit")" && +docker compose build && +docker compose up -d # the container fixes ownership and modes at start +``` + +Installer and manual installation, from a root shell (`nginx:nginx` on RHEL / openSUSE): + +```bash +b=/root/webvirtcloud-backup-YYYYMMDD-HHMMSS +systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker +cd /srv/webvirtcloud && +! systemctl is-active --quiet webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker && +git checkout -- $(git ls-files -m -- static/css dev/scss) && +git checkout "$(cat "$b/commit")" && +{ [ ! -f "$b/local-migrations.tar" ] || tar -xf "$b/local-migrations.tar"; } && +venv/bin/python3 -c "import os,sqlite3,sys; [os.remove(p) for p in ('db.sqlite3-journal','db.sqlite3-wal','db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(sys.argv[1]).backup(sqlite3.connect('db.sqlite3'))" "$b/db.sqlite3" && +cp "$b/settings.py" webvirtcloud/settings.py && +{ [ ! -f "$b/webvirtcloud.conf" ] || cp "$b/webvirtcloud.conf" /etc/nginx/conf.d/webvirtcloud.conf; } && +venv/bin/pip3 install -r conf/requirements.txt && +venv/bin/python3 manage.py migrate && +venv/bin/python3 manage.py collectstatic --noinput && +chown -R www-data:www-data /srv/webvirtcloud && +chmod 600 db.sqlite3 webvirtcloud/settings.py +``` + +Put back `/etc/default/webvirtcloud` if the backup has it (`cp -p "$b/webvirtcloud" /etc/default/`), then reload nginx and start the panel: + +```bash +nginx -t && systemctl reload nginx && systemctl start webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker +``` + +A version from before v3.0.0 has no worker: `systemctl disable --now webvirt-worker`. A rollback keeps the systemd units. A rollback leaves the clone on a detached HEAD at the restored commit; the update steps record that commit before they switch back to `master`. + +### Moving to a new machine + +A [backup](#backups) moves the panel to a new machine, or back onto lost volumes: the database, the secret key (signed-in users stay signed in), the worker's id and the SSH keys and TLS certificates the computes accept. Install the panel there first, the same version or a newer one, then copy the backup directory over and set `b` to it. The backup does not hold `docker-compose.override.yml`, the nginx config or `/etc/default/webvirtcloud`: bring your own lines over yourself. Users sign in with their passwords from the backup; the new installation's `data/admin_password` no longer applies. A `SECRET_KEY` set in the compose file has no `secret_key` in the backup: set the same value on the new machine. With PostgreSQL, stop the old panel before the new one starts: both would run the jobs of the one database. + +Docker, in the directory you installed from: + +```bash +cd webvirtcloud && +b=../webvirtcloud-backup-YYYYMMDD-HHMMSS && +docker compose stop -t 95 && +docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \ + -c "import os,sqlite3,sys,tempfile; t=tempfile.mktemp(); open(t,'wb').write(sys.stdin.buffer.read()); [os.remove(p) for p in ('data/db.sqlite3-journal','data/db.sqlite3-wal','data/db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(t).backup(sqlite3.connect('data/db.sqlite3'))" < "$b/db.sqlite3" && +{ [ ! -f "$b/secret_key" ] || docker compose cp "$b/secret_key" webvirtcloud:/srv/webvirtcloud/data/; } && +docker compose cp "$b/node_id" webvirtcloud:/srv/webvirtcloud/data/ && +{ [ ! -d "$b/ssh" ] || docker compose cp "$b/ssh/." webvirtcloud:/var/www/.ssh/; } && +{ [ ! -d "$b/pki" ] || docker compose cp "$b/pki/." webvirtcloud:/var/www/.pki/; } && +docker compose up -d # the container migrates and fixes ownership and modes at start +``` + +Installer and manual installation, from a root shell (`nginx` instead of `www-data` on RHEL / openSUSE). Compare `$b/settings.py` with `webvirtcloud/settings.py` and carry your own values over: + +```bash +b=/root/webvirtcloud-backup-YYYYMMDD-HHMMSS +systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker +cd /srv/webvirtcloud && +venv/bin/python3 -c "import os,sqlite3,sys; [os.remove(p) for p in ('db.sqlite3-journal','db.sqlite3-wal','db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(sys.argv[1]).backup(sqlite3.connect('db.sqlite3'))" "$b/db.sqlite3" && +cp "$b/node_id" data/ && { [ ! -f "$b/secret_key" ] || cp "$b/secret_key" data/; } && +mkdir -p ~www-data/.ssh && { [ ! -d "$b/ssh" ] || cp "$b"/ssh/* ~www-data/.ssh/; } && +mkdir -p ~www-data/.pki && { [ ! -d "$b/pki" ] || cp -r "$b"/pki/. ~www-data/.pki/; } && +venv/bin/python3 manage.py migrate && +chown -R www-data:www-data data ~www-data/.ssh ~www-data/.pki && +chmod 700 data ~www-data/.ssh ~www-data/.pki && chmod 600 db.sqlite3 && +systemctl start webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker +``` + +### Behavior changes + +What an update from an older version changes for operators and users: + +- Requests for a host not in `ALLOWED_HOSTS` get 400. The installer writes the server's names and IPs; add more with the `ALLOWED_HOSTS` env var. The cloud-init datasource host must be allowed too. +- Logging out needs a POST; a GET to `/accounts/logout/` gets 405. +- The API accepts only a session from the login form or an API token (no HTTP Basic), and the API docs need a login. Sessions opened through the old password-only doors skip OTP; end them once after the update (everyone signs in again): `python3 manage.py shell -c "from django.contrib.sessions.models import Session; Session.objects.all().delete()"`. +- Failed logins lock a user (django-axes). The limit and lock time are on the Settings page; unlock with `python3 manage.py axes_reset_username `. +- Changing one's own password is the "Can change password" permission (per user or group; new users have it); `SHOW_PROFILE_EDIT_PASSWORD` is gone. +- The admin's first-login password change survives losing `data/admin_password` (migration `accounts.0007`); run `migrate` as a user who can read that file. +- A new VM and each clone get a random VNC password of their own (clones no longer share their source's). The console page sends it for a user with **Open a console without the VM's console password**, which a new user has unless unticked; others are asked for it. Existing VMs keep theirs. +- An `admin` account that still has the password `admin` (the first user of versions up to v2.0.0) must change it at its next login, and its API tokens are refused until then (migration `accounts.0013`). +- A `settings.py` kept from an older version must match the template (apps, middleware, the SQLite `OPTIONS`, the REST framework, knox, axes and password settings): rebuild it from the template and carry your own values over, as step 2 of the update does. +- The VNC console connects to `/novncd/` on the panel's own address through nginx, and the serial console to `/socket.io/`: they work on HTTPS and behind another proxy without settings. `WS_PUBLIC_PORT`, `WS_PUBLIC_HOST`, `WS_PUBLIC_PATH`, `WS_CERT` and `SOCKETIO_PUBLIC_HOST`/`_PORT`/`_PATH` are gone; remove them from `docker-compose.override.yml`, `/etc/default/webvirtcloud` or a kept `settings.py`, set `WS_HOST = "127.0.0.1"` in a kept `settings.py`, and close port 6080 in the firewall. +- Installer and manual installations run the panel as systemd units (`webvirt-cloud`, `webvirt-novncd`, `webvirt-socketiod`, `webvirt-worker`, `webvirt-backup.timer`) and computes run `webvirt-gstfsd`, instead of supervisor; see [Moving from supervisor to systemd](#moving-from-supervisor-to-systemd). Service settings go into `/etc/default/webvirtcloud`, service output into the journal (`journalctl -u webvirt-cloud`); the app log stays in `data/`. novncd and socketiod run sandboxed (read-only system directories, no `/home`). +- gstfsd does only what the panel signs with the host's key: it does not start without `GSTFSD_KEY` in `/etc/default/webvirt-gstfsd`. On every compute, update gstfsd and its unit and make the key as in [Adding a Compute Node](#adding-a-compute-node), step 3 (a remote panel's `GSTFSD_BIND_HOST` and `GSTFSD_ALLOW_REMOTE` move into that file from `systemctl edit webvirt-gstfsd`), then enter the key on each compute's edit page. +- LDAP login's packages are in `conf/requirements-ldap.txt` and no longer installed by default, nor their build headers by the installer; the Docker image keeps them. An existing virtualenv keeps them, but only installing that file updates them (see [LDAP](#ldap)). +- The themes ship compiled; picking one on the Settings page only saves its name, and the "Theme SASS Path" setting is gone. Browsers revalidate static files on each load: files cached before the update can stay until a hard reload (Ctrl+F5) of each page; an external caching proxy may need a purge. +- The panel backs itself up every day ([Backups](#backups)); in Docker into a new volume, `webvirtcloud-backups`. +- The Docker image is built on Ubuntu 26.04 LTS (Python 3.14, libvirt 12) instead of 24.04; `docker compose up -d --build` rebuilds it. +- New passwords must pass Django's password validators: at least 8 characters, not a common password, not only digits, not like the user's name or e-mail. Existing passwords keep working. +- In Docker, the TLS client certificates of TLS computes are kept in a new volume, `webvirtcloud-pki` (`/var/www/.pki`), and backed up; put them there once ([Adding a Compute Node](#adding-a-compute-node), step 2). +- The SQLite database must be on a local file system (WAL). Outside compose, stop the Docker container with `docker stop -t 95` so the worker can finish running jobs. +- Long operations run as jobs in a worker process (`manage.py run_worker`): creating, cloning, migrating and deleting VMs, moving disks, cloning volumes, taking and reverting snapshots and deleting external snapshots. The page returns at once and the job panel and the Jobs page follow them; a migration can be cancelled. The API's VM create, migrate and delete answer 202 with the job and need a new `submission_key` per request. The create form no longer takes a console password; set it on the VM page. +- An API error is `{"detail": ...}` with a status that says what it is: 404 for a VM, pool or volume read or deleted that the host does not have, 409 for a volume in use, a template started or a stopped pool, 503 for a host that cannot be reached (all were 400 or 200 before); an unknown API URL answers JSON. +- An ISO upload is tracked as a job that can be cancelled. The browser sends the file, so stay on the page until it ends; an upload left idle for an hour is ended and its partial file deleted. +- A clone of a running VM (libvirt 11.10 or later, local file disks, no TPM) locks the VM until the copy ends and can be cancelled; cancelling a clone of a shut-off VM has no effect. A job that ends uncertain keeps its VM locked until a superuser resolves it on its job page. +- The job worker runs at most half of its concurrency (Settings, at least one) of one host's jobs at a time, so a host that does not answer cannot take every slot. +- A new VM is made in three steps (host, machine, details) on one form; flavors are presets that fill vCPU, memory and disk size. "VM Clone Auto Migrate" is removed; a clone stays on its source host. +- A user's quota counts, for each VM, the larger of its running and its next-start vCPUs, memory and disks, and a queued clone as one VM. A user can be over the quota after the update; nothing is stopped, but new VMs, clones and raises are refused until it fits. +- A user who is not a superuser names a clone with one of the free names the clone form offers; other names are refused. +- A template VM is changed or deleted only by superusers and staff owners with the matching permission; other owners can still view, open the console and clone it. +- On a running VM the edit forms show the settings as they will be at the next start, so saving an edit no longer reverts another pending change. +- External snapshots need libvirt 9.9 or newer on the computes. libvirt 9.10 (openEuler 24.03 LTS) refuses to revert a snapshot taken without memory of a running or paused VM ("Invalid target domain state 'disk-snapshot'"); 11.10 reverts it. A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. An internal snapshot is reverted only on a shut-off VM. Snapshot names no longer get an `s1.` prefix. A snapshot an older version took of a UEFI VM can only be deleted, not reverted. On NFS, the qemu user needs write access to the image directory to remove merged snapshot files. +- A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is not deleted; deleting a VM with its files keeps a file another compute's VM or volume uses at the same path, and is refused before the VM is stopped when one of the other computes cannot be reached. +- A VM that vanishes from its compute, or whose compute is deleted, keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers manage these records on the "Removed VMs" page. +- The Logs page records who changed what (computes, users, groups, VM owners, settings, SSH keys, host networks, pools, volumes, interfaces, secrets and network filters), every login, failed login and lockout, and every page or API call refused to a signed-in user or an API token, with the address and the result. +- On a host whose libvirt cannot change network interfaces (the udev backend: RHEL 9 and later, Debian 12), the interface pages offer no create, start, stop or delete. +- The panel is offered in English, Turkish and Simplified Chinese. +- With SELinux (RHEL family, openEuler), systemd could not run the virtualenv's programs and the panel did not start. The installer now writes file context rules (`httpd_sys_content_t` for the panel, `bin_t` for `venv/bin`) instead of `chcon`, so a relabel keeps them. A manual installation runs `semanage fcontext -a -t bin_t "/srv/webvirtcloud/venv/bin(/.*)?"` and `restorecon -R /srv/webvirtcloud` once; an installer one runs the installer again. + +## Usage + +### Users, Roles and Permissions + +See [doc/permissions.md](doc/permissions.md). + +### REST API + +The REST API (Django REST Framework, documented with `drf-spectacular`) takes the login session or an API token. After logging in: + +* **Swagger UI:** `http:///swagger/` +* **ReDoc UI:** `http:///redoc/` +* **OpenAPI 3.0 schema:** `http:///api/schema/` (JSON or YAML) +* **API tokens**, for scripts and tools: **Profile → API Tokens**. A token acts as its user and is shown once; an administrator who sets the user's password deletes them. Send it as a header: + + ```bash + curl -H "Authorization: Token wvc_..." https:///api/v1/instances/ + ``` + +### Screenshots + +| VM list | VM page | |:---:|:---:| -| ![Grouped Instances](doc/images/grouped.PNG) | ![Non-Grouped Instances](doc/images/nongrouped.PNG) | +| ![VM list](doc/images/instances.png) | ![VM page](doc/images/instance.png) | -| Compute Hosts | Activity Log | +| Computes | Host overview | |:---:|:---:| -| ![Compute Hosts](doc/images/hosts.PNG) | ![Activity Log](doc/images/log.PNG) | +| ![Computes](doc/images/computes.png) | ![Host overview](doc/images/overview.png) | + +| New VM | Logs | +|:---:|:---:| +| ![New VM](doc/images/create.png) | ![Logs](doc/images/logs.png) | + +## Development + +Run every step as your normal user, not with `sudo`: if `manage.py` ever runs as root, `db.sqlite3` and `data/` end up root-owned and later runs fail with "readonly database" or "Permission denied". + +1. System packages. The virtualenv reuses the distro's `libvirt`, `lxml` and `ldap` bindings when their versions satisfy `conf/requirements.txt`; otherwise pip installs or builds newer ones: + + ```bash + # Rocky Linux / RHEL / Fedora + sudo dnf -y install git python3-devel libvirt-devel python3-libvirt python3-lxml python3-ldap gcc + + # Ubuntu / Debian + sudo apt-get update && sudo apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt python3-ldap libvirt-dev zlib1g-dev libldap2-dev libsasl2-dev gcc pkg-config + + # openSUSE Leap 15.x / Tumbleweed / SLES 15 (use python3.11 instead of python3 below) + sudo zypper --non-interactive install -y git hostname python311 python311-devel python311-pip python311-libvirt-python python311-lxml python311-ldap libvirt-devel openldap2-devel cyrus-sasl-devel libopenssl-devel gcc pkg-config + ``` + +2. Virtualenv and dependencies (`--system-site-packages` is required to see the distro bindings; `dev/requirements.txt` includes `conf/requirements.txt`): + + ```bash + python3 -m venv --system-site-packages .venv + source .venv/bin/activate + pip install -r dev/requirements.txt + ``` + +3. Settings and secret key: + + ```bash + cp webvirtcloud/settings.py.template webvirtcloud/settings.py + mkdir -p data && python conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key + ``` + +4. Migrate and run the dev server (`settings-dev` enables `DEBUG` and the Django Debug Toolbar); the admin password is in `data/admin_password`, or set `ADMIN_PASSWORD` beforehand: + + ```bash + python manage.py migrate + python manage.py runserver 0.0.0.0:8000 --settings=webvirtcloud.settings-dev + ``` + + Open `http://127.0.0.1:8000`. The browser consoles connect to `/novncd/` and `/socket.io/` on the panel's own address, which the dev server does not proxy: try them in the Docker image, or behind nginx with `conf/nginx/webvirtcloud.conf`. + +### Keyboard layouts + +The VNC console's "Type it" sends the keys that type each character on the VM's keyboard layout. A layout is one file in `console/layouts/`, written from the XKB layout of the same name (needs `libxkbcommon` and `xkeyboard-config`, present on Linux desktops): + +```bash +python3 dev/console_layout.py de "German" # XKB layout, the name the console shows +python3 dev/console_layout.py tr "Turkish F" --variant f # a variant +python3 dev/console_layout.py us "English (US)" --model pc104 # a keyboard without the key left of Z +``` + +It lists the dead keys it leaves out: a character only a dead key types cannot be typed. Type a sample with every character group of the layout into a VM set to that layout before committing the file. + +### Translations + +The panel is translated into the languages of `LANGUAGES` in `settings.py`, each with a full catalog in `locale/`. A new or changed text needs its translation in each before CI passes (needs `gettext`): + +```bash +python manage.py makemessages -l tr -l zh --no-obsolete --ignore=staticfiles --ignore=.venv +# translate the new and fuzzy entries of locale/tr/LC_MESSAGES/django.po and locale/zh/LC_MESSAGES/django.po +python manage.py compilemessages -l tr -l zh +``` + +### Running tests + +```bash +python manage.py test # all tests, including vrtManager +ruff check . +``` + +Each app keeps its tests in a `tests/` package, and the tests of the libvirt layer are in `vrtManager/tests`, so `python manage.py test instances` runs one app; `--parallel` spreads the tests over the CPUs. The suite includes mocked regression tests and disk/clone tests on libvirt's in-process `test:///default` driver. Set `WEBVIRTCLOUD_DB_HOST` (and the other `WEBVIRTCLOUD_DB_*` variables) to run it against PostgreSQL, as CI does. + +The compute and instance tests run only when `TEST_LIBVIRT_HOST` is set, never on the machine's own libvirt by default. A green result with skips does not establish live KVM compatibility. The instance tests create `wvc-test-*` VMs and a temporary `wvc-test` pool (`/var/lib/libvirt/wvc-test`), remove them afterwards, and fail if any other VM or volume on the host changed: + +```bash +export TEST_LIBVIRT_HOST=compute1 TEST_LIBVIRT_TYPE=2 # 1 TCP, 2 SSH, 3 TLS, 4 socket +export TEST_LIBVIRT_LOGIN=root TEST_LIBVIRT_PASSWORD= # login/password for TCP and TLS +export TEST_LIBVIRT_GUEST_IMAGE=/var/lib/libvirt/images/linux.qcow2 # optional, see below +export TEST_LIBVIRT_LVM_DEVICE=/dev/vdb # optional: an empty disk of 4 GiB or more on the host, wiped by the LVM tests +export TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun # optional: a target on the host's 127.0.0.1, open to any initiator, with a LUN +export TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs # optional: a directory the host exports to its 127.0.0.1 over NFS +python manage.py test +``` + +`TEST_LIBVIRT_GUEST_IMAGE` enables the tests that need a guest OS (disk and NIC hot-unplug, vCPU hotplug, guest agent, ACPI power off). It must be an installed, BIOS-bootable Linux qcow2 image with qemu-guest-agent, in a storage pool of the host other than `wvc-test`; the tests boot copies of it from overlays and only read the image. Shut down any VM using it first. + +Two disposable compute nodes for these tests come from the `Vagrantfile`: AlmaLinux 10 with nested KVM, set up by `dev/libvirt-bootstrap.sh` (with this checkout's gstfsd), a SASL user for TCP (`wvc`, password `wvc-test`) and your `~/.ssh/id_ed25519.pub` for root; migration runs between them, and each has an empty second disk (`/dev/vdb`) for the LVM tests and serves a 1 GiB iSCSI LUN on 127.0.0.1 for the iSCSI test and an NFS export (`/srv/wvc-nfs`) to 127.0.0.1 for the NFS pool test. They accept TLS logins (SASL) with certificates from a throwaway CA that `vagrant up` makes in `.vagrant/tls`; the TLS run takes its client certificate from there through `HOME`. It needs Vagrant with the `vagrant-libvirt` plugin, a KVM host with `qemu-kvm` and `cyrus-sasl-md5`, and a user in the `libvirt` group. On a host that also runs Docker, let the VMs' network (the bridge of `virsh net-info vagrant-libvirt`, `virbr1` here) through Docker's forward rules (`sudo iptables -I DOCKER-USER -i virbr1 -j ACCEPT` and `sudo iptables -I DOCKER-USER -o virbr1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT`). + +```bash +vagrant up --provider=libvirt +C1=$(vagrant ssh compute1 -c "hostname -I" | awk '{print $1}') C2=$(vagrant ssh compute2 -c "hostname -I" | awk '{print $1}') +for h in $C1 $C2; do ssh-keygen -R $h; ssh -o StrictHostKeyChecking=accept-new root@$h true; done # new VMs, new host keys +export TEST_LIBVIRT_HOST=$C1 TEST_LIBVIRT_TYPE=1 TEST_LIBVIRT_LOGIN=wvc TEST_LIBVIRT_PASSWORD=wvc-test +export TEST_LIBVIRT_SSH_HOST=$C2 TEST_LIBVIRT_SSH_LOGIN=root TEST_LIBVIRT_MIGRATE_ADDRESS=$C2 TEST_LIBVIRT_LVM_DEVICE=/dev/vdb TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs +python manage.py test +HOME=$PWD/.vagrant/tls TEST_LIBVIRT_TYPE=3 TEST_LIBVIRT_SSH_HOST= python manage.py test # compute1 again, over TLS +# a socket compute is the panel's own libvirt: the committed HEAD runs inside compute1 +git archive HEAD | vagrant ssh compute1 -c "sudo sh -c 'rm -rf /root/wvc && mkdir /root/wvc && tar -x -C /root/wvc'" +vagrant ssh compute1 -c "sudo -i sh -ec 'dnf -y install python3-devel openldap-devel cyrus-sasl-devel gcc; cd /root/wvc; python3 -m venv --system-site-packages venv; venv/bin/pip install -r dev/requirements.txt; cp webvirtcloud/settings.py.template webvirtcloud/settings.py; mkdir data; venv/bin/python conf/runit/secret_generator.py > data/secret_key; TEST_LIBVIRT_HOST=localhost TEST_LIBVIRT_TYPE=4 TEST_LIBVIRT_LOGIN=wvc TEST_LIBVIRT_PASSWORD=wvc-test TEST_LIBVIRT_LVM_DEVICE=/dev/vdb TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs venv/bin/python manage.py test'" +vagrant destroy -f +``` ## License diff --git a/Vagrantfile b/Vagrantfile index 51522bb0..37586e16 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,56 +1,79 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : +# Two disposable compute nodes for the live tests (README: Running tests), so +# that migration runs between them: AlmaLinux 10 with nested KVM, set up by +# dev/libvirt-bootstrap.sh, with a SASL user for TCP and the host user's SSH +# key for root (SASL user wvc, password wvc-test). The empty second disk +# /dev/vdb is for the LVM tests (TEST_LIBVIRT_LVM_DEVICE); each also serves a +# 1 GiB iSCSI LUN on 127.0.0.1 for the iSCSI tests (TEST_LIBVIRT_ISCSI_TARGET), +# exports /srv/wvc-nfs to 127.0.0.1 for the NFS pool test (TEST_LIBVIRT_NFS_EXPORT), +# and accepts TLS logins (SASL) with a certificate from a throwaway CA. -Vagrant.configure(2) do |config| - # Default machine, if name not specified... - config.vm.define "dev", primary: true do |dev| - dev.vm.box = "ubuntu/bionic64" - dev.vm.hostname = "webvirtcloud" - dev.vm.network "private_network", ip: "192.168.33.10" - dev.vm.provision "shell", inline: <<-SHELL - sudo sh /vagrant/dev/libvirt-bootstrap.sh - sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf - sudo service libvirt-bin restart - sudo adduser vagrant libvirtd - sudo apt-get -y install python3-virtualenv virtualenv python3-pip python3-dev python3-lxml libvirt-dev zlib1g-dev python3-guestfs - virtualenv -p python3 /vagrant/venv - source /vagrant/venv/bin/activate - pip3 install -r /vagrant/dev/requirements.txt - SHELL - end - # To start this machine run "vagrant up prod" - # To enter this machine run "vagrant ssh prod" - config.vm.define "prod", autostart: false do |prod| - prod.vm.box = "ubuntu/bionic64" - prod.vm.hostname = "webvirtcloud" - prod.vm.network "private_network", ip: "192.168.33.11" - prod.vm.network "forwarded_port", guest: 80, host: 8081 - #prod.vm.synced_folder ".", "/srv/webvirtcloud" - prod.vm.provision "shell", inline: <<-SHELL - sudo mkdir /srv/webvirtcloud - sudo cp -R /vagrant/* /srv/webvirtcloud - sudo sh /srv/webvirtcloud/dev/libvirt-bootstrap.sh - sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf - sudo service libvirt-bin restart - sudo adduser vagrant libvirtd - sudo chown -R vagrant:vagrant /srv/webvirtcloud - sudo apt-get -y install python3-virtualenv python3-dev python3-lxml python3-pip virtualenv libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs - virtualenv -p python3 /srv/webvirtcloud/venv - source /srv/webvirtcloud/venv/bin/activate - pip3 install -r /srv/webvirtcloud/requirements.txt - sudo cp /srv/webvirtcloud/conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d - sudo cp /srv/webvirtcloud/conf/nginx/webvirtcloud.conf /etc/nginx/conf.d - sudo cp /srv/webvirtcloud/webvirtcloud/settings.py.template /srv/webvirtcloud/webvirtcloud/settings.py - sudo sed "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py - python3 /srv/webvirtcloud/manage.py makemigrations - python3 /srv/webvirtcloud/manage.py migrate - python3 /srv/webvirtcloud/manage.py collectstatic --noinput - sudo rm /etc/nginx/sites-enabled/default - sudo chown -R www-data:www-data /srv/webvirtcloud - sudo service nginx restart - sudo service supervisor restart - SHELL +# The CA is made here once; each compute signs its own certificate with it. +# The client certificate sits where libvirt looks with HOME=.vagrant/tls. +pki = File.join(__dir__, ".vagrant/tls/.pki/libvirt") +unless File.exist?(File.join(pki, "clientcert.pem")) + # the host's openssl, not the one Vagrant puts first on PATH + Vagrant::Util::Env.with_original_env do + system("sh", "-ec", <<-SH, pki, exception: true) + mkdir -p "$0" && cd "$0" + openssl req -x509 -newkey rsa:3072 -nodes -keyout cakey.pem -out cacert.pem -days 365 -subj /CN=wvc-test-ca + openssl req -new -newkey rsa:3072 -nodes -keyout clientkey.pem -subj /CN=wvc-test-client | + openssl x509 -req -CA cacert.pem -CAkey cakey.pem -days 365 -out clientcert.pem + SH end end - +Vagrant.configure(2) do |config| + config.vm.box = "almalinux/10" + %w[compute1 compute2].each do |name| + config.vm.define(name) { |node| node.vm.hostname = "wvc-#{name}" } + end + config.vm.synced_folder ".", "/vagrant", disabled: true + config.vm.provider :libvirt do |v| + v.memory = 4096 + v.cpus = 2 + v.nested = true + v.cpu_mode = "host-passthrough" + v.video_type = "virtio" # EL10 hosts have no cirrus + v.storage :file, size: "4G" + end + # the bootstrap installs this checkout's gstfsd + config.vm.provision "file", source: "dev", destination: "/tmp/wvc/dev" + config.vm.provision "file", source: "conf", destination: "/tmp/wvc/conf" + config.vm.provision "file", source: "~/.ssh/id_ed25519.pub", destination: "/tmp/wvc/key.pub" + config.vm.provision "file", source: "#{pki}/cacert.pem", destination: "/tmp/wvc/cacert.pem" + config.vm.provision "file", source: "#{pki}/cakey.pem", destination: "/tmp/wvc/cakey.pem" + config.vm.provision "shell", inline: <<-SHELL + set -e + sh /tmp/wvc/dev/libvirt-bootstrap.sh + virsh pool-info default > /dev/null 2>&1 || { virsh pool-define-as default dir --target /var/lib/libvirt/images && virsh pool-autostart default && virsh pool-start default; } + echo wvc-test | saslpasswd2 -a libvirt -p wvc + install -d -m 700 /root/.ssh + cat /tmp/wvc/key.pub >> /root/.ssh/authorized_keys + dnf -y install targetcli + systemctl enable --now target + T=iqn.2026-10.test.wvc:lun + targetcli ls /iscsi/$T > /dev/null 2>&1 || { + targetcli /backstores/fileio create wvc /var/lib/wvc-iscsi.img 1G + targetcli /iscsi create $T + targetcli /iscsi/$T/tpg1/portals delete 0.0.0.0 3260 + targetcli /iscsi/$T/tpg1/portals create 127.0.0.1 3260 + targetcli /iscsi/$T/tpg1/luns create /backstores/fileio/wvc + targetcli /iscsi/$T/tpg1 set attribute generate_node_acls=1 demo_mode_write_protect=0 + targetcli saveconfig + } + dnf -y install nfs-utils + install -d /srv/wvc-nfs + echo "/srv/wvc-nfs 127.0.0.1(rw,no_root_squash)" > /etc/exports.d/wvc.exports + systemctl enable --now nfs-server + exportfs -r + install -D -m 644 /tmp/wvc/cacert.pem /etc/pki/CA/cacert.pem + install -d /etc/pki/libvirt/private + echo "subjectAltName=IP:$(hostname -I | cut -d' ' -f1)" > /tmp/wvc/ext + openssl req -new -newkey rsa:3072 -nodes -keyout /etc/pki/libvirt/private/serverkey.pem -subj /CN=$(hostname) | + openssl x509 -req -CA /tmp/wvc/cacert.pem -CAkey /tmp/wvc/cakey.pem -days 365 -extfile /tmp/wvc/ext -out /etc/pki/libvirt/servercert.pem + sed -i 's/^#auth_tls = "none"/auth_tls = "sasl"/' /etc/libvirt/virtproxyd.conf + # a running service would keep the old settings and refuse a new socket + systemctl stop virtproxyd.service + systemctl enable --now virtproxyd-tls.socket + SHELL +end diff --git a/accounts/__init__.py b/accounts/__init__.py index 83198231..e69de29b 100644 --- a/accounts/__init__.py +++ b/accounts/__init__.py @@ -1 +0,0 @@ -default_app_config = 'accounts.apps.AccountsConfig' diff --git a/accounts/apps.py b/accounts/apps.py index 2a6f6311..56925b7a 100644 --- a/accounts/apps.py +++ b/accounts/apps.py @@ -1,57 +1,105 @@ +import os + from django.apps import AppConfig from django.db.models.signals import post_migrate +from django.db import transaction -def apply_change_password(sender, **kwargs): - """ - Apply new change_password permission for all users - Depending on settings SHOW_PROFILE_EDIT_PASSWORD - """ +def admin_password_path(): from django.conf import settings - from django.contrib.auth.models import Permission, User - if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"): - print("\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m") - print("\033[1m* \033[92mApplying permission can_change_password for all users\033[0m") - users = User.objects.all() - permission = Permission.objects.get(codename="change_password") - if settings.SHOW_PROFILE_EDIT_PASSWORD: - print("\033[1m! \033[91mWarning!!! Setting to True for all users\033[0m") - for user in users: - user.user_permissions.add(permission) - else: - print("\033[1m* \033[91mWarning!!! Setting to False for all users\033[0m") - for user in users: - user.user_permissions.remove(permission) - print("\033[1m! Don`t forget to remove the option from settings.py\033[0m") + return os.path.join(str(settings.BASE_DIR), "data", "admin_password") + + +def _migrated(state_apps, model, field=None): + """post_migrate follows every migrate, also targeted ones and rollbacks: + act only once the post-migration state has the model (and field).""" + if state_apps is None: + return False + try: + fields = state_apps.get_model(model)._meta.get_fields() + except LookupError: + return False + return field is None or any(f.name == field for f in fields) + + +def grant_change_password(sender, instance, created, raw=False, **kwargs): + """New users may change their own password unless an admin takes the + permission away; the user form saves its own choice after this.""" + if not created or raw: + return + from django.contrib.auth.models import Permission + + permission = Permission.objects.filter(content_type__app_label="accounts", codename="change_password").first() + if permission is not None: # created by post_migrate on a fresh database + instance.user_permissions.add(permission) + + +def _store_generated_password(password): + """ + Write the generated admin password to data/admin_password (mode 0600) + instead of printing it, so it does not end up in install logs or + `docker logs`. Abort provisioning if private storage fails. + """ + path = admin_password_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + # Never write through a pre-existing file or symlink (this runs as + # root in Docker, while data/ belongs to www-data). + if os.path.lexists(path): + os.unlink(path) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as f: + os.fchmod(f.fileno(), 0o600) + f.write(password + "\n") + print(f"\033[1m* \033[93mGenerated admin password written to {path}\033[0m") + except OSError as err: + raise RuntimeError( + f"Cannot securely store the generated admin password at {path}. " + "Fix the data directory permissions and run migrate again." + ) from err def create_admin(sender, **kwargs): """ Create initial admin user """ + import os + import secrets from django.contrib.auth.models import User from accounts.models import UserAttributes - plan = kwargs.get("plan", []) - for migration, rolled_back in plan: - if ( - migration.app_label == "accounts" - and migration.name == "0001_initial" - and not rolled_back - ): - if User.objects.count() == 0: - print("\033[1m* \033[92mCreating default admin user\033[0m") - admin = User.objects.create_superuser("admin", None, "admin") - UserAttributes( - user=admin, - max_instances=-1, - max_cpus=-1, - max_memory=-1, - max_disk_size=-1, - ).save() - break + # Any real migrate run provisions while no user exists, so a run that + # failed to store the password can simply be repeated. flush (used by + # tests) sends plan=None. + if kwargs.get("plan") is None: + return + state_apps = kwargs.get("apps") + if not (_migrated(state_apps, "auth.User") and _migrated(state_apps, "accounts.UserAttributes", "must_change_password")): + return + if User.objects.exists(): + return + + admin_user = os.environ.get("ADMIN_USERNAME", "admin") + admin_pass = os.environ.get("ADMIN_PASSWORD") + generated_password = not admin_pass + + if generated_password: + admin_pass = secrets.token_urlsafe(16) + _store_generated_password(admin_pass) + + print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m") + with transaction.atomic(): + admin = User.objects.create_superuser(admin_user, None, admin_pass) + UserAttributes.objects.create( + user=admin, + must_change_password=generated_password, + max_instances=-1, + max_cpus=-1, + max_memory=-1, + max_disk_size=-1, + ) class AccountsConfig(AppConfig): @@ -60,4 +108,8 @@ class AccountsConfig(AppConfig): def ready(self): post_migrate.connect(create_admin, sender=self) - post_migrate.connect(apply_change_password, sender=self) + from django.conf import settings + from django.db.models.signals import post_save + + post_save.connect(grant_change_password, sender=settings.AUTH_USER_MODEL) + from . import checks # noqa: F401 diff --git a/accounts/auth.py b/accounts/auth.py new file mode 100644 index 00000000..e0bf08d3 --- /dev/null +++ b/accounts/auth.py @@ -0,0 +1,31 @@ +"""API token authentication: knox's, noting when each token was last used.""" + +from datetime import timedelta + +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.contrib.knox_auth_token import KnoxTokenScheme +from knox.auth import TokenAuthentication as KnoxTokenAuthentication +from rest_framework import exceptions + +from accounts.models import UserAttributes + +# a token's last use is written at most once a minute, not on every request +LAST_USED_EVERY = timedelta(minutes=1) + + +class TokenAuthentication(KnoxTokenAuthentication): + def validate_user(self, auth_token): + user, token = super().validate_user(auth_token) + # as the pages do (ForcePasswordChangeMiddleware), until the user changes the password + if UserAttributes.objects.filter(user=user, must_change_password=True).exists(): + raise exceptions.AuthenticationFailed(_("Change your password before using the API.")) + now = timezone.now() + if token.last_used is None or now - token.last_used >= LAST_USED_EVERY: + type(token).objects.filter(pk=token.pk).update(last_used=now) + return user, token + + +class TokenScheme(KnoxTokenScheme): + """The API schema's description of these tokens, as of knox's.""" + target_class = "accounts.auth.TokenAuthentication" diff --git a/accounts/checks.py b/accounts/checks.py new file mode 100644 index 00000000..88cd90de --- /dev/null +++ b/accounts/checks.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.core.checks import Error, Tags, Warning, register + + +@register(Tags.security) +def password_change_middleware_check(app_configs, **kwargs): + middleware = list(settings.MIDDLEWARE) + required = "accounts.middleware.ForcePasswordChangeMiddleware" + authentication = "django.contrib.auth.middleware.AuthenticationMiddleware" + if required not in middleware or ( + authentication in middleware + and middleware.index(required) < middleware.index(authentication) + ): + return [ + Error( + "ForcePasswordChangeMiddleware must follow AuthenticationMiddleware.", + hint=f"Add '{required}' to MIDDLEWARE in webvirtcloud/settings.py, as settings.py.template does.", + id="accounts.E001", + ) + ] + return [] + + +@register(Tags.security) +def login_required_middleware_check(app_configs, **kwargs): + middleware = list(settings.MIDDLEWARE) + required = "django.contrib.auth.middleware.LoginRequiredMiddleware" + old = "login_required.middleware.LoginRequiredMiddleware" + if old in middleware or required not in middleware: + return [ + Error( + "MIDDLEWARE must use Django's LoginRequiredMiddleware.", + hint=f"In webvirtcloud/settings.py replace '{old}' with '{required}' " + "and delete LOGIN_REQUIRED_IGNORE_VIEW_NAMES, as settings.py.template does.", + id="accounts.E002", + ) + ] + return [] + + +@register() +def obsolete_settings_check(app_configs, **kwargs): + if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"): + return [ + Warning( + "SHOW_PROFILE_EDIT_PASSWORD is no longer used.", + hint="Remove it from webvirtcloud/settings.py. Whether a user may change their own " + "password is the 'Can change password' permission, per user or group.", + id="accounts.W001", + ) + ] + return [] + diff --git a/accounts/forms.py b/accounts/forms.py index d4c7e567..f2d665fb 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,6 +1,8 @@ from appsettings.settings import app_settings from django.contrib.auth import get_user_model -from django.forms import EmailField, Form, ModelForm, ValidationError +from django.contrib.auth.forms import AuthenticationForm +from django.core.validators import MaxLengthValidator +from django.forms import CharField, EmailField, Form, ModelForm, TypedChoiceField, ValidationError from django.utils.translation import gettext_lazy as _ from .models import UserInstance, UserSSHKey @@ -20,7 +22,8 @@ class UserInstanceForm(ModelForm): def clean_instance(self): instance = self.cleaned_data["instance"] if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False": - exists = UserInstance.objects.filter(instance=instance) + # the ownership being edited is not another owner + exists = UserInstance.objects.filter(instance=instance).exclude(pk=self.instance.pk) if exists: raise ValidationError(_("Instance owned by another user")) @@ -29,9 +32,24 @@ class UserInstanceForm(ModelForm): class Meta: model = UserInstance fields = "__all__" + # the rights beyond power and console, as the VM's Users tab names them + labels = {"is_change": _("Change settings"), "is_delete": _("Delete the VM"), + "is_vnc": _("Change console settings")} + help_texts = {"is_vnc": _("Only together with Change settings")} -class ProfileForm(ModelForm): +class UniqueEmailMixin: + """"Lost OTP?" mails only an address that is one user's: a second user + with the same address would switch it off for both.""" + + def clean_email(self): + email = self.cleaned_data["email"] + if email and get_user_model().objects.filter(email__iexact=email).exclude(pk=self.instance.pk).exists(): + raise ValidationError(_("Another user has this email address")) + return email + + +class ProfileForm(UniqueEmailMixin, ModelForm): class Meta: model = get_user_model() fields = ("first_name", "last_name", "email") @@ -71,5 +89,28 @@ class UserSSHKeyForm(ModelForm): fields = ("keyname", "keypublic") +class ApiTokenForm(Form): + name = CharField(label=_("Name"), max_length=64, help_text=_("What uses it, such as a script or a host")) + days = TypedChoiceField( + label=_("Expires"), coerce=int, empty_value=None, required=False, initial=90, + choices=[(30, _("in 30 days")), (90, _("in 90 days")), (365, _("in a year")), ("", _("never"))], + ) + + class EmailOTPForm(Form): email = EmailField(label=_("Email")) + + +class BoundedUsernameMixin: + """Django's login form gives the username its max_length after the field + is built, so a longer one is not refused and reaches authentication; + django-axes then cannot store it (PostgreSQL refuses it: 500).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + username = self.fields["username"] + username.validators.append(MaxLengthValidator(username.max_length)) + + +class LoginForm(BoundedUsernameMixin, AuthenticationForm): + pass diff --git a/accounts/lockout.py b/accounts/lockout.py new file mode 100644 index 00000000..0e4720d5 --- /dev/null +++ b/accounts/lockout.py @@ -0,0 +1,54 @@ +""" +Settings for django-axes, read from the Settings page (AppSettings) on each +use, so an administrator can change them without a restart. +""" + +from datetime import timedelta +from ipaddress import ip_address, ip_network + +from appsettings.models import AppSettings +from django.conf import settings + +DEFAULT_FAILURE_LIMIT = 5 +DEFAULT_LOCKOUT_MINUTES = 15 + + +def _positive_int(key, default): + try: + value = int(AppSettings.objects.get(key=key).value) + except (AppSettings.DoesNotExist, ValueError): + return default + return value if value > 0 else default + + +def failure_limit(request, credentials): + """AXES_FAILURE_LIMIT: failed logins allowed before the lock.""" + return _positive_int("LOGIN_FAILURE_LIMIT", DEFAULT_FAILURE_LIMIT) + + +def cooloff_time(request): + """AXES_COOLOFF_TIME: how long a lock lasts.""" + return timedelta(minutes=_positive_int("LOGIN_LOCKOUT_MINUTES", DEFAULT_LOCKOUT_MINUTES)) + + +def _from_trusted_proxy(remote): + try: + address = ip_address(remote) + except ValueError: + return False + if address.is_loopback: # the bundled nginx + return True + return any(address in ip_network(net, strict=False) for net in getattr(settings, "LOGIN_TRUSTED_PROXIES", [])) + + +def client_ip(request): + """ + AXES_CLIENT_IP_CALLABLE. Behind the bundled nginx every request comes from + 127.0.0.1 and nginx sets X-Real-IP to the client's address (overwriting + any sent by the client). The header is used only for requests from that + proxy, or from the addresses in LOGIN_TRUSTED_PROXIES; from anyone else + it could be forged to escape a lock. + """ + remote = request.META.get("REMOTE_ADDR", "") + real_ip = request.META.get("HTTP_X_REAL_IP", "").strip() + return real_ip if real_ip and _from_trusted_proxy(remote) else remote diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 00000000..c0d08494 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,30 @@ +from django.http import JsonResponse +from django.shortcuts import redirect +from django.utils.translation import gettext as _ +from django.urls import Resolver404, resolve + +from accounts.models import UserAttributes + +ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login"} + + +class ForcePasswordChangeMiddleware: + """Send users who must change their password (the generated admin one, or one + an administrator set) to the change form.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and UserAttributes.objects.filter( + user=request.user, must_change_password=True + ).exists(): + try: + view_name = resolve(request.path_info).view_name + except Resolver404: + view_name = None + if view_name not in ALLOWED_VIEWS: + if request.path_info.startswith("/api/"): # as for a token (accounts.auth) + return JsonResponse({"detail": _("Change your password before using the API.")}, status=403) + return redirect("accounts:change_password") + return self.get_response(request) diff --git a/accounts/migrations/0006_alter_userattributes_id_alter_userinstance_id_and_more.py b/accounts/migrations/0006_alter_userattributes_id_alter_userinstance_id_and_more.py new file mode 100644 index 00000000..401b6309 --- /dev/null +++ b/accounts/migrations/0006_alter_userattributes_id_alter_userinstance_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.30 on 2026-09-27 10:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0005_auto_20200616_1039'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='userinstance', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='usersshkey', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='usersshkey', + name='keypublic', + field=models.TextField(max_length=16384, verbose_name='public key'), + ), + migrations.AlterField( + model_name='usersshkey', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/accounts/migrations/0007_userattributes_must_change_password.py b/accounts/migrations/0007_userattributes_must_change_password.py new file mode 100644 index 00000000..f4fd1f6d --- /dev/null +++ b/accounts/migrations/0007_userattributes_must_change_password.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.db import migrations, models + + +def flag_legacy_generated_password(apps, schema_editor): + """Preserve first-login enforcement for installations with a password file.""" + path = Path(settings.BASE_DIR) / "data" / "admin_password" + try: + generated = path.read_text().strip() + except FileNotFoundError: + return + except OSError as err: + raise RuntimeError( + f"Cannot read {path}; run migrate as its owner to preserve first-login enforcement." + ) from err + if not generated: + return + alias = schema_editor.connection.alias + User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) + Attributes = apps.get_model("accounts", "UserAttributes") + for user in User.objects.using(alias).filter(is_superuser=True): + if check_password(generated, user.password): + attributes, _ = Attributes.objects.using(alias).get_or_create( + user_id=user.pk + ) + attributes.must_change_password = True + attributes.save(using=alias, update_fields=["must_change_password"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0006_alter_userattributes_id_alter_userinstance_id_and_more") + ] + operations = [ + migrations.AddField( + model_name="userattributes", + name="must_change_password", + field=models.BooleanField(default=False), + ), + migrations.RunPython(flag_legacy_generated_password, migrations.RunPython.noop), + ] diff --git a/accounts/migrations/0008_apply_show_profile_edit_password_once.py b/accounts/migrations/0008_apply_show_profile_edit_password_once.py new file mode 100644 index 00000000..06f18176 --- /dev/null +++ b/accounts/migrations/0008_apply_show_profile_edit_password_once.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import migrations + + +def apply_setting_once(apps, schema_editor): + """SHOW_PROFILE_EDIT_PASSWORD was replaced by the change_password + permission, but its value was applied to every user on each migrate, + undoing per-user and per-group choices. Apply it one last time.""" + if not hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"): + return + alias = schema_editor.connection.alias + Permission = apps.get_model("auth", "Permission") + User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) + permission = Permission.objects.using(alias).filter( + content_type__app_label="accounts", codename="change_password" + ).first() + if permission is None: # fresh database: no users and no permissions yet + return + for user in User.objects.using(alias).all(): + if settings.SHOW_PROFILE_EDIT_PASSWORD: + user.user_permissions.add(permission) + else: + user.user_permissions.remove(permission) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0007_userattributes_must_change_password"), + ("auth", "0012_alter_user_first_name_max_length"), + ] + operations = [migrations.RunPython(apply_setting_once, migrations.RunPython.noop)] diff --git a/accounts/migrations/0009_api_token.py b/accounts/migrations/0009_api_token.py new file mode 100644 index 00000000..66960f32 --- /dev/null +++ b/accounts/migrations/0009_api_token.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.18 on 2026-10-24 19:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0008_apply_show_profile_edit_password_once'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApiToken', + fields=[ + ('digest', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('token_key', models.CharField(db_index=True, max_length=25)), + ('created', models.DateTimeField(auto_now_add=True)), + ('expiry', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=64, verbose_name='name')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token_set', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/accounts/migrations/0010_api_token_last_used.py b/accounts/migrations/0010_api_token_last_used.py new file mode 100644 index 00000000..82102cfd --- /dev/null +++ b/accounts/migrations/0010_api_token_last_used.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.18 on 2026-10-24 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_api_token'), + ] + + operations = [ + migrations.AddField( + model_name='apitoken', + name='last_used', + field=models.DateTimeField(blank=True, null=True, verbose_name='last used'), + ), + ] diff --git a/accounts/migrations/0011_quota_help.py b/accounts/migrations/0011_quota_help.py new file mode 100644 index 00000000..a389d250 --- /dev/null +++ b/accounts/migrations/0011_quota_help.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.18 on 2026-10-25 19:29 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0010_api_token_last_used'), + ] + + operations = [ + migrations.AlterField( + model_name='userattributes', + name='max_cpus', + field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max CPUs'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_disk_size', + field=models.IntegerField(default=20, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max disk size (GiB)'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_instances', + field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max instances'), + ), + migrations.AlterField( + model_name='userattributes', + name='max_memory', + field=models.IntegerField(default=2048, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max memory (MiB)'), + ), + ] diff --git a/accounts/migrations/0012_user_instance_constraint.py b/accounts/migrations/0012_user_instance_constraint.py new file mode 100644 index 00000000..6800919c --- /dev/null +++ b/accounts/migrations/0012_user_instance_constraint.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.18 on 2026-11-06 07:57 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0011_quota_help'), + ('instances', '0014_instancetombstone_host'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userinstance', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='userinstance', + constraint=models.UniqueConstraint(fields=('user', 'instance'), name='unique_user_instance'), + ), + ] diff --git a/accounts/migrations/0013_default_admin_password.py b/accounts/migrations/0013_default_admin_password.py new file mode 100644 index 00000000..f22fde1e --- /dev/null +++ b/accounts/migrations/0013_default_admin_password.py @@ -0,0 +1,19 @@ +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.db import migrations + + +def flag_default_admin_password(apps, schema_editor): + """Versions up to v2.0.0 created the first user as admin/admin. An + installation that never changed it must replace it at the next login.""" + alias = schema_editor.connection.alias + User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) + Attributes = apps.get_model("accounts", "UserAttributes") + for user in User.objects.using(alias).filter(username="admin"): + if check_password("admin", user.password): + Attributes.objects.using(alias).update_or_create(user_id=user.pk, defaults={"must_change_password": True}) + + +class Migration(migrations.Migration): + dependencies = [("accounts", "0012_user_instance_constraint")] + operations = [migrations.RunPython(flag_default_admin_password, migrations.RunPython.noop)] diff --git a/accounts/models.py b/accounts/models.py index 88cbb232..07e65862 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -3,6 +3,7 @@ from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ from instances.models import Instance +from knox.models import AbstractAuthToken class UserInstanceManager(models.Manager): @@ -26,13 +27,13 @@ class UserInstance(models.Model): } class Meta: - unique_together = ["user", "instance"] + constraints = [models.UniqueConstraint(fields=["user", "instance"], name="unique_user_instance")] class UserSSHKey(models.Model): - user = models.ForeignKey(User, on_delete=models.DO_NOTHING) + user = models.ForeignKey(User, on_delete=models.CASCADE) keyname = models.CharField(_("key name"), max_length=25) - keypublic = models.CharField(_("public key"), max_length=500) + keypublic = models.TextField(_("public key"), max_length=16384) def __str__(self): return self.keyname @@ -41,28 +42,29 @@ class UserSSHKey(models.Model): class UserAttributes(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) can_clone_instances = models.BooleanField(default=True) + must_change_password = models.BooleanField(default=False) max_instances = models.IntegerField( _("max instances"), default=2, - help_text=_("-1 for unlimited. Any integer value"), + help_text=_("-1: no limit; 0: none allowed"), validators=[MinValueValidator(-1)], ) max_cpus = models.IntegerField( _("max CPUs"), default=2, - help_text=_("-1 for unlimited. Any integer value"), + help_text=_("-1: no limit; 0: none allowed"), validators=[MinValueValidator(-1)], ) max_memory = models.IntegerField( - _("max memory"), + _("max memory (MiB)"), default=2048, - help_text=_("-1 for unlimited. Any integer value"), + help_text=_("-1: no limit; 0: none allowed"), validators=[MinValueValidator(-1)], ) max_disk_size = models.IntegerField( - _("max disk size"), + _("max disk size (GiB)"), default=20, - help_text=_("-1 for unlimited. Any integer value"), + help_text=_("-1: no limit; 0: none allowed"), validators=[MinValueValidator(-1)], ) @@ -80,3 +82,15 @@ class PermissionSet(models.Model): permissions = (("change_password", _("Can change password")),) managed = False + + +class ApiToken(AbstractAuthToken): + """A token for the REST API, kept as a digest (django-rest-knox); the + name says what uses it.""" + LIMIT = 10 # per user, expired ones not counted + + name = models.CharField(_("name"), max_length=64) + last_used = models.DateTimeField(_("last used"), null=True, blank=True) + + class Meta: + ordering = ["-created"] diff --git a/accounts/otp.py b/accounts/otp.py new file mode 100644 index 00000000..fc95da79 --- /dev/null +++ b/accounts/otp.py @@ -0,0 +1,45 @@ +"""The login form used when OTP_ENABLED. + +django-otp's ``OTPAuthenticationForm`` requires every user to present a token +and, since 1.6, to name the device explicitly, so the single-field login page +cannot satisfy it and a user who never enrolled is locked out. This form makes +OTP per user: a user with a confirmed TOTP device must enter a valid code, one +without a device logs in with the password alone. The device is chosen by the +server, so the page needs only the token field and a posted device id (which +django-otp does not check for confirmed status or ownership-by-type) is +ignored. +""" + +from django.contrib.auth.signals import user_login_failed +from django.core.exceptions import ValidationError +from django_otp.forms import OTPAuthenticationForm +from django_otp.plugins.otp_totp.models import TOTPDevice + +from accounts.forms import BoundedUsernameMixin + + +class SingleDeviceOTPForm(BoundedUsernameMixin, OTPAuthenticationForm): + def _confirmed_totp(self, user, for_verify=False): + """The user's oldest confirmed TOTP device, or None. With for_verify + the row is locked (select_for_update) so the token's replay and + throttling counters cannot race; call it only inside clean_otp's + transaction.""" + devices = TOTPDevice.objects.filter(user=user, confirmed=True) + if for_verify: + devices = devices.select_for_update() + return devices.order_by("id").first() + + def _chosen_device(self, user): + # Ignore a posted otp_device: it is verified within clean_otp's + # transaction, where the lock belongs. + return self._confirmed_totp(user, for_verify=True) + + def clean_otp(self, user): + if user is not None and self._confirmed_totp(user) is None: + return # no confirmed device: the password is the only factor + try: + return super().clean_otp(user) + except ValidationError: + # a wrong code is a failed login: counted by django-axes and logged + user_login_failed.send(sender=__name__, credentials={"username": user.get_username()}, request=self.request) + raise diff --git a/accounts/templates/account.html b/accounts/templates/account.html index 3dc77687..0f58d8d3 100644 --- a/accounts/templates/account.html +++ b/accounts/templates/account.html @@ -2,92 +2,96 @@ {% load i18n %} {% load bootstrap_icons %} -{% load qr_code %} {% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %} -{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %} - -{% block page_heading_extra %} -{% if otp_enabled %} - - {% bs_icon 'qr-code' %} - -{% endif %} - - {% bs_icon 'pencil' %} - - - {% bs_icon 'plus-circle-fill' %} - -{% endblock page_heading_extra %} +{% block page_heading %}{% trans "User Profile" %}{% endblock page_heading %} {% block content %} - - -
-
- - - - - - - - - - - - - {% for inst in user_insts %} - - - - - - - - - - {% endfor %} - -
#{% trans "Instance" %}{% trans "VNC" %}{% trans "Resize" %}{% trans "Delete" %}{% trans "Action" %}
{{ forloop.counter }}{{ inst.instance.name }}{{ inst.is_vnc }}{{ inst.is_change }}{{ inst.is_delete }} - - {% bs_icon 'pencil' %} - - - - {% bs_icon 'trash' %} - -
-
-
- - - - - - - - - {% for publickey in publickeys %} - - - - - {% endfor %} - -
{% trans "Key name" %}{% trans "Public key" %}
{{ publickey.keyname }}{{ publickey.keypublic|truncatechars:64 }}
+
+
+
+
+
+

{{ user.get_full_name|default:user.username }}

+ {% if user.is_superuser %} + {% trans "Superuser" %} + {% elif user.is_staff %} + {% trans "Template editor" %} + {% endif %} + {% if not user.is_active %}{% trans "Blocked" %}{% endif %} +
+
+ {{ user.username }} + {% if user.email %}{{ user.email }}{% endif %} + {% for group in user.groups.all %}{{ group.name }}{% endfor %} +
+
+ {% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }} + {% trans "Joined" %} {{ user.date_joined|date:"Y-m-d" }} +
+
+
+ {% bs_icon 'pencil' %} {% trans "Edit user" %} + {% if otp_enabled %} +
{% csrf_token %} + +
+ {% endif %} +
+ +
+ +
+
+

{% trans "VMs" %} {{ user_insts|length }}

+ {% bs_icon 'plus-lg' %} {% trans "Add VM" %} +
+
+ {% for inst in user_insts %} +
+
+
+
+ {{ inst.instance.name }} +
{% trans "Host" %}: {{ inst.instance.compute.name }}
+
+
+ {# the rights as the VM's Users tab names them: every owner powers the VM and opens its console #} + {% if inst.is_change %}{% trans "Change settings" %}{% endif %} + {% if inst.is_delete %}{% trans "Delete the VM" %}{% endif %} + {% if inst.is_change and inst.is_vnc %}{% trans "Change console settings" %}{% endif %} + {% if not inst.is_change and not inst.is_delete %}{% trans "Power and console only" %}{% endif %} +
+
+ +
+
+ {% empty %} +
{% trans "No VMs." %}
+ {% endfor %} +
+
+ +
+

{% trans "SSH Keys" %} {{ publickeys|length }}

+
{% include "ssh_key_list.html" with owner=user %}
+
+ +
+

{% trans "API Tokens" %} {{ api_tokens|length }}

+
{% include "api_token_list.html" with owner=user %}
+
{% endblock content %} diff --git a/accounts/templates/accounts/change_password_form.html b/accounts/templates/accounts/change_password_form.html index 3df2d0ed..6d3f2dae 100644 --- a/accounts/templates/accounts/change_password_form.html +++ b/accounts/templates/accounts/change_password_form.html @@ -1,34 +1,39 @@ {% extends "base.html" %} - {% load django_bootstrap5 %} {% load i18n %} -{% load bootstrap_icons %} -{% block title %}{%trans "Change Password" %}{% endblock title %} +{% block title %}{% trans "Change Password" %}{% endblock title %} + +{% block page_heading %}{% trans "Change Password" %}{% endblock page_heading %} {% block content %} -
-
-
-
-

{%trans "Change Password" %}: {{ user }}

+{# a user changes their own password; one who must (a new or reset account) cannot go elsewhere first #} +
+ {% csrf_token %} + {% bootstrap_form_errors form type="non_fields" %} + +
+
+
+

{{ request.user.username }}

+

+ {% if must_change %}{% trans "Choose a new password before you go on." %}{% else %}{% trans "You stay signed in here; your other sessions end." %}{% endif %} +

-
- - {% csrf_token %} - {% bootstrap_form form layout='horizontal' %} - -
-
+ +
+ {% if not must_change %}{% trans "Cancel" %}{% endif %} +
-
-{% endblock content %} \ No newline at end of file + +{% endblock content %} diff --git a/accounts/templates/accounts/email/otp.html b/accounts/templates/accounts/email/otp.html deleted file mode 100644 index c80ec147..00000000 --- a/accounts/templates/accounts/email/otp.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load i18n %} -{% load qr_code %} -{% blocktrans %} -Scan this QR code to get OTP for account '{{ user }}' -{% endblocktrans %} -
-{% qr_from_text totp_url %} -

{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}

-{% qr_from_text totp_url size="s" image_format="png" error_correction="M" %} diff --git a/accounts/templates/accounts/email_otp_form.html b/accounts/templates/accounts/email_otp_form.html index 83ba2d9a..da7277f0 100644 --- a/accounts/templates/accounts/email_otp_form.html +++ b/accounts/templates/accounts/email_otp_form.html @@ -22,7 +22,7 @@