openvibers/openvibe.host Commits
ovhost nginx tenants: vhosts for the dashboard, *.openvibe.host and verified custom domains
Changes
nnew file mode 100644@@@ -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;++ }++++}@@@ -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" } }}}}@@@ -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 certificatesnapshot <service> [--out <file>] config tarball: units, vhost, env NAMES, sha — no secret valuesbackup <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 restartrolled 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) => procreturn 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);@@@ -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 };nnew file mode 100644@@@ -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}}nnew file mode 100644@@@ -0,0 +1,9 @@++++server {++ listen 80;++ listen [::]:80;++ server_name {{names}};++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { {{fallback}} }++}nnew file mode 100644@@@ -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}}++}nnew file mode 100644@@@ -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;++ }nnew file mode 100644@@@ -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}}++}@@@ -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');nnew file mode 100644@@@ -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/);++ }),++]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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;
+ }
+
+}
@@ -156,6 +156,19 @@
@@ -156,6 +156,19 @@
"install": { "command": ["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"], "always": true },
"install": { "command": ["npm", "ci", "--omit=dev", "--no-audit", "--no-fund"], "always": true },
"build": [["node", "build.js"]],
"build": [["node", "build.js"]],
"nginx": { "repoVhosts": "deploy/nginx/*.conf", "installOnDeploy": true }
"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" } }
}
}
}
}
}
}
@@ -24,6 +24,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -24,6 +24,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
print a vhost from the service manifest; --install writes it,
print a vhost from the service manifest; --install writes it,
runs nginx -t and reloads only if clean
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
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
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
@@ -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
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) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -158,7 +162,24 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -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;
return list.some((c) => ['expired', 'missing', 'unreadable'].includes(c.status)) ? 2 : 0;
}
}
case 'nginx': {
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];
const id = pos[2];
if (!id) throw new UsageError('nginx render needs a <service>');
if (!id) throw new UsageError('nginx render needs a <service>');
const svc = lib.inventory.service(inv, id);
const svc = lib.inventory.service(inv, id);
@@ -127,4 +127,105 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
@@ -127,4 +127,105 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
return { changed, reloaded: true };
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name {{names}};
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { {{fallback}} }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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}}
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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;
+ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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}}
+}
@@ -17,7 +17,7 @@ runTests([
@@ -17,7 +17,7 @@ runTests([
test('host.example.json is a valid inventory for every service on the host', () => {
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 raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'host.example.json'), 'utf8'));
const inv = normalise(raw);
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.strictEqual(inv.services.live.socketUnit, 'openvibe-live.socket');
assert.ok(!inv.services.live.units.includes('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');
assert.strictEqual(inv.services.live.protected.url, 'http://127.0.0.1:3000/api/streams');
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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/);
+ }),
+]);