Initial import of Hugo site to Forgejo
This commit is contained in:
commit
cb1ba0317f
1259 changed files with 236349 additions and 0 deletions
893
public-onion/posts/matrix_setup/index.html
Normal file
893
public-onion/posts/matrix_setup/index.html
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="auto">
|
||||
|
||||
<head><meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way) | AlipourIm journeys</title>
|
||||
<meta name="keywords" content="matrix, synapse, element-call, livekit, coturn, nginx, webrtc, self-hosting, debugging">
|
||||
<meta name="description" content="How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.">
|
||||
<meta name="author" content="Iman Alipour">
|
||||
<link rel="canonical" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/">
|
||||
<link crossorigin="anonymous" href="/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css" integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as="style">
|
||||
<link rel="icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/apple-touch-icon.png">
|
||||
<link rel="mask-icon" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/safari-pinned-tab.svg">
|
||||
<meta name="theme-color" content="#2e2e33">
|
||||
<meta name="msapplication-TileColor" content="#2e2e33">
|
||||
<link rel="alternate" hreflang="en" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/">
|
||||
<noscript>
|
||||
<style>
|
||||
#theme-toggle,
|
||||
.top-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--theme: rgb(29, 30, 32);
|
||||
--entry: rgb(46, 46, 51);
|
||||
--primary: rgb(218, 218, 219);
|
||||
--secondary: rgb(155, 156, 157);
|
||||
--tertiary: rgb(65, 66, 68);
|
||||
--content: rgb(196, 196, 197);
|
||||
--code-block-bg: rgb(46, 46, 51);
|
||||
--code-bg: rgb(55, 56, 62);
|
||||
--border: rgb(51, 51, 51);
|
||||
}
|
||||
|
||||
.list {
|
||||
background: var(--theme);
|
||||
}
|
||||
|
||||
.list:not(.dark)::-webkit-scrollbar-track {
|
||||
background: 0 0;
|
||||
}
|
||||
|
||||
.list:not(.dark)::-webkit-scrollbar-thumb {
|
||||
border-color: var(--theme);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</noscript><script>
|
||||
(function () {
|
||||
|
||||
|
||||
window.goatcounter = { endpoint: "/count" };
|
||||
|
||||
const s = document.createElement("script");
|
||||
s.async = true;
|
||||
s.src = "/js/count.js";
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
const GC = "";
|
||||
const nf = new Intl.NumberFormat();
|
||||
const SING = "visit";
|
||||
const PLUR = "visits";
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(fn, 0);
|
||||
else document.addEventListener("DOMContentLoaded", fn);
|
||||
}
|
||||
|
||||
function gcPath() {
|
||||
try {
|
||||
return window.goatcounter && window.goatcounter.get_data
|
||||
? window.goatcounter.get_data().p
|
||||
: location.pathname;
|
||||
} catch (_) {
|
||||
return location.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCount(path, period) {
|
||||
try {
|
||||
const q = new URLSearchParams({ t: String(Date.now()) });
|
||||
if (period) q.set("start", period);
|
||||
const url = `${GC}/counter/${encodeURIComponent(path)}.json?${q}`;
|
||||
const r = await fetch(url, { credentials: "omit" });
|
||||
if (r.status === 404) return 0;
|
||||
if (!r.ok) throw 0;
|
||||
const j = await r.json();
|
||||
|
||||
const n = Number(String(j.count).replace(/,/g, ""));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const labelVisits = (n) =>
|
||||
n === null ? "—" : `${nf.format(n)} ${n === 1 ? SING : PLUR}`;
|
||||
|
||||
ready(async function () {
|
||||
const post = document.querySelector(".post-single");
|
||||
if (post) {
|
||||
const meta = post.querySelector(
|
||||
".post-header .post-meta, .post-header .entry-meta, .post-meta"
|
||||
);
|
||||
const visits = await fetchCount(gcPath());
|
||||
if (meta) {
|
||||
let span = meta.querySelector(".post-views");
|
||||
if (!span) {
|
||||
span = document.createElement("span");
|
||||
span.className = "post-views";
|
||||
meta.appendChild(span);
|
||||
}
|
||||
span.textContent = labelVisits(visits ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const cards = document.querySelectorAll("article.post-entry");
|
||||
await Promise.all(
|
||||
Array.from(cards).map(async (card) => {
|
||||
const meta = card.querySelector(".entry-footer, .post-meta");
|
||||
const link = card.querySelector("a.entry-link, h2 a, .entry-title a");
|
||||
if (!meta || !link) return;
|
||||
try {
|
||||
const u = new URL(link.getAttribute("href"), location.origin);
|
||||
if (u.origin !== location.origin) return;
|
||||
let span = meta.querySelector(".post-views");
|
||||
if (!span) {
|
||||
span = document.createElement("span");
|
||||
span.className = "post-views";
|
||||
meta.appendChild(span);
|
||||
}
|
||||
span.textContent = labelVisits((await fetchCount(u.pathname)) ?? 0);
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
|
||||
const uvEl = document.getElementById("gc_site_visitors_week");
|
||||
if (uvEl) {
|
||||
const n = await fetchCount("TOTAL", "week");
|
||||
uvEl.textContent = n === null ? "—" : nf.format(n);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="" id="top">
|
||||
<script>
|
||||
if (localStorage.getItem("pref-theme") === "dark") {
|
||||
document.body.classList.add('dark');
|
||||
} else if (localStorage.getItem("pref-theme") === "light") {
|
||||
document.body.classList.remove('dark')
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.body.classList.add('dark');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<nav class="nav">
|
||||
<div class="logo">
|
||||
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/" accesskey="h" title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a>
|
||||
<div class="logo-switches">
|
||||
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
|
||||
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="menu">
|
||||
<li>
|
||||
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/" title="Posts">
|
||||
<span>Posts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/phd_journey/" title="PhD_journey">
|
||||
<span>PhD_journey</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/about/" title="About">
|
||||
<span>About</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="main">
|
||||
|
||||
<article class="post-single">
|
||||
<header class="post-header">
|
||||
<div class="breadcrumbs"><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/">Home</a> » <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/">Posts</a></div>
|
||||
<h1 class="post-title entry-hint-parent">
|
||||
Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)
|
||||
</h1>
|
||||
<div class="post-description">
|
||||
How I set up a Matrix homeserver (Synapse) with TURN and Element Call using LiveKit, plus the exact debugging steps that took it from 'waiting for media' to solid calls.
|
||||
</div>
|
||||
<div class="post-meta"><span title='2025-08-26 00:00:00 +0000 UTC'>August 26, 2025</span> · 8 min · Iman Alipour
|
||||
|
||||
<span class="post-meta-item">
|
||||
<a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/matrix_setup/#isso-thread" class="isso-comments-link">Comments</a>
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<figure class="entry-cover">
|
||||
<img loading="eager" src="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/images/matrix-cover.png" alt="Matrix, Signal but distributed">
|
||||
<figcaption>Distributed end-to-end encrypted chat platform</figcaption>
|
||||
</figure>
|
||||
<div class="post-content"><blockquote>
|
||||
<p>TL;DR: The call kept saying <strong>“waiting for media”</strong> because the browser never opened a WebSocket to LiveKit. The root cause was <strong>duplicate <code>Access-Control-Allow-Origin</code> headers</strong> on <code>/sfu/get</code> (CORS), which stopped the JWT response. Fixing CORS and ensuring the WS proxy worked (HTTP <strong>101</strong> in logs) solved it.</p></blockquote>
|
||||
<h2 id="what-im-building">What I’m building<a hidden class="anchor" aria-hidden="true" href="#what-im-building">#</a></h2>
|
||||
<ul>
|
||||
<li>A <strong>Matrix homeserver</strong> (Synapse) at <code>matrix.example.com</code> (replace with your domain).</li>
|
||||
<li><strong>TURN/STUN</strong> (coTURN) for NAT traversal.</li>
|
||||
<li><strong>Element Call</strong> backed by <strong>LiveKit</strong>, fronted by <code>rtc.example.com</code>.</li>
|
||||
<li><strong>Nginx (host)</strong> as the single reverse proxy for everything.</li>
|
||||
<li><strong>Cloudflare</strong> DNS (with <code>rtc.*</code> set to <strong>DNS-only</strong>, no orange cloud).</li>
|
||||
<li><strong>UFW</strong> firewall opened for Matrix federation, TURN, and LiveKit media ports.</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>I used Docker for Synapse, PostgreSQL, LiveKit and the JWT helper. I used <strong>host</strong> Nginx (not Nginx in Docker) to avoid port binding conflicts on 80/443/8448.</p></blockquote>
|
||||
<hr>
|
||||
<h2 id="prereqs">Prereqs<a hidden class="anchor" aria-hidden="true" href="#prereqs">#</a></h2>
|
||||
<ul>
|
||||
<li>DNS A/AAAA:
|
||||
<ul>
|
||||
<li><code>matrix.example.com</code> → your server (v4/v6)</li>
|
||||
<li><code>rtc.example.com</code> → your server (v4/v6)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Certificates:
|
||||
<ul>
|
||||
<li><code>matrix.example.com</code> and <code>rtc.example.com</code> via Let’s Encrypt on the host</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Cloudflare: <strong>DNS-only (grey cloud)</strong> for <code>rtc.example.com</code> so WebSockets & UDP work without interference.</li>
|
||||
<li>UFW / firewall open:
|
||||
<ul>
|
||||
<li>80/tcp, 443/tcp</li>
|
||||
<li>8448/tcp (Matrix federation)</li>
|
||||
<li>3478/tcp, 3478/udp and <strong>5349/tcp</strong> (TURN/TLS)</li>
|
||||
<li><strong>LiveKit</strong>: 7881/tcp and <strong>50100–50200/udp</strong> (or your chosen range)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Docker + docker compose installed.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="synapse--postgresql">Synapse + PostgreSQL<a hidden class="anchor" aria-hidden="true" href="#synapse--postgresql">#</a></h2>
|
||||
<h3 id="1-the-postgresql-collation-gotcha">1) The PostgreSQL collation gotcha<a hidden class="anchor" aria-hidden="true" href="#1-the-postgresql-collation-gotcha">#</a></h3>
|
||||
<p>Synapse prefers the database collation <strong><code>C</code></strong>. If your Postgres cluster was initialized with <code>en_US.utf8</code>, Synapse will error like:</p>
|
||||
|
||||
<div class="mermaid">Database has incorrect collation of 'en_US.utf8'. Should be 'C'</div>
|
||||
|
||||
<p><strong>Two ways to resolve:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Preferred (clean)</strong>: Re-initialize the Postgres <strong>cluster</strong> with <code>C</code> and <code>UTF-8</code>:</li>
|
||||
</ul>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># docker-compose.yml (excerpt for Postgres)</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">db</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">postgres:16</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">POSTGRES_DB</span>: <span style="color:#ae81ff">synapse</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">POSTGRES_USER</span>: <span style="color:#ae81ff">synapse</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">POSTGRES_PASSWORD</span>: <span style="color:#ae81ff"><strong-password></span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">POSTGRES_INITDB_ARGS</span>: <span style="color:#e6db74">"--locale=C --encoding=UTF8 --lc-collate=C --lc-ctype=C"</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>:
|
||||
</span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./pgdata:/var/lib/postgresql/data</span>
|
||||
</span></span></code></pre></div><blockquote>
|
||||
<p>Requires wiping the volume and recreating the DB.</p></blockquote>
|
||||
<ul>
|
||||
<li><strong>Pragmatic (works quickly)</strong>: In Synapse’s DB config, set <code>allow_unsafe_locale: true</code>. This bypasses the check. It’s fine for hobby use; for production, prefer the clean <code>C</code> cluster.</li>
|
||||
</ul>
|
||||
<h3 id="2-start-synapse-and-generate-config">2) Start Synapse and generate config<a hidden class="anchor" aria-hidden="true" href="#2-start-synapse-and-generate-config">#</a></h3>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d db synapse
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Logs</span>
|
||||
</span></span><span style="display:flex;"><span>docker compose logs --tail<span style="color:#f92672">=</span><span style="color:#ae81ff">200</span> synapse
|
||||
</span></span></code></pre></div><p>Ensure Synapse prints your <strong>server_name</strong> and <strong>public base URL</strong> and stays up.</p>
|
||||
<h3 id="3-create-an-admin-user">3) Create an admin user<a hidden class="anchor" aria-hidden="true" href="#3-create-an-admin-user">#</a></h3>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Exec into the running Synapse container:</span>
|
||||
</span></span><span style="display:flex;"><span>docker compose exec synapse register_new_matrix_user <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -c /data/homeserver.yaml -u <username> -p <password> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -a -k
|
||||
</span></span></code></pre></div><blockquote>
|
||||
<p>If you see “Unknown execution mode”, you probably ran the binary with the wrong entrypoint. Use <code>docker compose exec synapse …</code> against the running container.</p></blockquote>
|
||||
<hr>
|
||||
<h2 id="turn-coturn">TURN (coTURN)<a hidden class="anchor" aria-hidden="true" href="#turn-coturn">#</a></h2>
|
||||
<h3 id="1-avoid-bad-inline-comments">1) Avoid bad inline comments<a hidden class="anchor" aria-hidden="true" href="#1-avoid-bad-inline-comments">#</a></h3>
|
||||
<p>If you see errors like:</p>
|
||||
|
||||
<div class="mermaid">ERROR: Unknown boolean value: # log to journald/syslog. You can use on/off, yes/no, 1/0, true/false.</div>
|
||||
|
||||
<p>…it means a <code>#</code> comment is on the <strong>same line</strong> as a boolean directive. Move comments to their own lines.</p>
|
||||
<h3 id="2-minimal-turnserverconf">2) Minimal <code>turnserver.conf</code><a hidden class="anchor" aria-hidden="true" href="#2-minimal-turnserverconf">#</a></h3>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">listening-port</span><span style="color:#f92672">=</span><span style="color:#e6db74">3478</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">tls-listening-port</span><span style="color:#f92672">=</span><span style="color:#e6db74">5349</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">fingerprint</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">use-auth-secret</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">static-auth-secret</span><span style="color:#f92672">=</span><span style="color:#e6db74"><shared-secret> # also set in Synapse</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">realm</span><span style="color:#f92672">=</span><span style="color:#e6db74">example.com # used in creds generation</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">total-quota</span><span style="color:#f92672">=</span><span style="color:#e6db74">0</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">bps-capacity</span><span style="color:#f92672">=</span><span style="color:#e6db74">0</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">cli-password</span><span style="color:#f92672">=</span><span style="color:#e6db74"><admin-pass></span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">no-cli</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">cert</span><span style="color:#f92672">=</span><span style="color:#e6db74">/etc/letsencrypt/live/turn.example.com/fullchain.pem</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pkey</span><span style="color:#f92672">=</span><span style="color:#e6db74">/etc/letsencrypt/live/turn.example.com/privkey.pem</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># If behind NAT:</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># external-ip=<public-ip>/<internal-ip></span>
|
||||
</span></span></code></pre></div><h3 id="3-wire-turn-into-synapse">3) Wire TURN into Synapse<a hidden class="anchor" aria-hidden="true" href="#3-wire-turn-into-synapse">#</a></h3>
|
||||
<p>In <code>homeserver.yaml</code>:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">turn_uris</span>:
|
||||
</span></span><span style="display:flex;"><span> - <span style="color:#e6db74">"turn:turn.example.com?transport=udp"</span>
|
||||
</span></span><span style="display:flex;"><span> - <span style="color:#e6db74">"turn:turn.example.com?transport=tcp"</span>
|
||||
</span></span><span style="display:flex;"><span> - <span style="color:#e6db74">"turns:turn.example.com:5349?transport=tcp"</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">turn_shared_secret</span>: <span style="color:#e6db74">"<shared-secret>"</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">turn_user_lifetime</span>: <span style="color:#e6db74">"1d"</span>
|
||||
</span></span></code></pre></div><p>Verify the homeserver issues TURN creds:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>TOKEN<span style="color:#f92672">=</span><span style="color:#e6db74">'<your matrix access token>'</span>
|
||||
</span></span><span style="display:flex;"><span>curl -s -H <span style="color:#e6db74">"Authorization: Bearer </span>$TOKEN<span style="color:#e6db74">"</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
|
||||
</span></span></code></pre></div><p>You should see <code>uris</code>, a time-limited <code>username</code> and <code>password</code>.</p>
|
||||
<hr>
|
||||
<h2 id="nginx-host-for-synapse-and-federation">Nginx (host) for Synapse and federation<a hidden class="anchor" aria-hidden="true" href="#nginx-host-for-synapse-and-federation">#</a></h2>
|
||||
<p>Create <code>/etc/nginx/conf.d/matrix.conf</code>:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#75715e"># Client traffic on 443
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">server</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">listen</span> <span style="color:#ae81ff">443</span> <span style="color:#e6db74">ssl</span>; <span style="color:#f92672">listen</span> <span style="color:#e6db74">[::]:443</span> <span style="color:#e6db74">ssl</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">server_name</span> <span style="color:#e6db74">matrix.example.com</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate</span> <span style="color:#e6db74">/etc/letsencrypt/live/matrix.example.com/fullchain.pem</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate_key</span> <span style="color:#e6db74">/etc/letsencrypt/live/matrix.example.com/privkey.pem</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#75715e"># Proxy client & admin APIs to Synapse (container) on 8008
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">location</span> ~ <span style="color:#e6db74">^(/_matrix|/_synapse/client)</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://127.0.0.1:8008</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-For</span> $remote_addr;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-Proto</span> $scheme;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Host</span> $host;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">client_max_body_size</span> <span style="color:#e6db74">50M</span>;
|
||||
</span></span><span style="display:flex;"><span> }
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#75715e"># Advertise homeserver base + RTC focus (MSC4143) via .well-known
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">location</span> = <span style="color:#e6db74">/.well-known/matrix/client</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">default_type</span> <span style="color:#e6db74">application/json</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Access-Control-Allow-Origin</span> <span style="color:#e6db74">"*"</span> <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">return</span> <span style="color:#ae81ff">200</span> <span style="color:#e6db74">'</span>{<span style="color:#f92672">"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com"}]}'</span>;
|
||||
</span></span><span style="display:flex;"><span> }
|
||||
</span></span><span style="display:flex;"><span>}
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Federation on 8448
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#f92672">server</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">listen</span> <span style="color:#ae81ff">8448</span> <span style="color:#e6db74">ssl</span> <span style="color:#e6db74">http2</span>; <span style="color:#f92672">listen</span> <span style="color:#e6db74">[::]:8448</span> <span style="color:#e6db74">ssl</span> <span style="color:#e6db74">http2</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">server_name</span> <span style="color:#e6db74">matrix.example.com</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate</span> <span style="color:#e6db74">/etc/letsencrypt/live/matrix.example.com/fullchain.pem</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate_key</span> <span style="color:#e6db74">/etc/letsencrypt/live/matrix.example.com/privkey.pem</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">location</span> <span style="color:#e6db74">/</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://127.0.0.1:8008</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-For</span> $remote_addr;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-Proto</span> $scheme;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Host</span> $host;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">client_max_body_size</span> <span style="color:#e6db74">50M</span>;
|
||||
</span></span><span style="display:flex;"><span> }
|
||||
</span></span><span style="display:flex;"><span>}
|
||||
</span></span></code></pre></div><p>Reload and sanity check:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo nginx -t <span style="color:#f92672">&&</span> sudo systemctl reload nginx
|
||||
</span></span><span style="display:flex;"><span>curl -s https://matrix.example.com/.well-known/matrix/client | jq
|
||||
</span></span></code></pre></div><p>Also check Synapse supports RTC signaling (MSC4140) so clients actually use it:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -s https://matrix.example.com/_matrix/client/versions | jq <span style="color:#e6db74">'.unstable_features."org.matrix.msc4140"'</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># expect: true</span>
|
||||
</span></span></code></pre></div><hr>
|
||||
<h2 id="element-call--livekit">Element Call + LiveKit<a hidden class="anchor" aria-hidden="true" href="#element-call--livekit">#</a></h2>
|
||||
<p>I’ll run <strong>LiveKit</strong> and the small <strong>JWT helper</strong> (Element’s <code>elementcall_jwt</code>) in Docker. LiveKit handles media; the JWT helper mints access tokens for WebSocket connects.</p>
|
||||
<h3 id="1-livekit-config-etclivekityaml-inside-container">1) LiveKit config (<code>/etc/livekit.yaml</code> inside container)<a hidden class="anchor" aria-hidden="true" href="#1-livekit-config-etclivekityaml-inside-container">#</a></h3>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">port</span>: <span style="color:#ae81ff">7880</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">bind_addresses</span>: [<span style="color:#e6db74">"0.0.0.0"</span>]
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">rtc</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">tcp_port</span>: <span style="color:#ae81ff">7881</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">port_range_start</span>: <span style="color:#ae81ff">50100</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">port_range_end</span>: <span style="color:#ae81ff">50200</span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">use_external_ip</span>: <span style="color:#66d9ef">true</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">logging</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">level</span>: <span style="color:#ae81ff">info</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">turn</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">enabled</span>: <span style="color:#66d9ef">false</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#f92672">keys</span>:
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">lk_prod_1</span>: <span style="color:#e6db74">"REPLACE_WITH_A_64_CHAR_RANDOM_SECRET___________________________________"</span>
|
||||
</span></span></code></pre></div><blockquote>
|
||||
<p><strong>Important:</strong> the secret must be <strong>>= 32 chars</strong>. The default <code>devkey</code> will trigger <code>secret is too short</code> warnings and won’t work with the JWT helper.</p></blockquote>
|
||||
<h3 id="2-elementcall_jwt-env">2) elementcall_jwt env<a hidden class="anchor" aria-hidden="true" href="#2-elementcall_jwt-env">#</a></h3>
|
||||
<p>Run it with:</p>
|
||||
<ul>
|
||||
<li><code>LIVEKIT_URL=wss://rtc.example.com</code> <em>(root WS URL, <strong>no</strong> <code>/livekit/sfu</code> path)</em></li>
|
||||
<li><code>LIVEKIT_KEY=lk_prod_1</code></li>
|
||||
<li><code>LIVEKIT_SECRET=<the long secret above></code></li>
|
||||
<li><code>LIVEKIT_JWT_PORT=8080</code> (internal HTTP port the proxy will hit)</li>
|
||||
<li>Optionally: <code>LIVEKIT_FULL_ACCESS_HOMESERVERS=*</code> during setup</li>
|
||||
</ul>
|
||||
<p>Check logs on start; it prints the LIVEKIT_URL it will advertise.</p>
|
||||
<h3 id="3-nginx-host-for-rtcexamplecom">3) Nginx (host) for <code>rtc.example.com</code><a hidden class="anchor" aria-hidden="true" href="#3-nginx-host-for-rtcexamplecom">#</a></h3>
|
||||
<p>Create <code>/etc/nginx/conf.d/rtc.conf</code>:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">server</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">listen</span> <span style="color:#ae81ff">443</span> <span style="color:#e6db74">ssl</span>; <span style="color:#f92672">listen</span> <span style="color:#e6db74">[::]:443</span> <span style="color:#e6db74">ssl</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">server_name</span> <span style="color:#e6db74">rtc.example.com</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate</span> <span style="color:#e6db74">/etc/letsencrypt/live/rtc.example.com/fullchain.pem</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">ssl_certificate_key</span> <span style="color:#e6db74">/etc/letsencrypt/live/rtc.example.com/privkey.pem</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">access_log</span> <span style="color:#e6db74">/var/log/nginx/rtc.access.log</span> <span style="color:#e6db74">combined</span>;
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#75715e"># 3a) JWT endpoint with clean CORS (avoid duplicate ACAO)
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">location</span> = <span style="color:#e6db74">/sfu/get</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_hide_header</span> <span style="color:#e6db74">Access-Control-Allow-Origin</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Access-Control-Allow-Origin</span> $http_origin <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Vary</span> <span style="color:#e6db74">"Origin"</span> <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Access-Control-Allow-Methods</span> <span style="color:#e6db74">"POST,</span> <span style="color:#e6db74">OPTIONS"</span> <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">add_header</span> <span style="color:#e6db74">Access-Control-Allow-Headers</span> <span style="color:#e6db74">"Accept,</span> <span style="color:#e6db74">Content-Type,</span> <span style="color:#e6db74">Content-Length,</span> <span style="color:#e6db74">Accept-Encoding,</span> <span style="color:#e6db74">X-CSRF-Token,</span> <span style="color:#e6db74">Authorization"</span> <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">if</span> <span style="color:#e6db74">(</span>$request_method = <span style="color:#e6db74">OPTIONS)</span> { <span style="color:#f92672">return</span> <span style="color:#ae81ff">204</span>; }
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Host</span> $host;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-Proto</span> $scheme;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://127.0.0.1:8070/sfu/get</span>; <span style="color:#75715e"># elementcall_jwt
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> }
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#75715e"># 3b) LiveKit WS & HTTP (catch-all)
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">location</span> <span style="color:#e6db74">/</span> {
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_http_version</span> <span style="color:#ae81ff">1</span><span style="color:#e6db74">.1</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Upgrade</span> $http_upgrade;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Connection</span> <span style="color:#e6db74">"upgrade"</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Sec-WebSocket-Protocol</span> $http_sec_websocket_protocol; <span style="color:#75715e"># "livekit"
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Origin</span> $http_origin;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">Host</span> $host;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_set_header</span> <span style="color:#e6db74">X-Forwarded-Proto</span> $scheme;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_buffering</span> <span style="color:#66d9ef">off</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_read_timeout</span> <span style="color:#e6db74">3600s</span>;
|
||||
</span></span><span style="display:flex;"><span> <span style="color:#f92672">proxy_pass</span> <span style="color:#e6db74">http://127.0.0.1:7880</span>; <span style="color:#75715e"># livekit
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> }
|
||||
</span></span><span style="display:flex;"><span>}
|
||||
</span></span></code></pre></div><p>Reload and basic checks:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo nginx -t <span style="color:#f92672">&&</span> sudo systemctl reload nginx
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># JWT preflight (should return a single ACAO header)</span>
|
||||
</span></span><span style="display:flex;"><span>curl -si -X OPTIONS https://rtc.example.com/sfu/get <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Origin: https://app.element.io'</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Access-Control-Request-Method: POST'</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Access-Control-Request-Headers: authorization, content-type'</span> | sed -n <span style="color:#e6db74">'1,30p'</span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Expected: one Access-Control-Allow-Origin and 200/204</span>
|
||||
</span></span></code></pre></div><blockquote>
|
||||
<p><strong>Why I did this:</strong> I initially had <strong>two</strong> <code>Access-Control-Allow-Origin</code> headers (one added by the upstream, one by Nginx). Browsers reject that with “Access-Control-Allow-Origin cannot contain more than one origin”, so the JWT response never reached the client. Fixing CORS fixed everything.</p></blockquote>
|
||||
<hr>
|
||||
<h2 id="the-waiting-for-media-debugging-story-how-i-found-it">The “waiting for media” debugging story (how I found it)<a hidden class="anchor" aria-hidden="true" href="#the-waiting-for-media-debugging-story-how-i-found-it">#</a></h2>
|
||||
<p>Symptom: Element Call created rooms, but calls stayed on <strong>“waiting for media.”</strong></p>
|
||||
<p>What worked:</p>
|
||||
<ul>
|
||||
<li><code>elementcall_jwt</code> could <strong>CreateRoom</strong> in LiveKit (seen in logs).</li>
|
||||
<li>TURN creds endpoint returned time-limited credentials.</li>
|
||||
</ul>
|
||||
<p>What didn’t appear:</p>
|
||||
<ul>
|
||||
<li>No <strong>HTTP 101</strong> lines in <code>rtc.access.log</code> → the <strong>browser never established a WebSocket</strong> to LiveKit.</li>
|
||||
</ul>
|
||||
<h3 id="step-1-prove-the-ws-vhost-works-even-without-auth">Step 1: prove the WS vhost works (even without auth)<a hidden class="anchor" aria-hidden="true" href="#step-1-prove-the-ws-vhost-works-even-without-auth">#</a></h3>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -v --http1.1 <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Connection: Upgrade'</span> -H <span style="color:#e6db74">'Upgrade: websocket'</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> -H <span style="color:#e6db74">'Sec-WebSocket-Version: 13'</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> https://rtc.example.com/rtc -o /dev/null
|
||||
</span></span></code></pre></div><p>Result: I got a <code>401</code> (expected), but importantly I saw a log line in <code>rtc.access.log</code>. So <strong>Nginx WS proxying was fine</strong>.</p>
|
||||
<h3 id="step-2-check-the-browser-console">Step 2: check the browser console<a hidden class="anchor" aria-hidden="true" href="#step-2-check-the-browser-console">#</a></h3>
|
||||
<p>The smoking gun in DevTools:</p>
|
||||
|
||||
<div class="mermaid">Access-Control-Allow-Origin cannot contain more than one origin.
|
||||
Fetch API cannot load https://rtc.example.com/sfu/get due to access control checks.</div>
|
||||
|
||||
<p>The browser refused the JWT call due to duplicated <strong>ACAO</strong> headers, so no token → no WebSocket connect.</p>
|
||||
<p><strong>Fix:</strong> in Nginx I added:</p>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#66d9ef">proxy_hide_header</span> <span style="color:#e6db74">Access-Control-Allow-Origin</span>;
|
||||
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">add_header</span> <span style="color:#e6db74">Access-Control-Allow-Origin</span> $http_origin <span style="color:#e6db74">always</span>;
|
||||
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">add_header</span> <span style="color:#e6db74">Vary</span> <span style="color:#e6db74">"Origin"</span> <span style="color:#e6db74">always</span>;
|
||||
</span></span></code></pre></div><p>…and ensured no other <code>add_header</code> created duplicates. After that, <code>/sfu/get</code> succeeded and the WebSocket to <code>wss://rtc.example.com</code> immediately followed (I saw <strong>HTTP 101</strong> in the logs).</p>
|
||||
<h3 id="step-3-confirm-livekit-side">Step 3: confirm LiveKit side<a hidden class="anchor" aria-hidden="true" href="#step-3-confirm-livekit-side">#</a></h3>
|
||||
<p>Once the WS was up, LiveKit logs showed participants joining (not just <code>RoomService.CreateRoom</code>), and calls were established.</p>
|
||||
<hr>
|
||||
<h2 id="useful-verification-commands">Useful verification commands<a hidden class="anchor" aria-hidden="true" href="#useful-verification-commands">#</a></h2>
|
||||
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># Synapse features (expect MSC4140 true)</span>
|
||||
</span></span><span style="display:flex;"><span>curl -s https://matrix.example.com/_matrix/client/versions | jq <span style="color:#e6db74">'.unstable_features."org.matrix.msc4140"'</span>
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Well-known with RTC focus</span>
|
||||
</span></span><span style="display:flex;"><span>curl -s https://matrix.example.com/.well-known/matrix/client | jq
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># TURN creds (with your access token)</span>
|
||||
</span></span><span style="display:flex;"><span>curl -s -H <span style="color:#e6db74">"Authorization: Bearer </span>$TOKEN<span style="color:#e6db74">"</span> <span style="color:#ae81ff">\
|
||||
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> https://matrix.example.com/_matrix/client/v3/voip/turnServer | jq
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># JWT health</span>
|
||||
</span></span><span style="display:flex;"><span>curl -si -X POST https://rtc.example.com/sfu/get
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># LiveKit simple HTTP probe</span>
|
||||
</span></span><span style="display:flex;"><span>curl -si https://rtc.example.com | head
|
||||
</span></span><span style="display:flex;"><span>
|
||||
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Nginx logs (look for 101 Switching Protocols when a call starts)</span>
|
||||
</span></span><span style="display:flex;"><span>sudo tail -f /var/log/nginx/rtc.access.log | grep <span style="color:#e6db74">' 101 '</span>
|
||||
</span></span></code></pre></div><hr>
|
||||
<h2 id="common-pitfalls-i-hit-these-so-you-dont-have-to">Common pitfalls (I hit these so you don’t have to)<a hidden class="anchor" aria-hidden="true" href="#common-pitfalls-i-hit-these-so-you-dont-have-to">#</a></h2>
|
||||
<ul>
|
||||
<li><strong>Host Nginx vs Docker Nginx:</strong> If you already run Nginx on the host, don’t also bind 80/443/8448 in a Docker Nginx — you’ll get <code>bind() ... already in use</code> and restart loops. Use <strong>host</strong> Nginx to reverse proxy to containers.</li>
|
||||
<li><strong>Nginx <code>http2</code> directive:</strong> Old Nginx may not support the <code>http2</code> directive on <code>listen</code>. Use <code>listen 443 ssl;</code> (and add <code>http2</code> if your version supports it).</li>
|
||||
<li><strong>Certificate name mismatch:</strong> Make sure <code>rtc.example.com</code>’s vhost uses a certificate <strong>for that exact hostname</strong> (initially I had the <code>matrix.*</code> cert on <code>rtc.*</code> and curl complained).</li>
|
||||
<li><strong>Postgres collation:</strong> Either initialize the cluster with <code>C</code> or use <code>allow_unsafe_locale: true</code> in Synapse DB config to get running quickly.</li>
|
||||
<li><strong>CORS duplication on <code>/sfu/get</code>:</strong> Only ONE <code>Access-Control-Allow-Origin</code> header. If the upstream adds it too, use <code>proxy_hide_header Access-Control-Allow-Origin;</code> on the Nginx location.</li>
|
||||
<li><strong>Cloudflare:</strong> Use <strong>DNS-only</strong> for <code>rtc.*</code>. Proxies can interfere with WS and UDP paths.</li>
|
||||
<li><strong>Firewall:</strong> Open the LiveKit UDP range and TURN ports on both v4 and v6.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2 id="final-checklist-print-me">Final checklist (print me)<a hidden class="anchor" aria-hidden="true" href="#final-checklist-print-me">#</a></h2>
|
||||
<ul>
|
||||
<li><input disabled="" type="checkbox"> <code>https://matrix.example.com/.well-known/matrix/client</code> returns <strong>both</strong> <code>m.homeserver.base_url</code> and <code>org.matrix.msc4143.rtc_foci</code> pointing to <code>https://rtc.example.com</code>.</li>
|
||||
<li><input disabled="" type="checkbox"> <code>/_matrix/client/versions</code> shows <code>"org.matrix.msc4140": true</code>.</li>
|
||||
<li><input disabled="" type="checkbox"> <code>/sfu/get</code> <strong>preflight</strong> returns <strong>one</strong> <code>Access-Control-Allow-Origin</code> and <strong>200/204</strong>.</li>
|
||||
<li><input disabled="" type="checkbox"> Starting a call creates <strong>HTTP 101</strong> entries to <code>wss://rtc.example.com</code> in <code>rtc.access.log</code>.</li>
|
||||
<li><input disabled="" type="checkbox"> LiveKit logs show <strong>participants joining</strong> (not just CreateRoom).</li>
|
||||
<li><input disabled="" type="checkbox"> <code>/voip/turnServer</code> returns time-limited TURN credentials.</li>
|
||||
<li><input disabled="" type="checkbox"> Cloudflare set to <strong>DNS-only</strong> for <code>rtc.*</code>. UFW allows 7881/tcp and your LiveKit UDP range.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h3 id="credits--tooling">Credits & tooling<a hidden class="anchor" aria-hidden="true" href="#credits--tooling">#</a></h3>
|
||||
<ul>
|
||||
<li>Matrix Synapse, coTURN, LiveKit, Element Call.</li>
|
||||
<li><code>curl</code>, <code>jq</code>, <code>docker compose logs</code>, Nginx access logs. These are your best friends.</li>
|
||||
<li>The debugging breakthrough was catching CORS errors in the browser console and looking for <strong>HTTP 101</strong> in Nginx logs.</li>
|
||||
</ul>
|
||||
<p>Happy calling! 🎉</p>
|
||||
<hr>
|
||||
<div class="signature-block" style="margin-top:1rem">
|
||||
<p><strong>Downloads:</strong>
|
||||
<a href="/sources/posts/matrix_setup.md">Markdown</a> ·
|
||||
<a href="/sources/posts/matrix_setup.md.asc">Signature (.asc)</a>
|
||||
</p><details class="signature">
|
||||
<summary>View OpenPGP signature</summary>
|
||||
<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuQACgkQtYgoUOBM
|
||||
jSpglxAArFNgxheR8m2r6QvtoEuHJwI51WQteu+0un0FiIfHKtFPcXd2HiVqzODu
|
||||
YZvIGCAecMURQMRvrgB8cO3ebY8Yi9EECnC1sHsluE05GOuro5Htja7TbsGxwTc3
|
||||
PIe+zju7lIhTHonqLogUyyunhWxOfg1RCaSjp3n6k/r2iEamgKu6Cihrv54wstGa
|
||||
SVJbwubie1D9TPcXU3ynC+ynNBcmVevFl7g/X7Ie8Pw0SP1dJF+we5iVqUrZgPO2
|
||||
AudHlWRm13j7Xifv/JxqymkZV1XiShIY8Mb0Ju8m5+HjkoIaZDtSfFyt+AwPdHDQ
|
||||
m3sMXA7yZUvy+pXtziwrOnHFAez22goAr9Ar9KcwGQgRvyxKuuKIuTq+yxtCuXBF
|
||||
fPWo5pL0rMtIfwRyaiiX9bwV+WbBXNhghTPnaxuQ3CWkLdiwaycI7xPDAg8FzFAR
|
||||
7yoN0vqhKSvZlAL1OQS+4yRcXnguq7UY9UF+drG0f0QpC3aht1QgrJ04gvDp2BOk
|
||||
ymmlxCxUWQrFSqDThjv7WFCclamKTimCODKWvIG6sjQcJuLCg9CXRl+ZMvwobQqH
|
||||
Tv8cm8PMimqJppUodB3Ig5zP3ZkVcK8uFm5XqoUnasqDVLLJaRcCu+Qt4h9gZ2w6
|
||||
Q3Q6K/zPZcKEIrwJfczWotSgG0g8dnuMUUYALWTbRrGjN0mgCss=
|
||||
=yHZZ
|
||||
-----END PGP SIGNATURE-----
|
||||
</pre>
|
||||
</details><p><em>Verify locally:</em></p>
|
||||
<pre style="font-size:0.85em; overflow-x:auto; padding:0.75rem;">torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md
|
||||
torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/matrix_setup.md.asc
|
||||
gpg --verify matrix_setup.md.asc matrix_setup.md
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="post-footer">
|
||||
<ul class="post-tags">
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/matrix/">Matrix</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/synapse/">Synapse</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/element-call/">Element-Call</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/livekit/">Livekit</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/coturn/">Coturn</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/nginx/">Nginx</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/webrtc/">Webrtc</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/self-hosting/">Self-Hosting</a></li>
|
||||
<li><a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/tags/debugging/">Debugging</a></li>
|
||||
</ul>
|
||||
<nav class="paginav">
|
||||
<a class="prev" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hedge_doc/">
|
||||
<span class="title">« Prev</span>
|
||||
<br>
|
||||
<span>Self-Hosting HedgeDoc with Docker + Nginx + Let's Encrypt</span>
|
||||
</a>
|
||||
<a class="next" href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/posts/hello-world/">
|
||||
<span class="title">Next »</span>
|
||||
<br>
|
||||
<span>Hello World</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
</footer><div class="comments-switch" style="display:flex;gap:.5rem;margin:.5rem 0 1rem;">
|
||||
<button id="btn-isso" type="button" aria-pressed="true">Anonymous (Isso)</button>
|
||||
<button id="btn-giscus" type="button" aria-pressed="false">GitHub (Giscus)</button>
|
||||
<a href="https://github.com/AlipourIm/blog-comments/discussions/categories/comments" target="_blank" rel="noopener">Open on GitHub ↗</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="panel-giscus" style="display:none">
|
||||
<section id="giscus-thread"></section>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="panel-isso">
|
||||
<section id="isso-thread"
|
||||
data-isso-id="/posts/matrix_setup/"
|
||||
data-title="Self-hosting Matrix + Element Call with LiveKit: from zero to working (and the [not so!!] fun debugging along the way)">
|
||||
<noscript>Javascript needs to be activated to view comments.</noscript>
|
||||
</section>
|
||||
<script
|
||||
src="/isso/js/embed.min.js"
|
||||
data-isso="/isso/"
|
||||
data-isso-css="false"
|
||||
data-isso-lang="en"
|
||||
data-isso-max-comments-nested="5"
|
||||
data-isso-sorting="newest"
|
||||
async>
|
||||
</script>
|
||||
<small class="isso-powered" style="display:block;margin:.5rem 0;color:var(--secondary,#888)">
|
||||
Comments powered by <a href="https://isso-comments.de" target="_blank" rel="noopener">Isso</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var panelIsso = document.getElementById('panel-isso');
|
||||
var panelGisc = document.getElementById('panel-giscus');
|
||||
var issoBtn = document.getElementById('btn-isso');
|
||||
var giscBtn = document.getElementById('btn-giscus');
|
||||
var gLoaded = false;
|
||||
|
||||
function isDark() {
|
||||
var pref = localStorage.getItem('pref-theme');
|
||||
if (pref === 'dark') return true;
|
||||
if (pref === 'light') return false;
|
||||
|
||||
var html = document.documentElement, body = document.body;
|
||||
if (
|
||||
(html.classList && html.classList.contains('dark')) ||
|
||||
(body.classList && body.classList.contains('dark')) ||
|
||||
html.getAttribute('data-theme') === 'dark' ||
|
||||
body.getAttribute('data-theme') === 'dark' ||
|
||||
(html.classList && html.classList.contains('theme-dark')) ||
|
||||
(body.classList && body.classList.contains('theme-dark'))
|
||||
) return true;
|
||||
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
function gTheme(){ return isDark() ? 'dark_dimmed' : 'noborder_light'; }
|
||||
|
||||
function loadGiscus() {
|
||||
if (gLoaded) return;
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://giscus.app/client.js';
|
||||
s.async = true;
|
||||
s.crossOrigin = 'anonymous';
|
||||
s.setAttribute('data-repo','AlipourIm/blog-comments');
|
||||
s.setAttribute('data-repo-id','R_kgDOQGARyA');
|
||||
s.setAttribute('data-category','Comments');
|
||||
s.setAttribute('data-category-id','DIC_kwDOQGARyM4Cw3x-');
|
||||
s.setAttribute('data-mapping','pathname');
|
||||
s.setAttribute('data-strict','0');
|
||||
s.setAttribute('data-reactions-enabled','1');
|
||||
s.setAttribute('data-emit-metadata','0');
|
||||
s.setAttribute('data-input-position','bottom');
|
||||
s.setAttribute('data-lang','en');
|
||||
s.setAttribute('data-theme', gTheme());
|
||||
document.getElementById('giscus-thread').appendChild(s);
|
||||
|
||||
function reTheme(){
|
||||
var iframe = document.querySelector('iframe.giscus-frame');
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ giscus: { setConfig: { theme: gTheme() } } },
|
||||
'https://giscus.app'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var tbtn = document.getElementById('theme-toggle');
|
||||
if (tbtn) tbtn.addEventListener('click', function(){ setTimeout(reTheme, 0); });
|
||||
|
||||
var mo = new MutationObserver(function(){ reTheme(); });
|
||||
mo.observe(document.documentElement, { attributes:true, attributeFilter:['class','data-theme'] });
|
||||
mo.observe(document.body, { attributes:true, attributeFilter:['class','data-theme'] });
|
||||
|
||||
gLoaded = true;
|
||||
}
|
||||
|
||||
function show(which) {
|
||||
var showIsso = which === 'isso';
|
||||
panelIsso.style.display = showIsso ? 'block' : 'none';
|
||||
panelGisc.style.display = showIsso ? 'none' : 'block';
|
||||
if (issoBtn) issoBtn.setAttribute('aria-pressed', showIsso ? 'true' : 'false');
|
||||
if (giscBtn) giscBtn.setAttribute('aria-pressed', showIsso ? 'false' : 'true');
|
||||
if (!showIsso) loadGiscus();
|
||||
}
|
||||
|
||||
if (issoBtn) issoBtn.addEventListener('click', function(e){ e.preventDefault(); show('isso'); });
|
||||
if (giscBtn) giscBtn.addEventListener('click', function(e){ e.preventDefault(); show('giscus'); });
|
||||
|
||||
|
||||
show('isso');
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>© 2026 <a href="http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/">AlipourIm journeys</a></span> ·
|
||||
|
||||
<span>
|
||||
Powered by
|
||||
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
|
||||
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
|
||||
</span>
|
||||
</footer>
|
||||
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
|
||||
<path d="M12 6H0l6-6z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
<div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
|
||||
<a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
|
||||
class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
</svg>
|
||||
<span>RSS</span>
|
||||
</a>
|
||||
|
||||
<span aria-hidden="true">·</span>
|
||||
|
||||
<span>Visitors this week: <span id="gc_site_visitors_week">—</span></span>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
let menu = document.getElementById('menu')
|
||||
if (menu) {
|
||||
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
|
||||
menu.onscroll = function () {
|
||||
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var id = this.getAttribute("href").substr(1);
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView({
|
||||
behavior: "smooth"
|
||||
});
|
||||
} else {
|
||||
document.querySelector(`[id='${decodeURIComponent(id)}']`).scrollIntoView();
|
||||
}
|
||||
if (id === "top") {
|
||||
history.replaceState(null, null, " ");
|
||||
} else {
|
||||
history.pushState(null, null, `#${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
<script>
|
||||
var mybutton = document.getElementById("top-link");
|
||||
window.onscroll = function () {
|
||||
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
|
||||
mybutton.style.visibility = "visible";
|
||||
mybutton.style.opacity = "1";
|
||||
} else {
|
||||
mybutton.style.visibility = "hidden";
|
||||
mybutton.style.opacity = "0";
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
<script>
|
||||
document.getElementById("theme-toggle").addEventListener("click", () => {
|
||||
if (document.body.className.includes("dark")) {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem("pref-theme", 'light');
|
||||
} else {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem("pref-theme", 'dark');
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
<script>
|
||||
document.querySelectorAll('pre > code').forEach((codeblock) => {
|
||||
const container = codeblock.parentNode.parentNode;
|
||||
|
||||
const copybutton = document.createElement('button');
|
||||
copybutton.classList.add('copy-code');
|
||||
copybutton.innerHTML = 'copy';
|
||||
|
||||
function copyingDone() {
|
||||
copybutton.innerHTML = 'copied!';
|
||||
setTimeout(() => {
|
||||
copybutton.innerHTML = 'copy';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
copybutton.addEventListener('click', (cb) => {
|
||||
if ('clipboard' in navigator) {
|
||||
navigator.clipboard.writeText(codeblock.textContent);
|
||||
copyingDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(codeblock);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
copyingDone();
|
||||
} catch (e) { };
|
||||
selection.removeRange(range);
|
||||
});
|
||||
|
||||
if (container.classList.contains("highlight")) {
|
||||
container.appendChild(copybutton);
|
||||
} else if (container.parentNode.firstChild == container) {
|
||||
|
||||
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName == "TABLE") {
|
||||
|
||||
codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(copybutton);
|
||||
} else {
|
||||
|
||||
codeblock.parentNode.appendChild(copybutton);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue