openvibers/openvibe.host Commits

ovhost nginx tenants: vhosts for the dashboard, *.openvibe.host and verified custom domains

OP OpenVibers committed 23 Sept 2026 · 5512cf7df4 · 11 files, +497 −4

Extends lib/nginx.js and the nginx templates for Stage B. The wildcard
vhost takes the certificate path as a parameter (--wildcard-cert), serves
the dashboard/API with large bodies only on the upload routes, and gives
tenant sites GET/HEAD only with cookies stripped in both directions.
Verified custom domains are read from the Host database read-only as the
service user; values that are not plain host names (or fall under the
sites domain) are refused before they reach nginx config, HTTPS is
rendered only where a certificate exists, and the rest answer the ACME
challenge until the operator issues one. Install stays transactional and
no key file is ever read. host.example.json gains the host service.

Changes

ddiff --git a/deploy/nginx/openvibe.host.conf b/deploy/nginx/openvibe.host.conf
nnew file mode 100644
iindex 0000000..c6d85f4
---- /dev/null
++++ b/deploy/nginx/openvibe.host.conf
@@@ -0,0 +1,115 @@
++## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with
++## that command; install with --install, which also writes the custom-domain vhost).
++## ═══════════════════════════════════════════════════════════════
++## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`
++##
++## OpenVibe.Host Stage B (tenant static hosting), port 4910.
++## openvibe.host the dashboard and the Host API (/api/v1), uploads up to 110m
++## *.openvibe.host tenant sites: GET/HEAD only, cookies stripped both ways
++## Wildcard certificate: /etc/letsencrypt/live/openvibe.host (issued by the operator with a DNS-01 challenge; see README).
++## `--install` writes /etc/nginx/sites-available/openvibe.host.conf, links it, and reloads nginx only if `nginx -t` passes.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovhost_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=ovhost_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=ovhost_sites:20m rate=50r/s;
++
++# ── HTTP → HTTPS ───────────────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.host *.openvibe.host;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://$host$request_uri; }
++}
++
++# ── Dashboard + API ────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.host;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.host.access.log;
++ error_log /var/log/nginx/openvibe.host.error.log;
++
++ client_max_body_size 1m;
++
++ add_header Strict-Transport-Security "max-age=31536000" always;
++
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ location /auth/ {
++ limit_req zone=ovhost_auth burst=10 nodelay;
++ proxy_pass http://127.0.0.1:4910;
++ }
++
++ # Deploy uploads (API and dashboard form): large bodies, streamed to the service.
++ location ~ ^/(api/v1/)?sites/[A-Za-z0-9_]+/deploys$ {
++ limit_req zone=ovhost_api burst=10 nodelay;
++ client_max_body_size 110m;
++ proxy_request_buffering off;
++ proxy_read_timeout 600s;
++ proxy_send_timeout 600s;
++ proxy_pass http://127.0.0.1:4910;
++ }
++
++ location /api/ {
++ limit_req zone=ovhost_api burst=30 nodelay;
++ proxy_pass http://127.0.0.1:4910;
++ }
++
++ # Prometheus metrics: host-local scrapers only (http://127.0.0.1:4910/metrics).
++ location = /metrics { return 404; }
++
++ location / {
++ limit_req zone=ovhost_api burst=40 nodelay;
++ proxy_pass http://127.0.0.1:4910;
++ }
++}
++
++# ── Tenant sites ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name *.openvibe.host;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/sites.openvibe.host.access.log;
++ error_log /var/log/nginx/sites.openvibe.host.error.log;
++
++ # Static files only: no request bodies, no cookies in either direction.
++ client_max_body_size 1k;
++ add_header Strict-Transport-Security "max-age=31536000" always;
++
++ location / {
++ limit_except GET HEAD { deny all; }
++ limit_req zone=ovhost_sites burst=200 nodelay;
++ proxy_pass http://127.0.0.1:4910;
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header Cookie "";
++ proxy_set_header Authorization "";
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ proxy_hide_header Set-Cookie;
++ proxy_read_timeout 60s;
++ }
++
++}
ddiff --git a/host.example.json b/host.example.json
iindex b74ae0c..50b30a5 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -156,6 +156,19 @@
"install": { "command": ["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"], "always": true },
"build": [["node", "build.js"]],
"nginx": { "repoVhosts": "deploy/nginx/*.conf", "installOnDeploy": true }
++ },
++ "host": {
++ "_note": "OpenVibe.Host Stage B (tenant static hosting): the Host API service on port 4910. Not deployed as of 22 Sep 2026. Its database and content-addressed objects live in /var/lib/openvibe-host-api, which is NOT ovhost's own stateDir (/var/lib/openvibe-host). Tenant vhosts: `ovhost nginx tenants host`.",
++ "repo": "/opt/openvibe.host",
++ "units": ["openvibe-host.service"],
++ "unitSources": { "openvibe-host.service": "deploy/systemd/openvibe-host.service" },
++ "envFile": "/etc/openvibe/host.env",
++ "env": { "required": ["BASE_URL", "OV_NETWORK_URL", "OV_OAUTH_CLIENT_ID", "OV_OAUTH_CLIENT_SECRET", "HOST_FORM_SECRET", "HOST_SITES_DOMAIN"] },
++ "port": 4910,
++ "ready": { "url": "http://127.0.0.1:4910/api/ready", "timeoutSeconds": 30 },
++ "noRestartPaths": ["docs/", "test/", "**/*.md"],
++ "databases": [{ "name": "host", "path": "/var/lib/openvibe-host-api/host.db" }],
++ "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m" } }
}
}
}
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 72843d1..95691ca 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -24,6 +24,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
print a vhost from the service manifest; --install writes it,
runs nginx -t and reloads only if clean
++ nginx tenants <service> [--wildcard-cert <name|dir>] [--install]
++ Stage B: the dashboard + *.<sites domain> vhost and one vhost
++ for the VERIFIED custom domains (read from the service's
++ database); lists domains still waiting for a certificate
snapshot <service> [--out <file>] config tarball: units, vhost, env NAMES, sha — no secret values
backup <service> sqlite .backup of the declared databases to a dated directory
@@@ -32,7 +36,7 @@ Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restart
rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active
`;
--const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout']);
++const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert']);
function parseArgs(argv) {
const pos = [];
@@@ -158,7 +162,24 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return list.some((c) => ['expired', 'missing', 'unreadable'].includes(c.status)) ? 2 : 0;
}
case 'nginx': {
-- if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--install]');
++ if (pos[1] === 'tenants') {
++ const id = pos[2];
++ if (!id) throw new UsageError('nginx tenants needs a <service>');
++ const svc = lib.inventory.service(inv, id);
++ const domains = await lib.nginx.tenantDomains(exec, svc);
++ const r = lib.nginx.renderTenants(inv, svc, { wildcardCert: opts['wildcard-cert'], domains });
++ for (const h of r.refused) log(`refused a custom domain value that is not a plain host name: ${JSON.stringify(h)}`);
++ if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }
++ if (!opts.install) {
++ for (const f of r.files) out(`# ---- ${f.name} ----\n${f.text}`);
++ } else {
++ const res = await lib.nginx.install(exec, inv, r.files, { log });
++ if (!res.changed.length) log('tenant vhosts already installed; nothing changed');
++ }
++ for (const h of r.needCert) log(`needs a certificate: certbot certonly --webroot -w /var/www/certbot -d ${h} (then run this again)`);
++ return 0;
++ }
++ if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--install] | ovhost nginx tenants <service> [--wildcard-cert …] [--install]');
const id = pos[2];
if (!id) throw new UsageError('nginx render needs a <service>');
const svc = lib.inventory.service(inv, id);
ddiff --git a/lib/nginx.js b/lib/nginx.js
iindex 348d2f3..ab7d687 100644
---- a/lib/nginx.js
++++ b/lib/nginx.js
@@@ -127,4 +127,105 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
return { changed, reloaded: true };
}
--module.exports = { render, test, install, defaultCertName, fill };
++// ── Stage B: tenant static hosting (OpenVibe.Host's own service) ─────────────
++
++const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
++const HOSTNAME_RE = new RegExp(`^(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`);
++const VERIFIED_SQL = "SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified' ORDER BY hostname";
++
++function isHostname(h) {
++ return typeof h === 'string' && h.length <= 253 && HOSTNAME_RE.test(h);
++}
++
++/** A certificate directory from a --wildcard-cert value: a Let's Encrypt name or an absolute directory. */
++function certDirFor(value, base = '/etc/letsencrypt/live') {
++ const v = String(value || '').replace(/\/+$/, '');
++ if (!v) throw new Error('empty certificate path');
++ if (path.isAbsolute(v)) {
++ if (!/^\/[A-Za-z0-9/._-]+$/.test(v) || v.split('/').includes('..')) throw new Error(`certificate directory "${v}" is not a plain absolute path`);
++ return /\.pem$/.test(v) ? path.dirname(v) : v;
++ }
++ if (!/^[A-Za-z0-9._-]+$/.test(v) || v === '..' || v === '.') throw new Error(`certificate name "${v}" is not a plain name`);
++ return path.join(base, v);
++}
++
++/** The inventory's nginx.tenants block with defaults. */
++function tenantsConfig(svc) {
++ const t = (svc.nginx && svc.nginx.tenants) || null;
++ if (!t) throw new Error(`${svc.id} has no nginx.tenants block in the inventory (it is not a tenant-hosting service)`);
++ const sitesDomain = String(t.sitesDomain || 'openvibe.host').toLowerCase();
++ const dashboard = String(t.dashboardDomain || sitesDomain).toLowerCase();
++ for (const d of [sitesDomain, dashboard]) if (!isHostname(d)) throw new Error(`"${d}" is not a host name`);
++ if (/(^|\.)openvibe\.(network|live|media|community|tools)$/.test(sitesDomain)) throw new Error(`tenant sites must not live under ${sitesDomain}: its cookies would reach tenant pages`);
++ if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);
++ if (t.maxUpload && !/^\d+[km]?$/.test(String(t.maxUpload))) throw new Error('nginx.tenants.maxUpload must look like 110m');
++ return {
++ sitesDomain,
++ dashboard,
++ wildcardCert: t.wildcardCert || sitesDomain,
++ certBase: t.certBase || '/etc/letsencrypt/live',
++ database: t.database || null,
++ vhost: t.vhost || `${sitesDomain}.conf`,
++ customVhost: t.customVhost || `${sitesDomain}-custom-domains.conf`,
++ maxUpload: t.maxUpload || '110m',
++ };
++}
++
++/**
++ * The wildcard vhost (dashboard + *.sitesDomain) and the custom-domain vhost.
++ * domains: [{ hostname, hasCert }] (verified custom domains). -> { files: [{ name, text }], needCert: [hostname], refused: [value] }
++ */
++function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
++ const t = tenantsConfig(svc);
++ const certDir = certDirFor(wildcardCert || t.wildcardCert, t.certBase);
++ const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;
++ const base = { port: svc.port, zone, sitesDomain: t.sitesDomain, service: svc.id };
++ const tenantLocation = fill(readTemplate('tenant-location.tmpl'), base);
++ const main = fill(readTemplate('tenants.conf.tmpl'), {
++ ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation,
++ });
++
++ const refused = [];
++ const ok = [];
++ const seen = new Set();
++ for (const d of domains) {
++ const h = String(d.hostname || '').toLowerCase();
++ // Values come from a database tenants write to: anything that is not a plain host name, or
++ // that falls under the sites domain / dashboard, is refused before it can reach nginx config.
++ if (!isHostname(h) || h === t.dashboard || h.endsWith(`.${t.sitesDomain}`) || h === t.sitesDomain || seen.has(h)) { refused.push(String(d.hostname)); continue; }
++ seen.add(h);
++ ok.push({ hostname: h, hasCert: !!d.hasCert });
++ }
++ const withCert = ok.filter((d) => d.hasCert);
++ const withoutCert = ok.filter((d) => !d.hasCert);
++ let servers = '';
++ if (withCert.length) servers += fill(readTemplate('custom-http.tmpl'), { names: withCert.map((d) => d.hostname).join(' '), fallback: 'return 301 https://$host$request_uri;' });
++ if (withoutCert.length) servers += fill(readTemplate('custom-http.tmpl'), { names: withoutCert.map((d) => d.hostname).join(' '), fallback: 'return 404;' });
++ for (const d of withCert) servers += fill(readTemplate('custom-https.tmpl'), { ...base, hostname: d.hostname, certDir: path.join(t.certBase, d.hostname), tenantLocation });
++ const custom = fill(readTemplate('custom-domains.conf.tmpl'), {
++ service: svc.id, database: t.database || '(the Host database)', withCert: withCert.length, withoutCert: withoutCert.length,
++ servers: servers || '\n# No verified custom domains yet.\n',
++ });
++ return {
++ files: [{ name: t.vhost, text: main }, { name: t.customVhost, text: custom }],
++ needCert: withoutCert.map((d) => d.hostname),
++ refused,
++ certDir,
++ };
++}
++
++/** Verified custom domains from the Host database (read-only, as the service user) + certificate presence. */
++async function tenantDomains(exec, svc) {
++ const t = tenantsConfig(svc);
++ if (!t.database) throw new Error(`${svc.id}: nginx.tenants.database is not set in the inventory`);
++ const rows = await exec.sqlite(t.database, VERIFIED_SQL, { as: svc.runAs });
++ const out = [];
++ for (const r of rows || []) {
++ const h = String(r.hostname || '').toLowerCase();
++ const hasCert = isHostname(h) ? Boolean(await exec.stat(path.join(t.certBase, h, 'fullchain.pem'))) : false;
++ out.push({ hostname: r.hostname, hasCert });
++ }
++ return out;
++}
++
++module.exports = { render, test, install, defaultCertName, fill, renderTenants, tenantDomains, tenantsConfig, certDirFor, VERIFIED_SQL };
ddiff --git a/templates/nginx/custom-domains.conf.tmpl b/templates/nginx/custom-domains.conf.tmpl
nnew file mode 100644
iindex 0000000..406fedb
---- /dev/null
++++ b/templates/nginx/custom-domains.conf.tmpl
@@@ -0,0 +1,10 @@
++## ═══════════════════════════════════════════════════════════════
++## Verified custom domains of OpenVibe.Host tenants — generated by `ovhost nginx tenants {{service}}`
++##
++## Source: host_domains rows with kind = 'custom' AND status = 'verified' in {{database}}, read
++## read-only as the service user. {{withCert}} domain(s) have a certificate and are served over HTTPS;
++## {{withoutCert}} wait for one (HTTP answers only the ACME challenge until the operator issues it:
++## certbot certonly --webroot -w /var/www/certbot -d <hostname>
++## then run this command again). Certificates and keys are never read or printed here.
++## ═══════════════════════════════════════════════════════════════
++{{servers}}
ddiff --git a/templates/nginx/custom-http.tmpl b/templates/nginx/custom-http.tmpl
nnew file mode 100644
iindex 0000000..b4310f5
---- /dev/null
++++ b/templates/nginx/custom-http.tmpl
@@@ -0,0 +1,9 @@
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name {{names}};
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { {{fallback}} }
++}
ddiff --git a/templates/nginx/custom-https.tmpl b/templates/nginx/custom-https.tmpl
nnew file mode 100644
iindex 0000000..e8eaff6
---- /dev/null
++++ b/templates/nginx/custom-https.tmpl
@@@ -0,0 +1,15 @@
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name {{hostname}};
++
++ ssl_certificate {{certDir}}/fullchain.pem;
++ ssl_certificate_key {{certDir}}/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/sites.{{sitesDomain}}.access.log;
++ error_log /var/log/nginx/sites.{{sitesDomain}}.error.log;
++{{tenantLocation}}
++}
ddiff --git a/templates/nginx/tenant-location.tmpl b/templates/nginx/tenant-location.tmpl
nnew file mode 100644
iindex 0000000..2925676
---- /dev/null
++++ b/templates/nginx/tenant-location.tmpl
@@@ -0,0 +1,20 @@
++
++ # Static files only: no request bodies, no cookies in either direction.
++ client_max_body_size 1k;
++ add_header Strict-Transport-Security "max-age=31536000" always;
++
++ location / {
++ limit_except GET HEAD { deny all; }
++ limit_req zone={{zone}}_sites burst=200 nodelay;
++ proxy_pass http://127.0.0.1:{{port}};
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header Cookie "";
++ proxy_set_header Authorization "";
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ proxy_hide_header Set-Cookie;
++ proxy_read_timeout 60s;
++ }
ddiff --git a/templates/nginx/tenants.conf.tmpl b/templates/nginx/tenants.conf.tmpl
nnew file mode 100644
iindex 0000000..1747e03
---- /dev/null
++++ b/templates/nginx/tenants.conf.tmpl
@@@ -0,0 +1,93 @@
++## ═══════════════════════════════════════════════════════════════
++## {{dashboard}} and *.{{sitesDomain}} — generated by `ovhost nginx tenants {{service}}`
++##
++## OpenVibe.Host Stage B (tenant static hosting), port {{port}}.
++## {{dashboard}} the dashboard and the Host API (/api/v1), uploads up to {{maxUpload}}
++## *.{{sitesDomain}} tenant sites: GET/HEAD only, cookies stripped both ways
++## Wildcard certificate: {{certDir}} (issued by the operator with a DNS-01 challenge; see README).
++## `--install` writes {{sitesAvailable}}/{{vhost}}, links it, and reloads nginx only if `nginx -t` passes.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone={{zone}}_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone={{zone}}_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone={{zone}}_sites:20m rate=50r/s;
++
++# ── HTTP → HTTPS ───────────────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name {{dashboard}} *.{{sitesDomain}};
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://$host$request_uri; }
++}
++
++# ── Dashboard + API ────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name {{dashboard}};
++
++ ssl_certificate {{certDir}}/fullchain.pem;
++ ssl_certificate_key {{certDir}}/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/{{dashboard}}.access.log;
++ error_log /var/log/nginx/{{dashboard}}.error.log;
++
++ client_max_body_size 1m;
++
++ add_header Strict-Transport-Security "max-age=31536000" always;
++
++ proxy_http_version 1.1;
++ proxy_set_header Connection "";
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ location /auth/ {
++ limit_req zone={{zone}}_auth burst=10 nodelay;
++ proxy_pass http://127.0.0.1:{{port}};
++ }
++
++ # Deploy uploads (API and dashboard form): large bodies, streamed to the service.
++ location ~ ^/(api/v1/)?sites/[A-Za-z0-9_]+/deploys$ {
++ limit_req zone={{zone}}_api burst=10 nodelay;
++ client_max_body_size {{maxUpload}};
++ proxy_request_buffering off;
++ proxy_read_timeout 600s;
++ proxy_send_timeout 600s;
++ proxy_pass http://127.0.0.1:{{port}};
++ }
++
++ location /api/ {
++ limit_req zone={{zone}}_api burst=30 nodelay;
++ proxy_pass http://127.0.0.1:{{port}};
++ }
++
++ # Prometheus metrics: host-local scrapers only (http://127.0.0.1:{{port}}/metrics).
++ location = /metrics { return 404; }
++
++ location / {
++ limit_req zone={{zone}}_api burst=40 nodelay;
++ proxy_pass http://127.0.0.1:{{port}};
++ }
++}
++
++# ── Tenant sites ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name *.{{sitesDomain}};
++
++ ssl_certificate {{certDir}}/fullchain.pem;
++ ssl_certificate_key {{certDir}}/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/sites.{{sitesDomain}}.access.log;
++ error_log /var/log/nginx/sites.{{sitesDomain}}.error.log;
++{{tenantLocation}}
++}
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 43c53a5..92a77dc 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -17,7 +17,7 @@ runTests([
test('host.example.json is a valid inventory for every service on the host', () => {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'host.example.json'), 'utf8'));
const inv = normalise(raw);
-- assert.deepStrictEqual(Object.keys(inv.services), ['network', 'live', 'media', 'tools', 'community', 'events', 'games', 'sites']);
++ assert.deepStrictEqual(Object.keys(inv.services), ['network', 'live', 'media', 'tools', 'community', 'events', 'games', 'sites', 'host']);
assert.strictEqual(inv.services.live.socketUnit, 'openvibe-live.socket');
assert.ok(!inv.services.live.units.includes('openvibe-live.socket'));
assert.strictEqual(inv.services.live.protected.url, 'http://127.0.0.1:3000/api/streams');
ddiff --git a/test/nginx-tenants.test.js b/test/nginx-tenants.test.js
nnew file mode 100644
iindex 0000000..768c84f
---- /dev/null
++++ b/test/nginx-tenants.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * `ovhost nginx tenants host`: the dashboard + *.openvibe.host vhost with the wildcard certificate
++ * path as a parameter, and a vhost for VERIFIED custom domains only (read-only query as the service
++ * user), HTTPS only where a certificate exists, hostile database values refused, transactional
++ * install, and never a key file read.
++ */
++const assert = require('assert');
++const { scenario, test, runTests } = require('./helpers');
++
++function withHost(host, rows) {
++ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
++ doc.services.host = {
++ repo: '/opt/openvibe.host',
++ units: ['openvibe-host.service'],
++ port: 4910,
++ ready: { url: 'http://127.0.0.1:4910/api/ready', timeoutSeconds: 30 },
++ databases: [{ name: 'host', path: '/var/lib/openvibe-host-api/host.db' }],
++ nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: '/var/lib/openvibe-host-api/host.db', maxUpload: '110m' } },
++ };
++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
++ host.sqliteHandler = (db, sql, as) => {
++ assert.strictEqual(db, '/var/lib/openvibe-host-api/host.db');
++ assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);
++ assert.strictEqual(as, 'ubuntu', 'queried as the service user');
++ return rows;
++ };
++ host.put('/etc/letsencrypt/live/www.tenant-a.org/fullchain.pem', 'CERT', { mode: 0o644 });
++ host.put('/etc/letsencrypt/live/www.tenant-a.org/privkey.pem', '-----BEGIN PRIVATE KEY-----\nNOPE\n-----END PRIVATE KEY-----\n', { mode: 0o600 });
++ return host;
++}
++
++runTests([
++ test('renders the wildcard vhost (dashboard + tenants) and verified custom domains; certificates never read', async () => {
++ const host = withHost(scenario(), [{ hostname: 'www.tenant-a.org' }, { hostname: 'docs.tenant-b.net' }]);
++ const r = await host.cli('nginx', 'tenants', 'host', '--wildcard-cert', '/etc/letsencrypt/live/openvibe.host-wild');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /server_name openvibe\.host \*\.openvibe\.host;/, 'port 80 for both');
++ assert.match(r.out, /server_name \*\.openvibe\.host;[\s\S]*ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host-wild\/fullchain\.pem;/, 'the wildcard certificate path parameter');
++ assert.match(r.out, /limit_except GET HEAD \{ deny all; \}/);
++ assert.match(r.out, /proxy_set_header Cookie "";/);
++ assert.match(r.out, /proxy_hide_header Set-Cookie;/);
++ assert.match(r.out, /location ~ \^\/\(api\/v1\/\)\?sites\/\[A-Za-z0-9_\]\+\/deploys\$ \{[\s\S]*client_max_body_size 110m;/);
++ assert.match(r.out, /location = \/metrics \{ return 404; \}/);
++ assert.match(r.out, /server_name www\.tenant-a\.org;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/www\.tenant-a\.org\/fullchain\.pem;/, 'HTTPS where a certificate exists');
++ assert.ok(!/server_name docs\.tenant-b\.net;\n\n ssl_certificate/.test(r.out), 'no HTTPS block without a certificate');
++ assert.match(r.out, /server_name docs\.tenant-b\.net;[\s\S]*?location \/ \{ return 404; \}/, 'ACME only until the certificate exists');
++ assert.match(r.out, /needs a certificate: certbot certonly --webroot -w \/var\/www\/certbot -d docs\.tenant-b\.net/);
++ assert.ok(!/\{\{/.test(r.out));
++ assert.ok(!r.out.includes('BEGIN PRIVATE KEY'));
++ assert.ok(!host.reads.some((f) => /privkey/.test(f)), 'no key file read');
++ assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.host.conf'), null, 'render alone writes nothing');
++ }),
++
++ test('hostile or misplaced values from the database never reach nginx config', async () => {
++ const host = withHost(scenario(), [
++ { hostname: 'ok.example.org' }, { hostname: 'evil.org; } server { listen 80; root /; }' }, { hostname: 'x.openvibe.host' },
++ { hostname: 'openvibe.host' }, { hostname: '../../etc' }, { hostname: 'with space.org' }, { hostname: 'ok.example.org' },
++ ]);
++ const r = await host.cli('nginx', 'tenants', 'host');
++ assert.strictEqual(r.code, 0, r.out);
++ const custom = r.out.split('# ---- openvibe.host-custom-domains.conf ----')[1];
++ assert.match(custom, /server_name ok\.example\.org;/);
++ assert.ok(!/evil\.org|x\.openvibe\.host|\.\.\/|with space/.test(custom.replace(/^##.*$/gm, '')));
++ assert.strictEqual((custom.match(/server_name ok\.example\.org;/g) || []).length, 1, 'duplicates collapse');
++ assert.match(r.out, /refused a custom domain value that is not a plain host name: "evil\.org; \} server/);
++ assert.match(r.out, /ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;/, 'default wildcard certificate name');
++ }),
++
++ test('--install writes both vhosts, runs nginx -t and reloads; a failed test restores everything', async () => {
++ const host = withHost(scenario(), [{ hostname: 'www.tenant-a.org' }]);
++ const ok = await host.cli('nginx', 'tenants', 'host', '--install');
++ assert.strictEqual(ok.code, 0, ok.out);
++ assert.match(host.read('/etc/nginx/sites-available/openvibe.host.conf'), /server_name \*\.openvibe\.host;/);
++ assert.match(host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), /server_name www\.tenant-a\.org;/);
++ assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'));
++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'));
++
++ const fresh = withHost(scenario(), []);
++ fresh.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });
++ const bad = await fresh.cli('nginx', 'tenants', 'host', '--install');
++ assert.strictEqual(bad.code, 2);
++ assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host.conf'), null);
++ assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null);
++ }),
++
++ test('refuses a service without a tenants block, a bad certificate path, and sites under a first-party domain', async () => {
++ const host = withHost(scenario(), []);
++ assert.notStrictEqual((await host.cli('nginx', 'tenants', 'media')).code, 0);
++ const r = await host.cli('nginx', 'tenants', 'host', '--wildcard-cert', '../../etc/ssl');
++ assert.notStrictEqual(r.code, 0);
++ assert.match(r.out, /not a plain name/);
++ const nginx = require('../lib/nginx');
++ assert.throws(() => nginx.renderTenants({ nginx: { sitesAvailable: '/x' } }, { id: 'host', port: 4910, nginx: { tenants: { sitesDomain: 'sites.openvibe.network' } } }), /cookies would reach tenant pages/);
++ }),
++]);