openvibers/openvibe.host Commits
ovhost drops Stage B's tenant vhosts and custom-domain certificates; openvibe.host is a repo vhost (#144)
Changes
@@@ -195,7 +195,6 @@ ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--ovhost releases <service> [--limit <n>]ovhost certs [--warn-days <n>]ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]--ovhost nginx tenants <service> [--wildcard-cert <name|dir>] [--install] tenant vhosts for a service whose inventory has `nginx.tenants`ovhost snapshot <service> [--out <file>]ovhost backup <service>ovhost backup --all [--offsite] [--logical] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]ddeleted file mode 100644@@@ -1,20 +0,0 @@--# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard--# certificate instead of falling through to the default server (which served another site).--# `ovhost nginx tenants host --install` removes it in the same nginx -t and reload that installs the--# real tenant vhost (nginx.tenants.replaces); see docs/launch.md.--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;-- add_header Cache-Control "no-store" always;-- location / { default_type text/plain; return 404 "No site is published at this address yet.\n"; }--}--server {-- listen 80;-- listen [::]:80;-- server_name *.openvibe.host;-- return 301 https://$host$request_uri;--}@@@ -1,4 +1,5 @@## OpenVibe.Host hosting comparison catalog on openvibe.host.++## *.openvibe.host were Stage B's tenant sites (moved to OpenVibe.Space): the service answers them 410 with a link there.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;@@@ -7,7 +8,7 @@ limit_req_zone $binary_remote_addr zone=ovhost_auth:10m rate=10r/m;server {listen 80;listen [::]:80;-- server_name openvibe.host;++ server_name openvibe.host *.openvibe.host;location /.well-known/acme-challenge/ { root /var/www/certbot; }location / { return 301 https://$host$request_uri; }@@@ -18,7 +19,7 @@ server {listen 443 ssl;listen [::]:443 ssl;http2 on;-- server_name openvibe.host;++ server_name openvibe.host *.openvibe.host;include snippets/security-txt.conf; # /.well-known/security.txt (the host's file)ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;ddeleted file mode 100644@@@ -1,20 +0,0 @@--[Unit]--Description=OpenVibe certificate lifecycle: renew the wildcard lineage and issue certificates for verified custom domains, then re-render the tenant vhosts--Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md--After=network-online.target--Wants=network-online.target----[Service]--Type=oneshot--# Root: certbot writes /etc/letsencrypt and ovhost re-renders and reloads nginx; neither runs as the--# Host service user. Host itself never reads a key file (only stat()s fullchain.pem).--User=root--ExecStart=/usr/local/bin/ovhost certs renew --install--Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin--Environment=OVHOST_INVENTORY=/etc/openvibe/host.json--UMask=0077--# /usr and /boot read-only; /etc stays writable (certbot and the nginx install write there). Not `full`:--# that also makes /etc read-only, and certbot failed on /etc/letsencrypt/.certbot.lock (2026-10-07).--ProtectSystem=true--PrivateTmp=true--NoNewPrivileges=trueddeleted file mode 100644@@@ -1,14 +0,0 @@--[Unit]--Description=Twice-daily OpenVibe certificate renewal (04:30 and 16:30 UTC, up to 15 min later)--Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md----[Timer]--OnCalendar=*-*-* 04,16:30:00 UTC--RandomizedDelaySec=15min--AccuracySec=1min--# A run missed while the host was down happens at the next boot.--Persistent=true--Unit=openvibe-certs.service----[Install]--WantedBy=timers.target@@@ -1,7 +1,7 @@[Unit]--Description=OpenVibe.Host Stage B: tenant static hosting (dashboard, API and *.openvibe.host sites)++Description=OpenVibe.Host: the hosting comparison catalog (openvibe.host)# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Host--# degrades gracefully without them: tenant sites keep serving; sign-in, API calls and events wait++# degrades gracefully without them: the catalog keeps serving; sign-in, API calls and events wait# (see /api/ready).After=network.target postgresql.service pgbouncer.service valkey-server.serviceWants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service@@@ -12,4 +12,4 @@ Every `services.<id>.databases[]` entry names a database in the host PostgreSQL{ "trade": { "url": "DATABASE_URL", "directUrl": "DATABASE_DIRECT_URL" } }```--The drill creates a scratch database and role, restores the archive, and points both settings at the scratch database. `nginx.tenants.database` uses `{ "engine": "postgresql", "database": "ov_host" }`. A `postgresql-count` protected-session probe runs a read-only SELECT through the postgres OS user.++The drill creates a scratch database and role, restores the archive, and points both settings at the scratch database. A `postgresql-count` protected-session probe runs a read-only SELECT through the postgres OS user.@@@ -1,5 +1,10 @@# Launching Stage B at openvibe.host++> **History.** Static hosting moved to OpenVibe.Space on 2026-10-12, and on 2026-10-23 ovhost lost what this runbook++> used: `ovhost nginx tenants`, `ovhost certs renew`, `openvibe-certs.timer` and the inventory's `nginx.tenants` block.++> openvibe.host is now the repo vhost `deploy/nginx/openvibe.host.conf` (the catalog; `*.openvibe.host` answers 410++> with a link to OpenVibe.Space), and certbot's own timer renews the wildcard certificate (DNS-01 via Cloudflare).++**Launched on 2026-10-07.** The window ran in this order: pre-flight (every line agreed); optional step 0, annginx catch-all (`/etc/nginx/sites-available/00-default-catchall.conf`, a 444 on :80 and `ssl_reject_handshake` on:443 for any host no site names); the `ovhost` CLI updated; `ovhost nginx tenants host --install` (openvibe.host.conf@@@ -1,5 +1,8 @@# Threat review: OpenVibe.Host Stage B (tenant static hosting)++> **History.** Stage B moved to OpenVibe.Space on 2026-10-12 and its vhost renderer left ovhost on 2026-10-23++> (see [launch.md](launch.md)); this review is kept as the record of what was checked at launch.++Launch-rule item 6 (README, [Launch rule](../README.md#launch-rule)) and roadmap Track S require athreat review before a service leaves placeholder status. This review covers Stage B as of thiscommit:@@@ -876,6 +876,11 @@]},"port": 4910,++ "nginx": {++ "vhost": "openvibe.host.conf",++ "repoVhost": "deploy/nginx/openvibe.host.conf",++ "variant": "http"++ },"ready": {"url": "http://127.0.0.1:4910/api/ready","timeoutSeconds": 30ddeleted file mode 100644@@@ -1,85 +0,0 @@--'use strict';--/**-- * Certificate lifecycle (operator plane, `ovhost certs renew`).-- *-- * certbot stays outside the Host service: this module shells out to it and never reads a key file.-- * It renews what already exists (`certbot renew` covers the wildcard lineage and any per-domain-- * certificates), issues a certificate for every VERIFIED custom domain that still has none-- * (HTTP-01 webroot, the port-80 block already answers the challenge), and — with --install —-- * re-renders the tenant vhosts through the same transactional nginx install.-- *-- * Domains come straight from the Host database, so the values are validated exactly where nginx-- * renders them (nginx.renderTenants): a hostile hostname is refused and never reaches certbot's-- * argv. A failed issuance leaves the domain HTTP-only and is reported, never fatal to the others.-- */--const fs = require('fs');--const path = require('path');--const nginx = require('./nginx');----const ACME_WEBROOT = '/var/www/certbot';----/** The single service whose inventory declares an nginx.tenants block. */--function tenantService(inv) {-- const ids = Object.values(inv.services).filter((s) => s.nginx && s.nginx.tenants).map((s) => s.id);-- if (!ids.length) throw new Error('no service has an nginx.tenants block (there is nothing to renew for)');-- if (ids.length > 1) throw new Error(`more than one tenant service (${ids.join(', ')}); name one: ovhost certs renew <service>`);-- return inv.services[ids[0]];--}----function firstLine(text) {-- return String(text || '').trim().split('\n')[0] || '';--}----/**-- * -> { renewed, issued: [host], failed: [{ hostname, error }], refused: [value], installed }-- * `installed` is null without --install, else the nginx install result ({ changed, removed, reloaded }).-- */--async function renew(exec, inv, svc, { wildcardCert, install = false, webroot = ACME_WEBROOT, log = () => {} } = {}) {-- nginx.tenantsConfig(svc); // throws early if this service does not host tenants---- // 1. Renew every existing certificate. The wildcard is a DNS-01 lineage the operator issued;-- // its credentials stay operator-side, so certbot renew owns it here too.-- const renewed = await exec.run('certbot', ['renew', '--non-interactive', '--quiet'], { privileged: true });-- if (renewed.code !== 0) log(`certbot renew reported a failure (exit ${renewed.code}): ${firstLine(renewed.stderr || renewed.stdout)}`);---- // 2. Issue for the verified custom domains that still have no certificate. renderTenants is the-- // same validator the vhosts use, so `needCert` holds only plain host names.-- const before = await nginx.tenantDomains(exec, svc);-- const pre = nginx.renderTenants(inv, svc, { wildcardCert, domains: before });-- for (const h of pre.refused) log(`refused a database value that is not a plain host name: ${JSON.stringify(h)}`);-- const issued = [];-- const failed = [];-- for (const host of pre.needCert) {-- const r = await exec.run('certbot', ['certonly', '--webroot', '-w', webroot, '-d', host, '--non-interactive'], { privileged: true });-- if (r.code === 0) { issued.push(host); log(`issued a certificate for ${host}`); }-- else {-- const error = firstLine(r.stderr || r.stdout) || `certbot exited ${r.code}`;-- failed.push({ hostname: host, error });-- log(`could not issue a certificate for ${host}: ${error} — it stays HTTP-only`);-- }-- }---- // 3. Install only when asked: re-read the domains (a freshly issued one now has a certificate)-- // and go through the transactional nginx install.-- let installed = null;-- if (install) {-- const after = await nginx.tenantDomains(exec, svc);-- const r = nginx.renderTenants(inv, svc, { wildcardCert, domains: after });-- const res = await nginx.install(exec, inv, r.files, { log, remove: r.replaces });-- for (const h of r.needCert) log(`still needs a certificate: ${h}`);-- installed = { changed: res.changed, removed: res.removed, reloaded: res.reloaded };-- }---- return { renewed: renewed.code === 0, issued, failed, refused: pre.refused, installed };--}----/** The renewal unit files as rendered (deploy/systemd/openvibe-certs.{service,timer}). */--function unitTexts() {-- const dir = path.join(__dirname, '..', 'deploy', 'systemd');-- return {-- service: fs.readFileSync(path.join(dir, 'openvibe-certs.service'), 'utf8'),-- timer: fs.readFileSync(path.join(dir, 'openvibe-certs.timer'), 'utf8'),-- };--}----module.exports = { renew, tenantService, unitTexts, ACME_WEBROOT };@@@ -65,17 +65,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]/etc/openvibe/host.env (docs/release-notifications.md)releases <service> [--limit <n>] the release logcerts [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry-- certs renew [<service>] [--wildcard-cert <name|dir>] [--install]-- renew existing certificates with certbot and issue one for every-- VERIFIED custom domain that has none; --install then re-renders-- the tenant vhosts (transactional nginx -t + reload)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> [--logical] .backup of the declared databases to a dated directory;--logical also takes a PostgreSQL pg_dump (else weekly)@@@ -136,7 +128,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirtybackup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;--const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);++const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);function parseArgs(argv) {const pos = [];@@@ -438,19 +430,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procreturn 0;}case 'certs': {-- if (pos[1] === 'renew') {-- const svc = pos[2] ? lib.inventory.service(inv, pos[2]) : lib.acme.tenantService(inv);-- const r = await lib.acme.renew(exec, inv, svc, { wildcardCert: opts['wildcard-cert'], install: !!opts.install, log });-- if (json) emit(r);-- else {-- out(`certbot renew: ${r.renewed ? 'ok' : 'reported a failure (existing certificates are untouched)'}`);-- for (const h of r.issued) out(`issued ${h}`);-- for (const h of r.refused) out(`refused (not a plain host name): ${JSON.stringify(h)}`);-- for (const f of r.failed) out(`failed ${f.hostname}: ${f.error} — it stays HTTP-only`);-- if (r.installed) out(`nginx: ${[r.installed.changed.length && `installed ${r.installed.changed.join(', ')}`, r.installed.removed.length && `removed ${r.installed.removed.join(', ')}`].filter(Boolean).join('; ') || 'nothing changed'}${r.installed.reloaded ? '; reloaded' : ''}`);-- }-- return (r.renewed && !r.failed.length) ? 0 : 2;-- }++ if (pos[1]) throw new UsageError('usage: ovhost certs [--warn-days <n>] (certbot.timer renews every certificate)');const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });if (json) emit(list);else {@@@ -462,25 +442,7 @@ 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] === '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), replaces: r.replaces, 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}`);-- if (r.replaces.length) out(`# --install also removes, in the same nginx -t and reload: ${r.replaces.join(', ')} (if present)`);-- } else {-- const res = await lib.nginx.install(exec, inv, r.files, { log, remove: r.replaces });-- if (!res.changed.length && !res.removed.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]');++ if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--install]');const id = pos[2];if (!id) throw new UsageError('nginx render needs a <service>');const svc = lib.inventory.service(inv, id);@@@ -16,7 +16,6 @@ const serviceAdd = require('./commands/service-add');const { drill, DrillError } = require('./commands/drill');const releases = require('./releases');const certs = require('./certs');--const acme = require('./acme');const nginx = require('./nginx');const announce = require('./announce');const { createSystemExecutor } = require('./executor');@@@ -53,7 +52,6 @@ module.exports = {DrillError,releases,certs,-- acme,nginx,announce: announce.announce,announceSummary: announce.summary,@@@ -320,17 +320,9 @@ function normaliseService(id, raw, defaults) {if (n.skipVhosts != null && (!Array.isArray(n.skipVhosts) || n.skipVhosts.some((name) => !FILE_NAME_RE.test(name)))) throw new InventoryError(`${where}.nginx.skipVhosts must list vhost file names`);if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);-- // The tenants database may be a PostgreSQL database (the Host API is on PostgreSQL): the-- // verified custom domains are then read over psql as the postgres OS user, not from a file.-- if (n.tenants && n.tenants.database != null) {-- const db = n.tenants.database;-- let tenantDb;-- if (db && typeof db === 'object' && !Array.isArray(db) && db.engine === 'postgresql' && PG_DATABASE_RE.test(db.database || '')) tenantDb = { engine: 'postgresql', database: db.database };-- else throw new InventoryError(`${where}.nginx.tenants.database must be { "engine": "postgresql", "database": "ov_host" }`);-- // A fresh object every time: normalise() must never rewrite the document it was handed-- // (the example inventory is a shared require() cache, and a drill test normalises it twice).-- out.nginx = { ...n, tenants: { ...n.tenants, database: tenantDb } };-- }++ // Stage B's tenant vhosts went with static hosting (OpenVibe.Space since 2026-10-12): openvibe.host is a++ // repo vhost like every other service's.++ if (n.tenants) throw new InventoryError(`${where}.nginx.tenants is retired (static hosting moved to OpenVibe.Space): declare the vhost with nginx.vhost and nginx.repoVhost`);}if (out.lifecycle !== null && (typeof out.lifecycle !== 'object' || Array.isArray(out.lifecycle))) throw new InventoryError(`${where}.lifecycle must be an object (the manifest's lifecycle block)`);for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);@@@ -9,7 +9,6 @@const fs = require('fs');const path = require('path');const systemd = require('./systemd');--const dbengine = require('./dbengine');const TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'nginx');@@@ -154,128 +153,4 @@ async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {return { changed, removed, reloaded: true };}--// ── 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');-- // The interim vhost that answered *.<sitesDomain> with a 404 before launch (deploy/nginx in this-- // repo). It sorts before <sitesDomain>.conf, so left in place it would keep winning the wildcard.-- const replaces = t.replaces || [`${sitesDomain}-tenants-pending.conf`];-- if (!Array.isArray(replaces) || replaces.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9._-]+\.conf$/.test(n))) throw new Error('nginx.tenants.replaces must list vhost file names');-- 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',-- replaces,-- };--}----/**-- * 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);-- // www.<dashboard> would otherwise be a tenant name (reserved, so "unknown host"): redirect it,-- // as the placeholder did. Only when the wildcard certificate covers it.-- const www = `www.${t.dashboard}`;-- const wwwRedirect = www.endsWith(`.${t.sitesDomain}`) && www.split('.').length === t.sitesDomain.split('.').length + 1-- ? fill(readTemplate('www-redirect.tmpl'), { www, dashboard: t.dashboard, certDir }) : '';-- const main = fill(readTemplate('tenants.conf.tmpl'), {-- ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation, wwwRedirect,-- });---- 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 ? dbengine.label(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,-- replaces: t.replaces,-- };--}----/**-- * Verified custom domains from the Host database + certificate presence. A PostgreSQL database is-- * read as the postgres OS user over peer auth (Host moved to PostgreSQL; its service role's password-- * is not ovhost's to read). A database without the table has no custom domains: OpenVibe.Host's migration-- * 0008 dropped host_domains when static hosting moved to OpenVibe.Space, and from then (2026-10-23) every-- * `ovhost certs renew` stopped at this read, before renewing anything.-- */--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`);-- let rows;-- try {-- rows = await exec.psql(t.database.database, VERIFIED_SQL);-- } catch (err) {-- if (/relation "host_domains" does not exist/.test(String(err && err.message))) return [];-- throw err;-- }-- 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 };++module.exports = { render, test, install, defaultCertName, fill };ddeleted file mode 100644@@@ -1,10 +0,0 @@--## ═══════════════════════════════════════════════════════════════--## 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}}ddeleted file mode 100644@@@ -1,9 +0,0 @@----server {-- listen 80;-- listen [::]:80;-- server_name {{names}};---- location /.well-known/acme-challenge/ { root /var/www/certbot; }-- location / { {{fallback}} }--}ddeleted file mode 100644@@@ -1,15 +0,0 @@----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}}--}ddeleted file mode 100644@@@ -1,21 +0,0 @@---- # 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 $remote_addr;-- proxy_set_header CF-Connecting-IP $remote_addr;-- proxy_set_header X-Forwarded-Proto $scheme;-- proxy_hide_header Set-Cookie;-- proxy_read_timeout 60s;-- }ddeleted file mode 100644@@@ -1,102 +0,0 @@--## ═══════════════════════════════════════════════════════════════--## {{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}};-- include snippets/security-txt.conf; # /.well-known/security.txt (the host's file; WS-R task 7)---- 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 $remote_addr;-- proxy_set_header CF-Connecting-IP $remote_addr;-- proxy_set_header X-Forwarded-Proto $scheme;---- # GET /auth/me runs on every page load (the navbar's session): the API zone, not the sign-in flow's 10/min,-- # or a visitor who opens a dozen pages in a minute shows as signed out (the browser check's 429s, 2026-10-17).-- location = /auth/me {-- limit_req zone={{zone}}_api burst=30 nodelay;-- proxy_pass http://127.0.0.1:{{port}};-- }---- 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}};-- }--}----{{wwwRedirect}}# ── 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}}--}ddeleted file mode 100644@@@ -1,15 +0,0 @@--# ── {{www}} → {{dashboard}} ─────────────────────────────--server {-- listen 443 ssl;-- listen [::]:443 ssl;-- http2 on;-- server_name {{www}};---- ssl_certificate {{certDir}}/fullchain.pem;-- ssl_certificate_key {{certDir}}/privkey.pem;-- ssl_protocols TLSv1.2 TLSv1.3;---- add_header Strict-Transport-Security "max-age=31536000" always;-- return 301 https://{{dashboard}}$request_uri;--}--ddeleted file mode 100644@@@ -1,131 +0,0 @@--'use strict';--/**-- * `ovhost certs renew [--install]`: renew the existing certificates with certbot, issue one for every-- * VERIFIED custom domain that has none, then (with --install) re-render the tenant vhosts through the-- * transactional nginx install. A hostile database value never reaches certbot's argv, a failed-- * issuance stays HTTP-only, and no key file is ever read — nothing real is run (test/fake-host.js).-- */--const assert = require('assert');--const { scenario, test, runTests } = require('./helpers');--const { LONG } = require('./fixtures/certs');--const acme = require('../lib/acme');----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', engine: 'postgresql', database: 'ov_host' }],-- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },-- };-- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });-- host.psqlHandler = (db, sql) => {-- assert.strictEqual(db, 'ov_host');-- assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);-- return rows;-- };-- return host;--}----/** certbot that issues for real (writes the certificate) but runs nothing. */--function issuer(host) {-- host.certbot = (args) => {-- if (args[0] === 'renew') return { code: 0, stdout: 'Certificate not yet due for renewal.' };-- if (args[0] === 'certonly') {-- const h = args[args.indexOf('-d') + 1];-- host.put(`/etc/letsencrypt/live/${h}/fullchain.pem`, LONG, { mode: 0o644 });-- return { code: 0, stdout: `Successfully received certificate for ${h}.` };-- }-- return { code: 1, stderr: `unknown certbot subcommand ${args[0]}` };-- };-- return host;--}----runTests([-- test('a verified domain with no certificate is issued and then installed over HTTPS', async () => {-- const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));-- assert.strictEqual(host.read('/etc/letsencrypt/live/docs.tenant-b.net/fullchain.pem'), null, 'no certificate yet');-- const r = await host.cli('certs', 'renew', 'host', '--install');-- assert.strictEqual(r.code, 0, r.out);-- assert.ok(host.certbotCalls.some((c) => c.args[0] === 'renew'), 'the wildcard lineage is renewed first');-- const call = host.certbotCalls.find((c) => c.args[0] === 'certonly');-- assert.ok(call, 'certbot certonly ran');-- assert.deepStrictEqual(call.args.slice(0, 4), ['certonly', '--webroot', '-w', '/var/www/certbot']);-- assert.strictEqual(call.args[call.args.indexOf('-d') + 1], 'docs.tenant-b.net');-- assert.ok(call.privileged, 'certbot runs as root');-- const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');-- assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/);-- assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'installed and enabled');-- assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'nginx reloaded');-- assert.ok(!host.reads.some((f) => /privkey|\.key$/.test(f)), 'no key file was read');-- assert.ok(!host.certbotCalls.some((c) => c.args.some((a) => /privkey|\.key$/.test(String(a)))), 'no command touched a key path');-- }),---- test('a database without host_domains (Host 0008) has no custom domains: the renewal still runs and succeeds', async () => {-- const host = issuer(withHost(scenario(), []));-- host.psqlHandler = () => { throw new Error('psql ov_host failed: ERROR: relation "host_domains" does not exist'); };-- const r = await host.cli('certs', 'renew', 'host', '--install');-- assert.strictEqual(r.code, 0, r.out);-- assert.ok(host.certbotCalls.some((c) => c.args[0] === 'renew'), 'the wildcard lineage is renewed');-- assert.ok(!host.certbotCalls.some((c) => c.args[0] === 'certonly'), 'nothing to issue');-- // Any other database failure still fails the command.-- host.psqlHandler = () => { throw new Error('psql ov_host failed: connection refused'); };-- assert.notStrictEqual((await host.cli('certs', 'renew', 'host', '--install')).code, 0);-- }),---- test('issuance failure keeps the domain HTTP-only and fails the command', async () => {-- const host = withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]);-- host.certbot = (args) => (args[0] === 'certonly' ? { code: 1, stderr: 'Certbot failed to authenticate some domains: Challenge failed' } : { code: 0, stdout: '' });-- const r = await host.cli('certs', 'renew', 'host', '--install');-- assert.strictEqual(r.code, 2, r.out);-- assert.match(r.out, /failed docs\.tenant-b\.net: .*Challenge failed .* it stays HTTP-only/);-- const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');-- assert.ok(!/ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/.test(custom), 'no HTTPS block without a certificate');-- assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?location \/ \{ return 404; \}/, 'ACME only until it succeeds');-- assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'the vhosts were still installed');-- }),---- test('hostile database values never reach certbot argv', async () => {-- const host = issuer(withHost(scenario(), [-- { hostname: 'ok.example.org' },-- { hostname: 'evil.org; } server { listen 80; root /; }' },-- { hostname: 'x.openvibe.host' },-- { hostname: '../../etc/letsencrypt' },-- { hostname: 'with space.org' },-- ]));-- const r = await host.cli('certs', 'renew', 'host');-- assert.strictEqual(r.code, 0, r.out);-- const certonly = host.certbotCalls.filter((c) => c.args[0] === 'certonly');-- assert.deepStrictEqual(certonly.map((c) => c.args[c.args.indexOf('-d') + 1]), ['ok.example.org'], 'only the plain host name was issued');-- assert.match(r.out, /refused \(not a plain host name\)/);-- assert.ok(certonly.every((c) => c.args.every((a) => !/evil|x\.openvibe\.host|\.\.\/|with space/.test(a))));-- }),---- test('without --install it writes no vhost and reports the plan; --json is the same shape', async () => {-- const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));-- const r = await host.cli('certs', 'renew', '--json', 'host');-- assert.strictEqual(r.code, 0, r.out);-- const o = JSON.parse(r.out);-- assert.strictEqual(o.renewed, true);-- assert.deepStrictEqual(o.issued, ['docs.tenant-b.net']);-- assert.deepStrictEqual(o.failed, []);-- assert.strictEqual(o.installed, null);-- assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null, 'render alone writes nothing');-- assert.ok(!host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'));-- }),---- test('the renewal unit is rendered: a root oneshot that renews and installs, on a persistent timer', async () => {-- const { service, timer } = acme.unitTexts();-- assert.match(service, /^Type=oneshot$/m);-- assert.match(service, /^User=root$/m);-- assert.match(service, /^ExecStart=\/usr\/local\/bin\/ovhost certs renew --install$/m);-- assert.match(service, /^Environment=OVHOST_INVENTORY=\/etc\/openvibe\/host\.json$/m);-- assert.match(timer, /^OnCalendar=.*UTC$/m);-- assert.match(timer, /^Persistent=true$/m);-- assert.match(timer, /^Unit=openvibe-certs\.service$/m);-- assert.match(timer, /^WantedBy=timers\.target$/m);-- assert.ok(!service.includes('{{') && !timer.includes('{{'));-- }),--]);ddeleted file mode 100644@@@ -1,185 +0,0 @@--'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');--const { normalise } = require('../lib/inventory');--const nginx = require('../lib/nginx');----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', engine: 'postgresql', database: 'ov_host' }],-- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },-- };-- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });-- host.psqlHandler = (db, sql) => {-- assert.strictEqual(db, 'ov_host');-- assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);-- 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, /location = \/auth\/me \{\n +limit_req zone=ovhost_api burst=30 nodelay;/, 'the session read on every page load is not held to the sign-in limit');-- assert.match(r.out, /location \/auth\/ \{\n +limit_req zone=ovhost_auth burst=10 nodelay;/);-- 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('a PostgreSQL tenant database reads the verified domains over psql, as PostgreSQL', async () => {-- const host = scenario();-- const doc = JSON.parse(host.read('/etc/openvibe/host.json'));-- // Host moved to PostgreSQL (ADR-035): its tenants database is a database in the cluster, not-- // a file, and its service role's password is not ovhost's to read — so psql as postgres.-- 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', engine: 'postgresql', database: 'ov_host' }],-- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },-- };-- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });-- host.psqlHandler = (database) => {-- assert.strictEqual(database, 'ov_host', 'the Host database in the cluster');-- return [{ hostname: 'www.tenant-a.org' }, { hostname: 'docs.tenant-b.net' }];-- };-- host.put('/etc/letsencrypt/live/www.tenant-a.org/fullchain.pem', 'CERT', { mode: 0o644 });-- const domains = await nginx.tenantDomains(host.exec, normalise(doc).services.host);-- assert.deepStrictEqual(domains, [{ hostname: 'www.tenant-a.org', hasCert: true }, { hostname: 'docs.tenant-b.net', hasCert: false }], 'the same [{ hostname, hasCert }] shape');-- assert.ok(host.psqlCalls.some((c) => c.database === 'ov_host' && c.sql === nginx.VERIFIED_SQL), 'the verified domains come from psql');-- const r = await host.cli('nginx', 'tenants', 'host');-- assert.strictEqual(r.code, 0, r.out);-- assert.match(r.out, /server_name www\.tenant-a\.org;/);-- assert.match(r.out, /server_name docs\.tenant-b\.net;/);-- }),---- 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('launch: --install removes the interim tenants-pending vhost in the same nginx -t and reload; a failed test brings it back', async () => {-- const PENDING = '# *.openvibe.host before Host Stage B tenant hosting launches\nserver { server_name *.openvibe.host; return 404; }\n';-- const SITES = '# openvibe.host — static front page (OpenVibe.Sites)\nserver { server_name openvibe.host; root /opt/openvibe.sites/dist/openvibe.host; }\n';-- const prepare = (host) => {-- host.put('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', PENDING);-- host.files.set('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', owner: 'root', mode: 0o777 });-- host.put('/etc/nginx/sites-available/openvibe.host.conf', SITES);-- host.files.set('/etc/nginx/sites-enabled/openvibe.host.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host.conf', owner: 'root', mode: 0o777 });-- return host;-- };-- const review = await prepare(withHost(scenario(), [])).cli('nginx', 'tenants', 'host');-- assert.match(review.out, /--install also removes, in the same nginx -t and reload: openvibe\.host-tenants-pending\.conf/);---- const host = prepare(withHost(scenario(), []));-- let seenAtTest = null;-- host.nginxTest = () => {-- seenAtTest = { pending: host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'), main: host.read('/etc/nginx/sites-available/openvibe.host.conf') };-- return { code: 0, stderr: 'ok' };-- };-- const r = await host.cli('nginx', 'tenants', 'host', '--install');-- assert.strictEqual(r.code, 0, r.out);-- assert.deepStrictEqual([seenAtTest.pending, /server_name \*\.openvibe\.host;/.test(seenAtTest.main)], [false, true], 'one nginx -t sees the tenant vhost and no pending vhost');-- assert.ok(!host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'));-- assert.ok(!host.files.has('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'));-- assert.match(r.out, /removed openvibe\.host-tenants-pending\.conf/);-- assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, 1, 'one reload');-- const again = await host.cli('nginx', 'tenants', 'host', '--install');-- assert.match(again.out, /already installed; nothing changed/);---- const bad = prepare(withHost(scenario(), []));-- bad.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });-- const f = await bad.cli('nginx', 'tenants', 'host', '--install');-- assert.strictEqual(f.code, 2);-- assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'), PENDING, 'pending vhost restored');-- assert.strictEqual(bad.files.get('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf').target, '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf');-- assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host.conf'), SITES, 'the Sites placeholder vhost restored');-- assert.ok(!bad.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'not reloaded');-- }),---- test('client address headers come from $remote_addr only (realip), in every template ovhost renders', async () => {-- const fs = require('fs');-- const path = require('path');-- const dir = path.join(__dirname, '..', 'templates', 'nginx');-- for (const f of fs.readdirSync(dir)) {-- const t = fs.readFileSync(path.join(dir, f), 'utf8');-- assert.ok(!/\$proxy_add_x_forwarded_for|\$http_x_forwarded_for|\$http_cf_connecting_ip/.test(t), `${f} passes a client-supplied address on`);-- if (/X-Forwarded-For/.test(t)) assert.match(t, /X-Forwarded-For \$remote_addr;/, f);-- }-- const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');-- assert.ok(!/proxy_add_x_forwarded_for/.test(r.out));-- assert.match(r.out, /proxy_set_header CF-Connecting-IP \$remote_addr;/);-- }),---- test('www.openvibe.host redirects to the dashboard over the wildcard certificate', async () => {-- const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');-- assert.match(r.out, /server_name www\.openvibe\.host;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;[\s\S]*?return 301 https:\/\/openvibe\.host\$request_uri;/);-- }),---- 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/);-- }),--]);@@@ -2,9 +2,8 @@/*** The PostgreSQL inventory: a `databases[]` entry is* PostgreSQL (a database in the cluster, named — its object carries NO `path`). The same engine-- * choice reaches the drill block ({ url, directUrl }), nginx tenants (read over psql, not a file)-- * and the protected `postgresql-count` probe. These are the normalised shapes the rest of ovhost-- * depends on, and every code path outside the engine's own code is written to assume no `.path`.++ * choice reaches the drill block ({ url, directUrl }) and the protected `postgresql-count` probe. These are++ * the normalised shapes the rest of ovhost depends on, and every code path outside the engine's own code is written to assume no `.path`.*/const assert = require('assert');const { test, runTests } = require('./helpers');@@@ -86,16 +85,11 @@ runTests([assert.throws(() => normalise(pgDrill({ countsTolerance: 1.5 })), /countsTolerance must be a whole number of rows/);}),-- test('nginx.tenants.database accepts a PostgreSQL database object', () => {-- const tenants = (database) => normalise({ services: { host: { ...base, nginx: { tenants: { database } } } } }).services.host.nginx.tenants.database;-- assert.deepStrictEqual(tenants({ engine: 'postgresql', database: 'ov_host' }), { engine: 'postgresql', database: 'ov_host' });-- }),---- test('nginx.tenants.database rejects anything else', () => {-- const tenants = (database) => normalise({ services: { host: { ...base, nginx: { tenants: { database } } } } });-- for (const database of [{ engine: 'mysql', database: 'ov_host' }, { engine: 'postgresql', database: 'host' }, { database: 'ov_host' }, 42, []]) {-- assert.throws(() => tenants(database), /nginx\.tenants\.database must be/, JSON.stringify(database));-- }++ test('a nginx.tenants block is refused: Stage B moved to OpenVibe.Space, openvibe.host is a repo vhost', () => {++ const tenants = { database: { engine: 'postgresql', database: 'ov_host' } };++ assert.throws(() => normalise({ services: { host: { ...base, nginx: { tenants } } } }), /nginx\.tenants is retired/);++ const nginx = normalise({ services: { host: { ...base, nginx: { vhost: 'openvibe.host.conf', repoVhost: 'deploy/nginx/openvibe.host.conf', variant: 'http' } } } }).services.host.nginx;++ assert.strictEqual(nginx.repoVhost, 'deploy/nginx/openvibe.host.conf');}),test('the postgresql-count probe validates its database and SQL, alone and inside a sum', () => {
@@ -195,7 +195,6 @@ ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--
@@ -195,7 +195,6 @@ ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--
ovhost releases <service> [--limit <n>]
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
ovhost certs [--warn-days <n>]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
-ovhost nginx tenants <service> [--wildcard-cert <name|dir>] [--install] tenant vhosts for a service whose inventory has `nginx.tenants`
ovhost snapshot <service> [--out <file>]
ovhost snapshot <service> [--out <file>]
ovhost backup <service>
ovhost backup <service>
ovhost backup --all [--offsite] [--logical] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]
ovhost backup --all [--offsite] [--logical] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]
deleted file mode 100644
deleted file mode 100644
@@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
-# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard
-# certificate instead of falling through to the default server (which served another site).
-# `ovhost nginx tenants host --install` removes it in the same nginx -t and reload that installs the
-# real tenant vhost (nginx.tenants.replaces); see docs/launch.md.
-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;
- add_header Cache-Control "no-store" always;
- location / { default_type text/plain; return 404 "No site is published at this address yet.\n"; }
-}
-server {
- listen 80;
- listen [::]:80;
- server_name *.openvibe.host;
- return 301 https://$host$request_uri;
-}
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
## OpenVibe.Host hosting comparison catalog on openvibe.host.
## OpenVibe.Host hosting comparison catalog on openvibe.host.
+## *.openvibe.host were Stage B's tenant sites (moved to OpenVibe.Space): the service answers them 410 with a link there.
limit_req_zone $binary_remote_addr zone=ovhost_api:10m rate=10r/s;
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_auth:10m rate=10r/m;
@@ -7,7 +8,7 @@ limit_req_zone $binary_remote_addr zone=ovhost_auth:10m rate=10r/m;
@@ -7,7 +8,7 @@ limit_req_zone $binary_remote_addr zone=ovhost_auth:10m rate=10r/m;
server {
server {
listen 80;
listen 80;
listen [::]:80;
listen [::]:80;
- server_name openvibe.host;
+ server_name openvibe.host *.openvibe.host;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
location / { return 301 https://$host$request_uri; }
@@ -18,7 +19,7 @@ server {
@@ -18,7 +19,7 @@ server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
http2 on;
- server_name openvibe.host;
+ server_name openvibe.host *.openvibe.host;
include snippets/security-txt.conf; # /.well-known/security.txt (the host's file)
include snippets/security-txt.conf; # /.well-known/security.txt (the host's file)
ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
deleted file mode 100644
deleted file mode 100644
@@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
-[Unit]
-Description=OpenVibe certificate lifecycle: renew the wildcard lineage and issue certificates for verified custom domains, then re-render the tenant vhosts
-Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=oneshot
-# Root: certbot writes /etc/letsencrypt and ovhost re-renders and reloads nginx; neither runs as the
-# Host service user. Host itself never reads a key file (only stat()s fullchain.pem).
-User=root
-ExecStart=/usr/local/bin/ovhost certs renew --install
-Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
-Environment=OVHOST_INVENTORY=/etc/openvibe/host.json
-UMask=0077
-# /usr and /boot read-only; /etc stays writable (certbot and the nginx install write there). Not `full`:
-# that also makes /etc read-only, and certbot failed on /etc/letsencrypt/.certbot.lock (2026-10-07).
-ProtectSystem=true
-PrivateTmp=true
-NoNewPrivileges=true
deleted file mode 100644
deleted file mode 100644
@@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
-[Unit]
-Description=Twice-daily OpenVibe certificate renewal (04:30 and 16:30 UTC, up to 15 min later)
-Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md
-
-[Timer]
-OnCalendar=*-*-* 04,16:30:00 UTC
-RandomizedDelaySec=15min
-AccuracySec=1min
-# A run missed while the host was down happens at the next boot.
-Persistent=true
-Unit=openvibe-certs.service
-
-[Install]
-WantedBy=timers.target
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
[Unit]
[Unit]
-Description=OpenVibe.Host Stage B: tenant static hosting (dashboard, API and *.openvibe.host sites)
+Description=OpenVibe.Host: the hosting comparison catalog (openvibe.host)
# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Host
# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Host
-# degrades gracefully without them: tenant sites keep serving; sign-in, API calls and events wait
+# degrades gracefully without them: the catalog keeps serving; sign-in, API calls and events wait
# (see /api/ready).
# (see /api/ready).
After=network.target postgresql.service pgbouncer.service valkey-server.service
After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
@@ -12,4 +12,4 @@ Every `services.<id>.databases[]` entry names a database in the host PostgreSQL
@@ -12,4 +12,4 @@ Every `services.<id>.databases[]` entry names a database in the host PostgreSQL
{ "trade": { "url": "DATABASE_URL", "directUrl": "DATABASE_DIRECT_URL" } }
{ "trade": { "url": "DATABASE_URL", "directUrl": "DATABASE_DIRECT_URL" } }
```
```
-The drill creates a scratch database and role, restores the archive, and points both settings at the scratch database. `nginx.tenants.database` uses `{ "engine": "postgresql", "database": "ov_host" }`. A `postgresql-count` protected-session probe runs a read-only SELECT through the postgres OS user.
+The drill creates a scratch database and role, restores the archive, and points both settings at the scratch database. A `postgresql-count` protected-session probe runs a read-only SELECT through the postgres OS user.
@@ -1,5 +1,10 @@
@@ -1,5 +1,10 @@
# Launching Stage B at openvibe.host
# Launching Stage B at openvibe.host
+> **History.** Static hosting moved to OpenVibe.Space on 2026-10-12, and on 2026-10-23 ovhost lost what this runbook
+> used: `ovhost nginx tenants`, `ovhost certs renew`, `openvibe-certs.timer` and the inventory's `nginx.tenants` block.
+> openvibe.host is now the repo vhost `deploy/nginx/openvibe.host.conf` (the catalog; `*.openvibe.host` answers 410
+> with a link to OpenVibe.Space), and certbot's own timer renews the wildcard certificate (DNS-01 via Cloudflare).
+
**Launched on 2026-10-07.** The window ran in this order: pre-flight (every line agreed); optional step 0, an
**Launched on 2026-10-07.** The window ran in this order: pre-flight (every line agreed); optional step 0, an
nginx catch-all (`/etc/nginx/sites-available/00-default-catchall.conf`, a 444 on :80 and `ssl_reject_handshake` on
nginx catch-all (`/etc/nginx/sites-available/00-default-catchall.conf`, a 444 on :80 and `ssl_reject_handshake` on
:443 for any host no site names); the `ovhost` CLI updated; `ovhost nginx tenants host --install` (openvibe.host.conf
:443 for any host no site names); the `ovhost` CLI updated; `ovhost nginx tenants host --install` (openvibe.host.conf
@@ -1,5 +1,8 @@
@@ -1,5 +1,8 @@
# Threat review: OpenVibe.Host Stage B (tenant static hosting)
# Threat review: OpenVibe.Host Stage B (tenant static hosting)
+> **History.** Stage B moved to OpenVibe.Space on 2026-10-12 and its vhost renderer left ovhost on 2026-10-23
+> (see [launch.md](launch.md)); this review is kept as the record of what was checked at launch.
+
Launch-rule item 6 (README, [Launch rule](../README.md#launch-rule)) and roadmap Track S require a
Launch-rule item 6 (README, [Launch rule](../README.md#launch-rule)) and roadmap Track S require a
threat review before a service leaves placeholder status. This review covers Stage B as of this
threat review before a service leaves placeholder status. This review covers Stage B as of this
commit:
commit:
@@ -876,6 +876,11 @@
@@ -876,6 +876,11 @@
]
]
},
},
"port": 4910,
"port": 4910,
+ "nginx": {
+ "vhost": "openvibe.host.conf",
+ "repoVhost": "deploy/nginx/openvibe.host.conf",
+ "variant": "http"
+ },
"ready": {
"ready": {
"url": "http://127.0.0.1:4910/api/ready",
"url": "http://127.0.0.1:4910/api/ready",
"timeoutSeconds": 30
"timeoutSeconds": 30
deleted file mode 100644
deleted file mode 100644
@@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
-'use strict';
-/**
- * Certificate lifecycle (operator plane, `ovhost certs renew`).
- *
- * certbot stays outside the Host service: this module shells out to it and never reads a key file.
- * It renews what already exists (`certbot renew` covers the wildcard lineage and any per-domain
- * certificates), issues a certificate for every VERIFIED custom domain that still has none
- * (HTTP-01 webroot, the port-80 block already answers the challenge), and — with --install —
- * re-renders the tenant vhosts through the same transactional nginx install.
- *
- * Domains come straight from the Host database, so the values are validated exactly where nginx
- * renders them (nginx.renderTenants): a hostile hostname is refused and never reaches certbot's
- * argv. A failed issuance leaves the domain HTTP-only and is reported, never fatal to the others.
- */
-const fs = require('fs');
-const path = require('path');
-const nginx = require('./nginx');
-
-const ACME_WEBROOT = '/var/www/certbot';
-
-/** The single service whose inventory declares an nginx.tenants block. */
-function tenantService(inv) {
- const ids = Object.values(inv.services).filter((s) => s.nginx && s.nginx.tenants).map((s) => s.id);
- if (!ids.length) throw new Error('no service has an nginx.tenants block (there is nothing to renew for)');
- if (ids.length > 1) throw new Error(`more than one tenant service (${ids.join(', ')}); name one: ovhost certs renew <service>`);
- return inv.services[ids[0]];
-}
-
-function firstLine(text) {
- return String(text || '').trim().split('\n')[0] || '';
-}
-
-/**
- * -> { renewed, issued: [host], failed: [{ hostname, error }], refused: [value], installed }
- * `installed` is null without --install, else the nginx install result ({ changed, removed, reloaded }).
- */
-async function renew(exec, inv, svc, { wildcardCert, install = false, webroot = ACME_WEBROOT, log = () => {} } = {}) {
- nginx.tenantsConfig(svc); // throws early if this service does not host tenants
-
- // 1. Renew every existing certificate. The wildcard is a DNS-01 lineage the operator issued;
- // its credentials stay operator-side, so certbot renew owns it here too.
- const renewed = await exec.run('certbot', ['renew', '--non-interactive', '--quiet'], { privileged: true });
- if (renewed.code !== 0) log(`certbot renew reported a failure (exit ${renewed.code}): ${firstLine(renewed.stderr || renewed.stdout)}`);
-
- // 2. Issue for the verified custom domains that still have no certificate. renderTenants is the
- // same validator the vhosts use, so `needCert` holds only plain host names.
- const before = await nginx.tenantDomains(exec, svc);
- const pre = nginx.renderTenants(inv, svc, { wildcardCert, domains: before });
- for (const h of pre.refused) log(`refused a database value that is not a plain host name: ${JSON.stringify(h)}`);
- const issued = [];
- const failed = [];
- for (const host of pre.needCert) {
- const r = await exec.run('certbot', ['certonly', '--webroot', '-w', webroot, '-d', host, '--non-interactive'], { privileged: true });
- if (r.code === 0) { issued.push(host); log(`issued a certificate for ${host}`); }
- else {
- const error = firstLine(r.stderr || r.stdout) || `certbot exited ${r.code}`;
- failed.push({ hostname: host, error });
- log(`could not issue a certificate for ${host}: ${error} — it stays HTTP-only`);
- }
- }
-
- // 3. Install only when asked: re-read the domains (a freshly issued one now has a certificate)
- // and go through the transactional nginx install.
- let installed = null;
- if (install) {
- const after = await nginx.tenantDomains(exec, svc);
- const r = nginx.renderTenants(inv, svc, { wildcardCert, domains: after });
- const res = await nginx.install(exec, inv, r.files, { log, remove: r.replaces });
- for (const h of r.needCert) log(`still needs a certificate: ${h}`);
- installed = { changed: res.changed, removed: res.removed, reloaded: res.reloaded };
- }
-
- return { renewed: renewed.code === 0, issued, failed, refused: pre.refused, installed };
-}
-
-/** The renewal unit files as rendered (deploy/systemd/openvibe-certs.{service,timer}). */
-function unitTexts() {
- const dir = path.join(__dirname, '..', 'deploy', 'systemd');
- return {
- service: fs.readFileSync(path.join(dir, 'openvibe-certs.service'), 'utf8'),
- timer: fs.readFileSync(path.join(dir, 'openvibe-certs.timer'), 'utf8'),
- };
-}
-
-module.exports = { renew, tenantService, unitTexts, ACME_WEBROOT };
@@ -65,17 +65,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -65,17 +65,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
/etc/openvibe/host.env (docs/release-notifications.md)
/etc/openvibe/host.env (docs/release-notifications.md)
releases <service> [--limit <n>] the release log
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
- certs renew [<service>] [--wildcard-cert <name|dir>] [--install]
- renew existing certificates with certbot and issue one for every
- VERIFIED custom domain that has none; --install then re-renders
- the tenant vhosts (transactional nginx -t + reload)
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> [--logical] .backup of the declared databases to a dated directory;
backup <service> [--logical] .backup of the declared databases to a dated directory;
--logical also takes a PostgreSQL pg_dump (else weekly)
--logical also takes a PostgreSQL pg_dump (else weekly)
@@ -136,7 +128,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
@@ -136,7 +128,7 @@ self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
`;
-const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
+const FLAGS_WITH_VALUE = new Set(['prometheus', 'sites', 'token-file', 'ttl', 'title', 'services', 'severity', 'message', 'state', 'from', 'until', 'reason', 'incident', 'inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note', 'tail']);
function parseArgs(argv) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -438,19 +430,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -438,19 +430,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 0;
return 0;
}
}
case 'certs': {
case 'certs': {
- if (pos[1] === 'renew') {
- const svc = pos[2] ? lib.inventory.service(inv, pos[2]) : lib.acme.tenantService(inv);
- const r = await lib.acme.renew(exec, inv, svc, { wildcardCert: opts['wildcard-cert'], install: !!opts.install, log });
- if (json) emit(r);
- else {
- out(`certbot renew: ${r.renewed ? 'ok' : 'reported a failure (existing certificates are untouched)'}`);
- for (const h of r.issued) out(`issued ${h}`);
- for (const h of r.refused) out(`refused (not a plain host name): ${JSON.stringify(h)}`);
- for (const f of r.failed) out(`failed ${f.hostname}: ${f.error} — it stays HTTP-only`);
- if (r.installed) out(`nginx: ${[r.installed.changed.length && `installed ${r.installed.changed.join(', ')}`, r.installed.removed.length && `removed ${r.installed.removed.join(', ')}`].filter(Boolean).join('; ') || 'nothing changed'}${r.installed.reloaded ? '; reloaded' : ''}`);
- }
- return (r.renewed && !r.failed.length) ? 0 : 2;
- }
+ if (pos[1]) throw new UsageError('usage: ovhost certs [--warn-days <n>] (certbot.timer renews every certificate)');
const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });
const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });
if (json) emit(list);
if (json) emit(list);
else {
else {
@@ -462,25 +442,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -462,25 +442,7 @@ 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] === '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), replaces: r.replaces, 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}`);
- if (r.replaces.length) out(`# --install also removes, in the same nginx -t and reload: ${r.replaces.join(', ')} (if present)`);
- } else {
- const res = await lib.nginx.install(exec, inv, r.files, { log, remove: r.replaces });
- if (!res.changed.length && !res.removed.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]');
+ if (pos[1] !== 'render') throw new UsageError('usage: ovhost nginx render <service> [--variant …] [--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);
@@ -16,7 +16,6 @@ const serviceAdd = require('./commands/service-add');
@@ -16,7 +16,6 @@ const serviceAdd = require('./commands/service-add');
const { drill, DrillError } = require('./commands/drill');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const releases = require('./releases');
const certs = require('./certs');
const certs = require('./certs');
-const acme = require('./acme');
const nginx = require('./nginx');
const nginx = require('./nginx');
const announce = require('./announce');
const announce = require('./announce');
const { createSystemExecutor } = require('./executor');
const { createSystemExecutor } = require('./executor');
@@ -53,7 +52,6 @@ module.exports = {
@@ -53,7 +52,6 @@ module.exports = {
DrillError,
DrillError,
releases,
releases,
certs,
certs,
- acme,
nginx,
nginx,
announce: announce.announce,
announce: announce.announce,
announceSummary: announce.summary,
announceSummary: announce.summary,
@@ -320,17 +320,9 @@ function normaliseService(id, raw, defaults) {
@@ -320,17 +320,9 @@ function normaliseService(id, raw, defaults) {
if (n.skipVhosts != null && (!Array.isArray(n.skipVhosts) || n.skipVhosts.some((name) => !FILE_NAME_RE.test(name)))) throw new InventoryError(`${where}.nginx.skipVhosts must list vhost file names`);
if (n.skipVhosts != null && (!Array.isArray(n.skipVhosts) || n.skipVhosts.some((name) => !FILE_NAME_RE.test(name)))) throw new InventoryError(`${where}.nginx.skipVhosts must list vhost file names`);
if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);
if (n.variant && !['http', 'sse', 'websocket'].includes(n.variant)) throw new InventoryError(`${where}.nginx.variant must be http, sse or websocket`);
if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);
if (n.certName && !FILE_NAME_RE.test(n.certName)) throw new InventoryError(`${where}.nginx.certName must be a name`);
- // The tenants database may be a PostgreSQL database (the Host API is on PostgreSQL): the
- // verified custom domains are then read over psql as the postgres OS user, not from a file.
- if (n.tenants && n.tenants.database != null) {
- const db = n.tenants.database;
- let tenantDb;
- if (db && typeof db === 'object' && !Array.isArray(db) && db.engine === 'postgresql' && PG_DATABASE_RE.test(db.database || '')) tenantDb = { engine: 'postgresql', database: db.database };
- else throw new InventoryError(`${where}.nginx.tenants.database must be { "engine": "postgresql", "database": "ov_host" }`);
- // A fresh object every time: normalise() must never rewrite the document it was handed
- // (the example inventory is a shared require() cache, and a drill test normalises it twice).
- out.nginx = { ...n, tenants: { ...n.tenants, database: tenantDb } };
- }
+ // Stage B's tenant vhosts went with static hosting (OpenVibe.Space since 2026-10-12): openvibe.host is a
+ // repo vhost like every other service's.
+ if (n.tenants) throw new InventoryError(`${where}.nginx.tenants is retired (static hosting moved to OpenVibe.Space): declare the vhost with nginx.vhost and nginx.repoVhost`);
}
}
if (out.lifecycle !== null && (typeof out.lifecycle !== 'object' || Array.isArray(out.lifecycle))) throw new InventoryError(`${where}.lifecycle must be an object (the manifest's lifecycle block)`);
if (out.lifecycle !== null && (typeof out.lifecycle !== 'object' || Array.isArray(out.lifecycle))) throw new InventoryError(`${where}.lifecycle must be an object (the manifest's lifecycle block)`);
for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);
for (const p of out.backupOnChange) rel(p, `${where}.backupOnChange`);
@@ -9,7 +9,6 @@
@@ -9,7 +9,6 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const systemd = require('./systemd');
const systemd = require('./systemd');
-const dbengine = require('./dbengine');
const TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'nginx');
const TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'nginx');
@@ -154,128 +153,4 @@ async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {
@@ -154,128 +153,4 @@ async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {
return { changed, removed, reloaded: true };
return { changed, removed, reloaded: true };
}
}
-// ── 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');
- // The interim vhost that answered *.<sitesDomain> with a 404 before launch (deploy/nginx in this
- // repo). It sorts before <sitesDomain>.conf, so left in place it would keep winning the wildcard.
- const replaces = t.replaces || [`${sitesDomain}-tenants-pending.conf`];
- if (!Array.isArray(replaces) || replaces.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9._-]+\.conf$/.test(n))) throw new Error('nginx.tenants.replaces must list vhost file names');
- 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',
- replaces,
- };
-}
-
-/**
- * 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);
- // www.<dashboard> would otherwise be a tenant name (reserved, so "unknown host"): redirect it,
- // as the placeholder did. Only when the wildcard certificate covers it.
- const www = `www.${t.dashboard}`;
- const wwwRedirect = www.endsWith(`.${t.sitesDomain}`) && www.split('.').length === t.sitesDomain.split('.').length + 1
- ? fill(readTemplate('www-redirect.tmpl'), { www, dashboard: t.dashboard, certDir }) : '';
- const main = fill(readTemplate('tenants.conf.tmpl'), {
- ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation, wwwRedirect,
- });
-
- 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 ? dbengine.label(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,
- replaces: t.replaces,
- };
-}
-
-/**
- * Verified custom domains from the Host database + certificate presence. A PostgreSQL database is
- * read as the postgres OS user over peer auth (Host moved to PostgreSQL; its service role's password
- * is not ovhost's to read). A database without the table has no custom domains: OpenVibe.Host's migration
- * 0008 dropped host_domains when static hosting moved to OpenVibe.Space, and from then (2026-10-23) every
- * `ovhost certs renew` stopped at this read, before renewing anything.
- */
-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`);
- let rows;
- try {
- rows = await exec.psql(t.database.database, VERIFIED_SQL);
- } catch (err) {
- if (/relation "host_domains" does not exist/.test(String(err && err.message))) return [];
- throw err;
- }
- 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 };
+module.exports = { render, test, install, defaultCertName, fill };
deleted file mode 100644
deleted file mode 100644
@@ -1,10 +0,0 @@
@@ -1,10 +0,0 @@
-## ═══════════════════════════════════════════════════════════════
-## 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}}
deleted file mode 100644
deleted file mode 100644
@@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
-
-server {
- listen 80;
- listen [::]:80;
- server_name {{names}};
-
- location /.well-known/acme-challenge/ { root /var/www/certbot; }
- location / { {{fallback}} }
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,15 +0,0 @@
@@ -1,15 +0,0 @@
-
-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}}
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,21 +0,0 @@
@@ -1,21 +0,0 @@
-
- # 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 $remote_addr;
- proxy_set_header CF-Connecting-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_hide_header Set-Cookie;
- proxy_read_timeout 60s;
- }
deleted file mode 100644
deleted file mode 100644
@@ -1,102 +0,0 @@
@@ -1,102 +0,0 @@
-## ═══════════════════════════════════════════════════════════════
-## {{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}};
- include snippets/security-txt.conf; # /.well-known/security.txt (the host's file; WS-R task 7)
-
- 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 $remote_addr;
- proxy_set_header CF-Connecting-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- # GET /auth/me runs on every page load (the navbar's session): the API zone, not the sign-in flow's 10/min,
- # or a visitor who opens a dozen pages in a minute shows as signed out (the browser check's 429s, 2026-10-17).
- location = /auth/me {
- limit_req zone={{zone}}_api burst=30 nodelay;
- proxy_pass http://127.0.0.1:{{port}};
- }
-
- 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}};
- }
-}
-
-{{wwwRedirect}}# ── 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}}
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,15 +0,0 @@
@@ -1,15 +0,0 @@
-# ── {{www}} → {{dashboard}} ─────────────────────────────
-server {
- listen 443 ssl;
- listen [::]:443 ssl;
- http2 on;
- server_name {{www}};
-
- ssl_certificate {{certDir}}/fullchain.pem;
- ssl_certificate_key {{certDir}}/privkey.pem;
- ssl_protocols TLSv1.2 TLSv1.3;
-
- add_header Strict-Transport-Security "max-age=31536000" always;
- return 301 https://{{dashboard}}$request_uri;
-}
-
deleted file mode 100644
deleted file mode 100644
@@ -1,131 +0,0 @@
@@ -1,131 +0,0 @@
-'use strict';
-/**
- * `ovhost certs renew [--install]`: renew the existing certificates with certbot, issue one for every
- * VERIFIED custom domain that has none, then (with --install) re-render the tenant vhosts through the
- * transactional nginx install. A hostile database value never reaches certbot's argv, a failed
- * issuance stays HTTP-only, and no key file is ever read — nothing real is run (test/fake-host.js).
- */
-const assert = require('assert');
-const { scenario, test, runTests } = require('./helpers');
-const { LONG } = require('./fixtures/certs');
-const acme = require('../lib/acme');
-
-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', engine: 'postgresql', database: 'ov_host' }],
- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },
- };
- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
- host.psqlHandler = (db, sql) => {
- assert.strictEqual(db, 'ov_host');
- assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);
- return rows;
- };
- return host;
-}
-
-/** certbot that issues for real (writes the certificate) but runs nothing. */
-function issuer(host) {
- host.certbot = (args) => {
- if (args[0] === 'renew') return { code: 0, stdout: 'Certificate not yet due for renewal.' };
- if (args[0] === 'certonly') {
- const h = args[args.indexOf('-d') + 1];
- host.put(`/etc/letsencrypt/live/${h}/fullchain.pem`, LONG, { mode: 0o644 });
- return { code: 0, stdout: `Successfully received certificate for ${h}.` };
- }
- return { code: 1, stderr: `unknown certbot subcommand ${args[0]}` };
- };
- return host;
-}
-
-runTests([
- test('a verified domain with no certificate is issued and then installed over HTTPS', async () => {
- const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));
- assert.strictEqual(host.read('/etc/letsencrypt/live/docs.tenant-b.net/fullchain.pem'), null, 'no certificate yet');
- const r = await host.cli('certs', 'renew', 'host', '--install');
- assert.strictEqual(r.code, 0, r.out);
- assert.ok(host.certbotCalls.some((c) => c.args[0] === 'renew'), 'the wildcard lineage is renewed first');
- const call = host.certbotCalls.find((c) => c.args[0] === 'certonly');
- assert.ok(call, 'certbot certonly ran');
- assert.deepStrictEqual(call.args.slice(0, 4), ['certonly', '--webroot', '-w', '/var/www/certbot']);
- assert.strictEqual(call.args[call.args.indexOf('-d') + 1], 'docs.tenant-b.net');
- assert.ok(call.privileged, 'certbot runs as root');
- const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');
- assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/);
- assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'installed and enabled');
- assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'nginx reloaded');
- assert.ok(!host.reads.some((f) => /privkey|\.key$/.test(f)), 'no key file was read');
- assert.ok(!host.certbotCalls.some((c) => c.args.some((a) => /privkey|\.key$/.test(String(a)))), 'no command touched a key path');
- }),
-
- test('a database without host_domains (Host 0008) has no custom domains: the renewal still runs and succeeds', async () => {
- const host = issuer(withHost(scenario(), []));
- host.psqlHandler = () => { throw new Error('psql ov_host failed: ERROR: relation "host_domains" does not exist'); };
- const r = await host.cli('certs', 'renew', 'host', '--install');
- assert.strictEqual(r.code, 0, r.out);
- assert.ok(host.certbotCalls.some((c) => c.args[0] === 'renew'), 'the wildcard lineage is renewed');
- assert.ok(!host.certbotCalls.some((c) => c.args[0] === 'certonly'), 'nothing to issue');
- // Any other database failure still fails the command.
- host.psqlHandler = () => { throw new Error('psql ov_host failed: connection refused'); };
- assert.notStrictEqual((await host.cli('certs', 'renew', 'host', '--install')).code, 0);
- }),
-
- test('issuance failure keeps the domain HTTP-only and fails the command', async () => {
- const host = withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]);
- host.certbot = (args) => (args[0] === 'certonly' ? { code: 1, stderr: 'Certbot failed to authenticate some domains: Challenge failed' } : { code: 0, stdout: '' });
- const r = await host.cli('certs', 'renew', 'host', '--install');
- assert.strictEqual(r.code, 2, r.out);
- assert.match(r.out, /failed docs\.tenant-b\.net: .*Challenge failed .* it stays HTTP-only/);
- const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');
- assert.ok(!/ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/.test(custom), 'no HTTPS block without a certificate');
- assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?location \/ \{ return 404; \}/, 'ACME only until it succeeds');
- assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'the vhosts were still installed');
- }),
-
- test('hostile database values never reach certbot argv', async () => {
- const host = issuer(withHost(scenario(), [
- { hostname: 'ok.example.org' },
- { hostname: 'evil.org; } server { listen 80; root /; }' },
- { hostname: 'x.openvibe.host' },
- { hostname: '../../etc/letsencrypt' },
- { hostname: 'with space.org' },
- ]));
- const r = await host.cli('certs', 'renew', 'host');
- assert.strictEqual(r.code, 0, r.out);
- const certonly = host.certbotCalls.filter((c) => c.args[0] === 'certonly');
- assert.deepStrictEqual(certonly.map((c) => c.args[c.args.indexOf('-d') + 1]), ['ok.example.org'], 'only the plain host name was issued');
- assert.match(r.out, /refused \(not a plain host name\)/);
- assert.ok(certonly.every((c) => c.args.every((a) => !/evil|x\.openvibe\.host|\.\.\/|with space/.test(a))));
- }),
-
- test('without --install it writes no vhost and reports the plan; --json is the same shape', async () => {
- const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));
- const r = await host.cli('certs', 'renew', '--json', 'host');
- assert.strictEqual(r.code, 0, r.out);
- const o = JSON.parse(r.out);
- assert.strictEqual(o.renewed, true);
- assert.deepStrictEqual(o.issued, ['docs.tenant-b.net']);
- assert.deepStrictEqual(o.failed, []);
- assert.strictEqual(o.installed, null);
- assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null, 'render alone writes nothing');
- assert.ok(!host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'));
- }),
-
- test('the renewal unit is rendered: a root oneshot that renews and installs, on a persistent timer', async () => {
- const { service, timer } = acme.unitTexts();
- assert.match(service, /^Type=oneshot$/m);
- assert.match(service, /^User=root$/m);
- assert.match(service, /^ExecStart=\/usr\/local\/bin\/ovhost certs renew --install$/m);
- assert.match(service, /^Environment=OVHOST_INVENTORY=\/etc\/openvibe\/host\.json$/m);
- assert.match(timer, /^OnCalendar=.*UTC$/m);
- assert.match(timer, /^Persistent=true$/m);
- assert.match(timer, /^Unit=openvibe-certs\.service$/m);
- assert.match(timer, /^WantedBy=timers\.target$/m);
- assert.ok(!service.includes('{{') && !timer.includes('{{'));
- }),
-]);
deleted file mode 100644
deleted file mode 100644
@@ -1,185 +0,0 @@
@@ -1,185 +0,0 @@
-'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');
-const { normalise } = require('../lib/inventory');
-const nginx = require('../lib/nginx');
-
-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', engine: 'postgresql', database: 'ov_host' }],
- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },
- };
- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
- host.psqlHandler = (db, sql) => {
- assert.strictEqual(db, 'ov_host');
- assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);
- 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, /location = \/auth\/me \{\n +limit_req zone=ovhost_api burst=30 nodelay;/, 'the session read on every page load is not held to the sign-in limit');
- assert.match(r.out, /location \/auth\/ \{\n +limit_req zone=ovhost_auth burst=10 nodelay;/);
- 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('a PostgreSQL tenant database reads the verified domains over psql, as PostgreSQL', async () => {
- const host = scenario();
- const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
- // Host moved to PostgreSQL (ADR-035): its tenants database is a database in the cluster, not
- // a file, and its service role's password is not ovhost's to read — so psql as postgres.
- 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', engine: 'postgresql', database: 'ov_host' }],
- nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: { engine: 'postgresql', database: 'ov_host' }, maxUpload: '110m' } },
- };
- host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
- host.psqlHandler = (database) => {
- assert.strictEqual(database, 'ov_host', 'the Host database in the cluster');
- return [{ hostname: 'www.tenant-a.org' }, { hostname: 'docs.tenant-b.net' }];
- };
- host.put('/etc/letsencrypt/live/www.tenant-a.org/fullchain.pem', 'CERT', { mode: 0o644 });
- const domains = await nginx.tenantDomains(host.exec, normalise(doc).services.host);
- assert.deepStrictEqual(domains, [{ hostname: 'www.tenant-a.org', hasCert: true }, { hostname: 'docs.tenant-b.net', hasCert: false }], 'the same [{ hostname, hasCert }] shape');
- assert.ok(host.psqlCalls.some((c) => c.database === 'ov_host' && c.sql === nginx.VERIFIED_SQL), 'the verified domains come from psql');
- const r = await host.cli('nginx', 'tenants', 'host');
- assert.strictEqual(r.code, 0, r.out);
- assert.match(r.out, /server_name www\.tenant-a\.org;/);
- assert.match(r.out, /server_name docs\.tenant-b\.net;/);
- }),
-
- 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('launch: --install removes the interim tenants-pending vhost in the same nginx -t and reload; a failed test brings it back', async () => {
- const PENDING = '# *.openvibe.host before Host Stage B tenant hosting launches\nserver { server_name *.openvibe.host; return 404; }\n';
- const SITES = '# openvibe.host — static front page (OpenVibe.Sites)\nserver { server_name openvibe.host; root /opt/openvibe.sites/dist/openvibe.host; }\n';
- const prepare = (host) => {
- host.put('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', PENDING);
- host.files.set('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', owner: 'root', mode: 0o777 });
- host.put('/etc/nginx/sites-available/openvibe.host.conf', SITES);
- host.files.set('/etc/nginx/sites-enabled/openvibe.host.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host.conf', owner: 'root', mode: 0o777 });
- return host;
- };
- const review = await prepare(withHost(scenario(), [])).cli('nginx', 'tenants', 'host');
- assert.match(review.out, /--install also removes, in the same nginx -t and reload: openvibe\.host-tenants-pending\.conf/);
-
- const host = prepare(withHost(scenario(), []));
- let seenAtTest = null;
- host.nginxTest = () => {
- seenAtTest = { pending: host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'), main: host.read('/etc/nginx/sites-available/openvibe.host.conf') };
- return { code: 0, stderr: 'ok' };
- };
- const r = await host.cli('nginx', 'tenants', 'host', '--install');
- assert.strictEqual(r.code, 0, r.out);
- assert.deepStrictEqual([seenAtTest.pending, /server_name \*\.openvibe\.host;/.test(seenAtTest.main)], [false, true], 'one nginx -t sees the tenant vhost and no pending vhost');
- assert.ok(!host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'));
- assert.ok(!host.files.has('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'));
- assert.match(r.out, /removed openvibe\.host-tenants-pending\.conf/);
- assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, 1, 'one reload');
- const again = await host.cli('nginx', 'tenants', 'host', '--install');
- assert.match(again.out, /already installed; nothing changed/);
-
- const bad = prepare(withHost(scenario(), []));
- bad.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });
- const f = await bad.cli('nginx', 'tenants', 'host', '--install');
- assert.strictEqual(f.code, 2);
- assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'), PENDING, 'pending vhost restored');
- assert.strictEqual(bad.files.get('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf').target, '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf');
- assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host.conf'), SITES, 'the Sites placeholder vhost restored');
- assert.ok(!bad.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'not reloaded');
- }),
-
- test('client address headers come from $remote_addr only (realip), in every template ovhost renders', async () => {
- const fs = require('fs');
- const path = require('path');
- const dir = path.join(__dirname, '..', 'templates', 'nginx');
- for (const f of fs.readdirSync(dir)) {
- const t = fs.readFileSync(path.join(dir, f), 'utf8');
- assert.ok(!/\$proxy_add_x_forwarded_for|\$http_x_forwarded_for|\$http_cf_connecting_ip/.test(t), `${f} passes a client-supplied address on`);
- if (/X-Forwarded-For/.test(t)) assert.match(t, /X-Forwarded-For \$remote_addr;/, f);
- }
- const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
- assert.ok(!/proxy_add_x_forwarded_for/.test(r.out));
- assert.match(r.out, /proxy_set_header CF-Connecting-IP \$remote_addr;/);
- }),
-
- test('www.openvibe.host redirects to the dashboard over the wildcard certificate', async () => {
- const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
- assert.match(r.out, /server_name www\.openvibe\.host;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;[\s\S]*?return 301 https:\/\/openvibe\.host\$request_uri;/);
- }),
-
- 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/);
- }),
-]);
@@ -2,9 +2,8 @@
@@ -2,9 +2,8 @@
/**
/**
* The PostgreSQL inventory: a `databases[]` entry is
* The PostgreSQL inventory: a `databases[]` entry is
* PostgreSQL (a database in the cluster, named — its object carries NO `path`). The same engine
* PostgreSQL (a database in the cluster, named — its object carries NO `path`). The same engine
- * choice reaches the drill block ({ url, directUrl }), nginx tenants (read over psql, not a file)
- * and the protected `postgresql-count` probe. These are the normalised shapes the rest of ovhost
- * depends on, and every code path outside the engine's own code is written to assume no `.path`.
+ * choice reaches the drill block ({ url, directUrl }) and the protected `postgresql-count` probe. These are
+ * the normalised shapes the rest of ovhost depends on, and every code path outside the engine's own code is written to assume no `.path`.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { test, runTests } = require('./helpers');
const { test, runTests } = require('./helpers');
@@ -86,16 +85,11 @@ runTests([
@@ -86,16 +85,11 @@ runTests([
assert.throws(() => normalise(pgDrill({ countsTolerance: 1.5 })), /countsTolerance must be a whole number of rows/);
assert.throws(() => normalise(pgDrill({ countsTolerance: 1.5 })), /countsTolerance must be a whole number of rows/);
}),
}),
- test('nginx.tenants.database accepts a PostgreSQL database object', () => {
- const tenants = (database) => normalise({ services: { host: { ...base, nginx: { tenants: { database } } } } }).services.host.nginx.tenants.database;
- assert.deepStrictEqual(tenants({ engine: 'postgresql', database: 'ov_host' }), { engine: 'postgresql', database: 'ov_host' });
- }),
-
- test('nginx.tenants.database rejects anything else', () => {
- const tenants = (database) => normalise({ services: { host: { ...base, nginx: { tenants: { database } } } } });
- for (const database of [{ engine: 'mysql', database: 'ov_host' }, { engine: 'postgresql', database: 'host' }, { database: 'ov_host' }, 42, []]) {
- assert.throws(() => tenants(database), /nginx\.tenants\.database must be/, JSON.stringify(database));
- }
+ test('a nginx.tenants block is refused: Stage B moved to OpenVibe.Space, openvibe.host is a repo vhost', () => {
+ const tenants = { database: { engine: 'postgresql', database: 'ov_host' } };
+ assert.throws(() => normalise({ services: { host: { ...base, nginx: { tenants } } } }), /nginx\.tenants is retired/);
+ const nginx = normalise({ services: { host: { ...base, nginx: { vhost: 'openvibe.host.conf', repoVhost: 'deploy/nginx/openvibe.host.conf', variant: 'http' } } } }).services.host.nginx;
+ assert.strictEqual(nginx.repoVhost, 'deploy/nginx/openvibe.host.conf');
}),
}),
test('the postgresql-count probe validates its database and SQL, alone and inside a sum', () => {
test('the postgresql-count probe validates its database and SQL, alone and inside a sum', () => {