- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and, on the control centre only, Stripe/billing. Stored sealed in data/settings.json and applied over .env in every process; hosted agency workspaces don't see our email, platform keys or billing. - Odoo connection per workspace in Connections (login checked before saving). - provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true. - docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
11 KiB
HTML
191 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Automatic agency subdomains</title>
|
|
<style>
|
|
:root { --navy:#242142; --coral:#FC6D45; --ink:#1f2024; --muted:#6b6e78; --line:#e4e7ec; --bg:#f5f6f8; --card:#fff; --code:#14132a; --ok:#1d7a46; --warn:#9a5b00; --warnbg:#fff6e5; }
|
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink:#e8e8ee; --muted:#a3a5b0; --line:#2c2b45; --bg:#12111f; --card:#1b1a2e; --code:#0b0a16; --warnbg:#2a2210; --warn:#f0b65a; --ok:#5fcf8f; } }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 Inter, "Segoe UI", Arial, sans-serif; }
|
|
main { max-width: 860px; margin: 0 auto; padding: 32px 16px 64px; }
|
|
h1 { font-size: 26px; margin: 0 0 6px; color: var(--ink); }
|
|
h2 { font-size: 18px; margin: 0 0 6px; display:flex; gap:10px; align-items:center; }
|
|
.num { flex:none; width:28px; height:28px; border-radius:50%; background:var(--navy); color:#fff; display:grid; place-items:center; font-size:14px; }
|
|
.lede { color: var(--muted); margin: 0 0 24px; }
|
|
section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin: 0 0 16px; }
|
|
p { margin: 8px 0; }
|
|
.why { color: var(--muted); font-size: 14px; }
|
|
pre { position: relative; background: var(--code); color: #e9e8ff; border-radius: 8px; padding: 14px 16px; overflow-x: auto; font: 13px/1.55 ui-monospace, Consolas, monospace; margin: 10px 0; }
|
|
pre .c { color: #8f8cb8; }
|
|
button.copy { position:absolute; top:8px; right:8px; background:#2f2c55; color:#fff; border:0; border-radius:6px; padding:4px 10px; font-size:12px; cursor:pointer; }
|
|
button.copy:hover { background: var(--coral); }
|
|
.check { border-left: 3px solid var(--ok); padding: 4px 0 4px 12px; margin: 10px 0; font-size: 14px; }
|
|
.warn { background: var(--warnbg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 14px; margin: 10px 0; }
|
|
code { font: 13px ui-monospace, Consolas, monospace; background: rgba(127,127,160,.15); padding: 1px 5px; border-radius: 4px; }
|
|
ul { margin: 6px 0; padding-left: 20px; }
|
|
.meta { display:flex; flex-wrap:wrap; gap:8px 20px; font-size:13px; color:var(--muted); margin-bottom:20px; }
|
|
label.done { display:flex; gap:8px; align-items:center; font-size:14px; color:var(--muted); margin-top:10px; cursor:pointer; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Automatic agency subdomains</h1>
|
|
<p class="lede">Do this once. After it, every approved agency gets <code>name.quorlasocial.com</code> with HTTPS automatically, without anyone touching Hestia.</p>
|
|
<div class="meta">
|
|
<span>Server: 72.62.169.43 (HestiaCP user <code>user</code>)</span>
|
|
<span>Run as: <code>dev</code>, with sudo where shown</span>
|
|
<span>Time: about 30 minutes</span>
|
|
</div>
|
|
|
|
<section>
|
|
<p><b>What this changes.</b> Today a new agency's workspace (container, data, port and welcome email) is created by the provisioning script, but its web address needs three manual steps in Hestia: DNS, SSL and nginx. This setup replaces those with:</p>
|
|
<ul>
|
|
<li>one wildcard DNS record</li>
|
|
<li>one wildcard certificate</li>
|
|
<li>one nginx rule that reads <code>nginx-tenants.map</code> (the script already keeps that file up to date)</li>
|
|
</ul>
|
|
<p>Nothing changes for <b>app.quorlasocial.com</b> or <b>clickstocart.quorlasocial.com</b>: nginx always prefers an exact server name over the wildcard.</p>
|
|
<div class="warn">If any command's output looks different from what this page expects, stop and paste the output to Claude before going on.</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">0</span>Check and back up</h2>
|
|
<pre><button class="copy">Copy</button>sudo nginx -t
|
|
nginx -v
|
|
sudo /usr/local/hestia/bin/v-list-web-domain user quorlasocial.com
|
|
sudo /usr/local/hestia/bin/v-list-dns-records user quorlasocial.com | grep -E '^ID|\*'
|
|
which nginx systemctl
|
|
sudo tar -czf ~/nginx-backup-$(date +%F).tar.gz /etc/nginx</pre>
|
|
<div class="check">
|
|
<b>Expect:</b>
|
|
<ul>
|
|
<li><code>nginx -t</code> says the syntax is ok.</li>
|
|
<li>The web-domain line tells you whether <code>quorlasocial.com</code> is a web domain on Hestia. Note the answer, because it decides step 2.</li>
|
|
<li>The DNS line tells you whether a <code>*</code> record already exists.</li>
|
|
<li>Note the two paths from <code>which</code>, usually <code>/usr/sbin/nginx</code> and <code>/usr/bin/systemctl</code>.</li>
|
|
</ul>
|
|
</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">1</span>Wildcard DNS</h2>
|
|
<p class="why">Every <code>anything.quorlasocial.com</code> then points at this server. Skip this step if step 0 already showed a <code>*</code> record.</p>
|
|
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-dns-record user quorlasocial.com '*' A 72.62.169.43
|
|
dig +short test123.quorlasocial.com @8.8.8.8</pre>
|
|
<div class="check"><b>Expect:</b> <code>72.62.169.43</code>. It can take a few minutes to appear.</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">2</span>Wildcard SSL certificate</h2>
|
|
<p class="why">Hestia can issue a <code>*.quorlasocial.com</code> certificate because the DNS zone is on this server, and it renews the certificate by itself.</p>
|
|
<p><b>Only if step 0 showed that quorlasocial.com is <u>not</u> a web domain yet</b>, add it first. Do this only if the bare domain isn't hosted somewhere else (for example a marketing site); if it is, ask Claude first.</p>
|
|
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain user quorlasocial.com</pre>
|
|
<p>Then add the wildcard alias and issue the certificate:</p>
|
|
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain-alias user quorlasocial.com '*.quorlasocial.com'
|
|
sudo /usr/local/hestia/bin/v-add-letsencrypt-domain user quorlasocial.com '*.quorlasocial.com'
|
|
sudo openssl x509 -in /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem -noout -subject -ext subjectAltName -enddate</pre>
|
|
<div class="check"><b>Expect:</b> the certificate lists <code>DNS:*.quorlasocial.com</code> and an end date about 90 days away.</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">3</span>One nginx rule for every agency</h2>
|
|
<p class="why">This file lives outside Hestia's per-domain folders, so Hestia rebuilds don't overwrite it. It looks up the agency's port by the subdomain. Addresses that aren't in the map get a 404.</p>
|
|
<pre><button class="copy">Copy</button>touch /home/dev/APPS/quorla-tenants/nginx-tenants.map
|
|
sudo tee /etc/nginx/conf.d/quorla-tenants.conf >/dev/null <<'EOF'
|
|
map $host $quorla_tenant {
|
|
default "";
|
|
include /home/dev/APPS/quorla-tenants/nginx-tenants.map;
|
|
}
|
|
server {
|
|
listen 72.62.169.43:80;
|
|
server_name *.quorlasocial.com;
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
server {
|
|
listen 72.62.169.43:443 ssl;
|
|
server_name *.quorlasocial.com;
|
|
ssl_certificate /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem;
|
|
ssl_certificate_key /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.key;
|
|
client_max_body_size 500m;
|
|
proxy_read_timeout 600s;
|
|
location / {
|
|
if ($quorla_tenant = "") { return 404; }
|
|
proxy_pass http://$quorla_tenant;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto https;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
}
|
|
}
|
|
EOF
|
|
sudo nginx -t && sudo systemctl reload nginx</pre>
|
|
<div class="check">
|
|
<b>Expect:</b> <code>syntax is ok</code>. If nginx warns that <code>*.quorlasocial.com</code> is a "conflicting server name", the step 2 alias made Hestia's quorlasocial.com config claim the wildcard too. Paste the warning to Claude; the fix is a one-line change.
|
|
</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">4</span>Let the script reload nginx</h2>
|
|
<p class="why">The script runs as <code>dev</code>. It needs exactly two root commands and nothing else. Use the paths from step 0 if they differ.</p>
|
|
<pre><button class="copy">Copy</button>echo 'dev ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/quorla
|
|
sudo chmod 440 /etc/sudoers.d/quorla
|
|
sudo visudo -c
|
|
sudo -n nginx -t && echo "sudo rule works"</pre>
|
|
<div class="check"><b>Expect:</b> <code>parsed OK</code>, then <code>sudo rule works</code>, without being asked for a password.</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">5</span>Run the script every 5 minutes</h2>
|
|
<p class="why">It picks up approved agencies, creates their workspaces, moves agencies onto new versions after a deploy, and refreshes the nginx map.</p>
|
|
<pre><button class="copy">Copy</button>bash /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh
|
|
cat /home/dev/APPS/quorla-tenants/nginx-tenants.map
|
|
( crontab -l 2>/dev/null | grep -v provision-tenants.sh; echo '*/5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1' ) | crontab -
|
|
crontab -l</pre>
|
|
<div class="check"><b>Expect:</b> the map contains <code>clickstocart.quorlasocial.com 127.0.0.1:4400;</code>, the script logs <code>nginx reloaded</code> the first time, and the cron line is listed.</div>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">6</span>Test</h2>
|
|
<pre><button class="copy">Copy</button>curl -sI https://app.quorlasocial.com | head -1
|
|
curl -sI https://clickstocart.quorlasocial.com | head -1
|
|
curl -sI https://nobody-here.quorlasocial.com | head -1</pre>
|
|
<div class="check"><b>Expect:</b> the first two return 200 (or a redirect), and the last returns 404 with a valid certificate (no SSL error).</div>
|
|
<p>Then do a real end-to-end test:</p>
|
|
<ul>
|
|
<li>Sign up a test agency at <code>https://app.quorlasocial.com/signup</code>.</li>
|
|
<li>Approve it in Admin → Agencies.</li>
|
|
<li>Wait up to 5 minutes, then open <code>https://<test>.quorlasocial.com</code>. The welcome email's link should work.</li>
|
|
<li>Remove the test agency in Agencies afterwards. Its data is archived.</li>
|
|
</ul>
|
|
<label class="done"><input type="checkbox"> Done</label>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Undo</h2>
|
|
<pre><button class="copy">Copy</button>sudo rm /etc/nginx/conf.d/quorla-tenants.conf
|
|
sudo nginx -t && sudo systemctl reload nginx
|
|
crontab -l | grep -v provision-tenants.sh | crontab -</pre>
|
|
<p class="why">The DNS record and the certificate are harmless to leave in place. Existing workspaces keep running either way.</p>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
document.querySelectorAll("button.copy").forEach((b) => b.addEventListener("click", async () => {
|
|
const text = b.parentElement.innerText.replace(/^Copy\n?/, "");
|
|
try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; } catch { b.textContent = "Select & copy"; }
|
|
setTimeout(() => (b.textContent = "Copy"), 1500);
|
|
}));
|
|
</script>
|
|
</body>
|
|
</html>
|