openvibers/openvibe.host Commits

ovhost drops Stage B's tenant vhosts and custom-domain certificates; openvibe.host is a repo vhost (#144)

OP OpenVibers committed 21 days ago · 5a75dfd1c0 · 24 files, +33 −826

Static hosting moved to OpenVibe.Space on 2026-10-12 and migration 0008 dropped host_domains, so
'ovhost nginx tenants', 'ovhost certs renew' and openvibe-certs.timer had nothing left to do (the
timer failed on 10-23 until Host#141). The inventory refuses a nginx.tenants block; Host declares
deploy/nginx/openvibe.host.conf like every other service, which also answers *.openvibe.host so old
site links keep getting the service's 410 page pointing at OpenVibe.Space. certbot.timer renews the
wildcard certificate (DNS-01).

Changes

ddiff --git a/README.md b/README.md
iindex 1c2f5f1..d70fddc 100644
---- a/README.md
++++ b/README.md
@@@ -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>]
ddiff --git a/deploy/nginx/openvibe.host-tenants-pending.conf b/deploy/nginx/openvibe.host-tenants-pending.conf
ddeleted file mode 100644
iindex b0f7d0e..0000000
---- a/deploy/nginx/openvibe.host-tenants-pending.conf
++++ /dev/null
@@@ -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;
--}
ddiff --git a/deploy/nginx/openvibe.host.conf b/deploy/nginx/openvibe.host.conf
iindex 0050e11..46d38c6 100644
---- a/deploy/nginx/openvibe.host.conf
++++ b/deploy/nginx/openvibe.host.conf
@@@ -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;
ddiff --git a/deploy/systemd/openvibe-certs.service b/deploy/systemd/openvibe-certs.service
ddeleted file mode 100644
iindex 76ad5b3..0000000
---- a/deploy/systemd/openvibe-certs.service
++++ /dev/null
@@@ -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
ddiff --git a/deploy/systemd/openvibe-certs.timer b/deploy/systemd/openvibe-certs.timer
ddeleted file mode 100644
iindex 40e64e4..0000000
---- a/deploy/systemd/openvibe-certs.timer
++++ /dev/null
@@@ -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
ddiff --git a/deploy/systemd/openvibe-host.service b/deploy/systemd/openvibe-host.service
iindex 216d5ad..a2ddb7d 100644
---- a/deploy/systemd/openvibe-host.service
++++ b/deploy/systemd/openvibe-host.service
@@@ -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.service
Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
ddiff --git a/docs/db-inventory.md b/docs/db-inventory.md
iindex 4a43293..12da1d7 100644
---- a/docs/db-inventory.md
++++ b/docs/db-inventory.md
@@@ -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.
ddiff --git a/docs/launch.md b/docs/launch.md
iindex 97ce8bb..af9f2f5 100644
---- a/docs/launch.md
++++ b/docs/launch.md
@@@ -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, an
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
ddiff --git a/docs/threat-review.md b/docs/threat-review.md
iindex e2fcf5b..34fa94a 100644
---- a/docs/threat-review.md
++++ b/docs/threat-review.md
@@@ -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 a
threat review before a service leaves placeholder status. This review covers Stage B as of this
commit:
ddiff --git a/host.example.json b/host.example.json
iindex 59e76e5..cd10394 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -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": 30
ddiff --git a/lib/acme.js b/lib/acme.js
ddeleted file mode 100644
iindex a941c09..0000000
---- a/lib/acme.js
++++ /dev/null
@@@ -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 };
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 4024b24..2df74c0 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -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 log
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]
print a vhost from the service manifest; --install writes it,
runs nginx -t and reloads only if clean
-- nginx tenants <service> [--wildcard-cert <name|dir>] [--install]
-- Stage B: the dashboard + *.<sites domain> vhost and one vhost
-- for the VERIFIED custom domains (read from the service's
-- database); lists domains still waiting for a certificate
snapshot <service> [--out <file>] config tarball: units, vhost, env NAMES, sha — no secret values
backup <service> [--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, dirty
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) {
const pos = [];
@@@ -438,19 +430,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 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) => proc
return 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);
ddiff --git a/lib/index.js b/lib/index.js
iindex 5abe334..572b9b8 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -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,
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex c12577c..8a8164c 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -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`);
ddiff --git a/lib/nginx.js b/lib/nginx.js
iindex c87f489..0e4a12c 100644
---- a/lib/nginx.js
++++ b/lib/nginx.js
@@@ -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 };
ddiff --git a/templates/nginx/custom-domains.conf.tmpl b/templates/nginx/custom-domains.conf.tmpl
ddeleted file mode 100644
iindex 406fedb..0000000
---- a/templates/nginx/custom-domains.conf.tmpl
++++ /dev/null
@@@ -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}}
ddiff --git a/templates/nginx/custom-http.tmpl b/templates/nginx/custom-http.tmpl
ddeleted file mode 100644
iindex b4310f5..0000000
---- a/templates/nginx/custom-http.tmpl
++++ /dev/null
@@@ -1,9 +0,0 @@
--
--server {
-- listen 80;
-- listen [::]:80;
-- server_name {{names}};
--
-- location /.well-known/acme-challenge/ { root /var/www/certbot; }
-- location / { {{fallback}} }
--}
ddiff --git a/templates/nginx/custom-https.tmpl b/templates/nginx/custom-https.tmpl
ddeleted file mode 100644
iindex e8eaff6..0000000
---- a/templates/nginx/custom-https.tmpl
++++ /dev/null
@@@ -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}}
--}
ddiff --git a/templates/nginx/tenant-location.tmpl b/templates/nginx/tenant-location.tmpl
ddeleted file mode 100644
iindex c2ee5e2..0000000
---- a/templates/nginx/tenant-location.tmpl
++++ /dev/null
@@@ -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;
-- }
ddiff --git a/templates/nginx/tenants.conf.tmpl b/templates/nginx/tenants.conf.tmpl
ddeleted file mode 100644
iindex d803a26..0000000
---- a/templates/nginx/tenants.conf.tmpl
++++ /dev/null
@@@ -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}}
--}
ddiff --git a/templates/nginx/www-redirect.tmpl b/templates/nginx/www-redirect.tmpl
ddeleted file mode 100644
iindex f7ba6d3..0000000
---- a/templates/nginx/www-redirect.tmpl
++++ /dev/null
@@@ -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;
--}
--
ddiff --git a/test/certs-acme.test.js b/test/certs-acme.test.js
ddeleted file mode 100644
iindex 4ea80cd..0000000
---- a/test/certs-acme.test.js
++++ /dev/null
@@@ -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('{{'));
-- }),
--]);
ddiff --git a/test/nginx-tenants.test.js b/test/nginx-tenants.test.js
ddeleted file mode 100644
iindex 3aa7a57..0000000
---- a/test/nginx-tenants.test.js
++++ /dev/null
@@@ -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/);
-- }),
--]);
ddiff --git a/test/pg-inventory.test.js b/test/pg-inventory.test.js
iindex 9afab07..49f6f58 100644
---- a/test/pg-inventory.test.js
++++ b/test/pg-inventory.test.js
@@@ -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', () => {