diff --git a/README.md b/README.md index 0aca0673..2297afef 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,23 @@ Run WebVirtCloud in a container with persistent volumes for data and SSH keys: git clone https://github.com/retspen/webvirtcloud cd webvirtcloud -# 2. Start services: +# 2. Create docker-compose.override.yml (below) with your settings + +# 3. Start services: docker compose up -d ``` +Local settings go in `docker-compose.override.yml`, which Compose merges into +`docker-compose.yml` and which `git pull` never touches. At least list the +names/IPs the panel is reached by: + +```yaml +services: + webvirtcloud: + environment: + - ALLOWED_HOSTS=wvc.example.com,192.0.2.10 +``` + Access the panel at `http://` and noVNC console at port `6080`. ## Manual Installation @@ -106,6 +119,13 @@ python3 conf/runit/secret_generator.py > data/secret_key chmod 600 data/secret_key ``` +### Allowed hosts + +The template answers only to localhost. After copying it in step 3 below, list +the names/IPs the panel and the cloud-init datasource are reached by in +`webvirtcloud/settings.py`, e.g. +`ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]`. + ### Ubuntu 20.04 / 22.04 / 24.04 LTS & Debian 11 / 12 ```bash @@ -442,6 +462,15 @@ of `webvirtcloud/settings.py.template` into your `webvirtcloud/settings.py`, set (`sudo nginx -t && sudo systemctl reload nginx`). Re-running the installer replaces that file, including any TLS directives you added to it. +`ALLOWED_HOSTS` no longer defaults to `"*"`: the template allows localhost, the +installer adds the server's name and IPs, and the `ALLOWED_HOSTS` env var +(comma-separated) adds more. Docker: set `ALLOWED_HOSTS` in +`docker-compose.override.yml` to the names/IPs you open the panel and the cloud-init datasource with; requests +for any other host get 400. Bare metal (your settings.py is kept): replace +`ALLOWED_HOSTS = ["*"]` with the names/IPs the panel and the datasource are +reached by, and copy the `if extra_hosts := ...` lines below it from the +template so the env var works. + ```bash # Go to Installation Directory cd /srv/webvirtcloud @@ -453,6 +482,18 @@ python3 manage.py collectstatic --noinput sudo systemctl restart supervisor # supervisord on RHEL / openSUSE ``` +Docker: each new container generates `settings.py` from the template and runs +the migrations at start; the database and the secret key stay in the `data` +volume. Keep your variables (at least `ALLOWED_HOSTS`) in +`docker-compose.override.yml` (see Docker Deployment), then: + +```bash +cd webvirtcloud +docker compose cp webvirtcloud:/srv/webvirtcloud/data/db.sqlite3 ./db.sqlite3.bak +git pull +docker compose up -d --build +``` + > **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'`. diff --git a/conf/install-lib.sh b/conf/install-lib.sh index 128625ef..ee1c17b1 100755 --- a/conf/install-lib.sh +++ b/conf/install-lib.sh @@ -58,3 +58,16 @@ wvc_install_nginx() { cp "$snippet" "$etc/conf.d/webvirtcloud.conf" return 0 } + +# Python list literal for ALLOWED_HOSTS: the FQDN, the loopbacks and the given +# IPs. IPv6 addresses are bracketed, as Django compares them. +wvc_allowed_hosts() { + local host out="" seen=" " + for host in "$1" localhost 127.0.0.1 ::1 "${@:2}"; do + case "$host" in *:*) host="[$host]" ;; esac + case "$seen" in *" $host "*) continue ;; esac + seen+="$host " + out+="${out:+, }'$host'" + done + echo "[$out]" +} diff --git a/conf/test_install_lib.sh b/conf/test_install_lib.sh index 30d9758c..5bf51615 100755 --- a/conf/test_install_lib.sh +++ b/conf/test_install_lib.sh @@ -66,5 +66,9 @@ echo "OP_EDIT" > "$etc3/nginx.conf" wvc_install_nginx "$tmp/main_tpl" "$tmp/snippet" "$etc3" check "no-orig second run keeps operator edit" "[ \"\$(cat '$etc3/nginx.conf')\" = OP_EDIT ]" +# wvc_allowed_hosts: fqdn + loopbacks + IPs, IPv6 bracketed, no duplicates +check "allowed hosts list" "[ \"\$(wvc_allowed_hosts wvc.example.com 192.0.2.10 2001:db8::1 127.0.0.1)\" = \"['wvc.example.com', 'localhost', '127.0.0.1', '[::1]', '192.0.2.10', '[2001:db8::1]']\" ]" +check "allowed hosts without ips" "[ \"\$(wvc_allowed_hosts wvc.example.com)\" = \"['wvc.example.com', 'localhost', '127.0.0.1', '[::1]']\" ]" + [ "$fail" = 0 ] && echo "ALL PASS" || echo "FAILURES" exit "$fail" diff --git a/console/tests.py b/console/tests.py index 68d959cf..8e62f69c 100644 --- a/console/tests.py +++ b/console/tests.py @@ -268,6 +268,7 @@ class ConsoleViewsTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.context["ws_path"], "") + @override_settings(ALLOWED_HOSTS=["panel.example.com"]) @patch("console.views.wvmInstance") def test_console_ws_host_strip_port(self, mock_wvm): mock_conn = MagicMock() diff --git a/docker-compose.yml b/docker-compose.yml index 0cc0764c..9e6544fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: # - SECRET_KEY=your-custom-secret-key-here # Optional: comma-separated trusted CSRF origins (e.g. http://wvc.example.com,https://wvc.example.com) # - CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 + # Host names/IPs the panel is reached by (comma-separated; "*" accepts any). + # Without it only localhost works. Set it in docker-compose.override.yml. + - ALLOWED_HOSTS=localhost - TZ=UTC volumes: # Persistent SQLite database and runtime data diff --git a/webvirtcloud.sh b/webvirtcloud.sh index 6154e7f2..786db083 100755 --- a/webvirtcloud.sh +++ b/webvirtcloud.sh @@ -318,6 +318,9 @@ install_webvirtcloud () { done fi sed -i "s|^\\(CSRF_TRUSTED_ORIGINS = \\).*|\\1\[ \'http://$fqdn\', $host_ip ]|" "$APP_PATH/webvirtcloud/settings.py" + # shellcheck disable=SC2046 + allowed_hosts="$(wvc_allowed_hosts "$fqdn" $(hostname -I 2>/dev/null))" + sed -i "s|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = $allowed_hosts|" "$APP_PATH/webvirtcloud/settings.py" else echo "* Keeping existing settings.py (not overwritten)." fi diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index 8f143606..e8632cad 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -29,7 +29,11 @@ DEBUG = False MAC_OUI = '52:54:10' -ALLOWED_HOSTS = ["*"] +# Host names the panel answers to. The installer writes the server's; add more +# (comma-separated, "*" for any) through the environment. +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] +if extra_hosts := os.getenv("ALLOWED_HOSTS"): + ALLOWED_HOSTS.extend([host.strip() for host in extra_hosts.split(",") if host.strip()]) CSRF_TRUSTED_ORIGINS = ["http://localhost", "http://127.0.0.1", "http://localhost:8000", "http://127.0.0.1:8000"] if extra_origins := os.getenv("CSRF_TRUSTED_ORIGINS"): diff --git a/webvirtcloud/test_settings_env.py b/webvirtcloud/test_settings_env.py index d679121a..584dff66 100644 --- a/webvirtcloud/test_settings_env.py +++ b/webvirtcloud/test_settings_env.py @@ -7,11 +7,11 @@ import subprocess import sys from django.conf import settings -from django.test import TestCase, override_settings +from django.test import SimpleTestCase, TestCase, override_settings # Env vars that change the settings; removed from the inherited environment so # every case starts from the defaults. -CONFIG_VARS = ["WEBVIRTCLOUD_HTTPS"] +CONFIG_VARS = ["WEBVIRTCLOUD_HTTPS", "ALLOWED_HOSTS"] def _env(env): @@ -68,3 +68,20 @@ class HttpsProfileTestCase(TestCase): with override_settings(SECURE_SSL_REDIRECT=True): response = self.client.get("/accounts/login/", HTTP_X_FORWARDED_PROTO="https") self.assertNotEqual(response.status_code, 301) + + +class AllowedHostsTestCase(SimpleTestCase): + def hosts(self, env): + return load_settings(env, ["ALLOWED_HOSTS"])["ALLOWED_HOSTS"] + + def test_allowed_hosts_default_is_not_any_host(self): + hosts = self.hosts({}) + self.assertNotIn("*", hosts) + self.assertIn("localhost", hosts) + + def test_allowed_hosts_extended_from_env(self): + hosts = self.hosts({"ALLOWED_HOSTS": " wvc.example.com, ,192.0.2.10 "}) + self.assertEqual(hosts[-2:], ["wvc.example.com", "192.0.2.10"]) + + def test_allowed_hosts_star_escape(self): + self.assertIn("*", self.hosts({"ALLOWED_HOSTS": "*"}))