// Upgrader2 45: server Brány Coralu. // SPDX-License-Identifier: AGPL-3.0-or-later // // Dělá dvě věci. Rozdává soubory brány (úvodní stránka, service worker, // ovladač Scramjetu a transport libcurl) a je relé Wisp. Relé jen přeposílá // spojení prohlížeče: TLS skládá libcurl přímo v prohlížeči, takže server vidí // jen šifrovaná data. Pustí jen přihlášené uživatele webů Coralu; token // podepisuje WordPress klíčem Ed25519 a server si jeho veřejnou půlku stáhne // z /interpreter/brana-klic.json. import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import zlib from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { server as wisp, logging } from '@mercuryworkshop/wisp-js/server'; import { ZAPLATY, zaplatuj } from './zaplaty.mjs'; const KOREN = path.dirname(fileURLToPath(import.meta.url)); const NM = path.join(KOREN, 'node_modules', '@mercuryworkshop'); const VEREJNE = path.join(KOREN, 'public'); const PORT = Number(process.env.PORT || 2092); const NASLOUCHA = process.env.NASLOUCHA || '127.0.0.1'; const ADRESA = (process.env.BRANA_ADRESA || `http://localhost:${PORT}`).replace(/\/+$/, ''); const WEBY = (process.env.BRANA_WEBY || 'https://wp.coralvanderpink.com') .split(',').map((w) => w.trim().replace(/\/+$/, '')).filter(Boolean); const MAX_SPOJENI = Number(process.env.BRANA_MAX_SPOJENI || 12); // Relé smí jen na veřejné weby a jen na porty 80 a 443: žádná vnitřní síť // serveru, žádné přímé IP adresy, žádné UDP. logging.set_level(logging.WARN); Object.assign(wisp.options, { allow_udp_streams: false, allow_tcp_streams: true, port_whitelist: [80, 443], allow_direct_ip: false, allow_private_ips: false, allow_loopback_ips: false, // Limit na jeden web má wisp-js 0.5.0 rozbitý (prochází objekt jako pole // a shodí každé nové spojení), drží se jen celkový limit na kartu. stream_limit_per_host: -1, stream_limit_total: 160, parse_real_ip: false, }); // --------------------------------------------------------------------------- // Soubory // --------------------------------------------------------------------------- const TYPY = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json; charset=utf-8', '.wasm': 'application/wasm', '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css; charset=utf-8', '.yml': 'text/plain; charset=utf-8', '.conf': 'text/plain; charset=utf-8', '.txt': 'text/plain; charset=utf-8', }; const KOMPRIMOVAT = /^(text\/|application\/(json|manifest\+json|wasm)|image\/svg)/; const KRATCE = 'no-cache'; const DLOUHO = 'public, max-age=600, stale-while-revalidate=604800'; const soubory = new Map(); function typSouboru(jmeno) { return TYPY[path.extname(jmeno).toLowerCase()] || 'application/octet-stream'; } function pridej(adresa, data, typ, cache) { const telo = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8'); const zaznam = { telo, typ, cache, etag: '"' + crypto.createHash('sha1').update(telo).digest('base64url').slice(0, 22) + '"', gzip: null, br: null, }; if (KOMPRIMOVAT.test(typ) && telo.length > 1024) { zaznam.gzip = zlib.gzipSync(telo, { level: 9 }); // Brotli nejvyšší kvality trvá u velkých souborů vteřiny, proto na pozadí; // do té doby jde gzip. zlib.brotliCompress(telo, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: telo.length, }, }, (chyba, br) => { if (!chyba) zaznam.br = br; }); } soubory.set(adresa, zaznam); } function nastaveniProStranku() { return JSON.stringify({ weby: WEBY, adresa: ADRESA }).replace(/ String(s).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[z])); const odkaz = (href, text) => `
  • ${html(text)}
  • `; const serverove = ZDROJE_SERVERU.filter((j) => fs.existsSync(path.join(KOREN, j))) .map((j) => odkaz('/zdroj/' + j, j)).join(''); const prohlizec = verejne.filter((a) => /\.(js|html|webmanifest)$/.test(a)) .map((a) => odkaz(a, a.slice(1))).join(''); const balicky = BALICKY.map(([jmeno, repo, licence]) => `
  • ${html(jmeno)} ${html(verzeBalicku(jmeno))}, ${html(licence)}
  • `).join(''); const zaplaty = ZAPLATY.map((z) => `
  • ${html(z.soubor)}: ${html(z.popis)}
  • `).join(''); return ` Zdrojový kód Brány Coralu

    Zdrojový kód Brány Coralu

    Brána stojí na svobodném softwaru pod licencí GNU AGPL-3.0, a proto je celý její zdrojový kód tady. Source code of the Coral gateway, licensed under the GNU AGPL-3.0.

    Server

    Prohlížeč / Browser

    Převzaté balíčky / Upstream packages

    Úpravy převzatého kódu / Changes to upstream code

    Server je dělá při startu nad soubory z balíčků, přesný text je v zaplaty.mjs.

    `; } function nactiSoubory() { const verejne = projdi(VEREJNE, '', []); for (const [adresa, plna] of verejne) { const typ = typSouboru(plna); let data = fs.readFileSync(plna); if (adresa === '/index.html') { data = data.toString('utf8').replace('/*BRANA_NASTAVENI*/null', nastaveniProStranku()); } pridej(adresa, data, typ, /^image\//.test(typ) ? DLOUHO : KRATCE); } for (const [adresa, soubor] of Object.entries(VENDOR)) { let data = fs.readFileSync(path.join(NM, soubor)); if (ZAPLATY.some((z) => z.soubor === soubor)) data = zaplatuj(soubor, data.toString('utf8')); pridej(adresa, data, typSouboru(soubor), DLOUHO); } for (const jmeno of ZDROJE_SERVERU) { const plna = path.join(KOREN, jmeno); if (fs.existsSync(plna)) pridej('/zdroj/' + jmeno, fs.readFileSync(plna), 'text/plain; charset=utf-8', KRATCE); } pridej('/zdroj/index.html', stranaZdroju(verejne.map(([a]) => a)), TYPY['.html'], KRATCE); if (soubory.has('/ikony/icon-192-v3.png')) soubory.set('/favicon.ico', soubory.get('/ikony/icon-192-v3.png')); } function posli(req, res, zaznam) { const hlavicky = { 'Content-Type': zaznam.typ, 'Cache-Control': zaznam.cache, ETag: zaznam.etag, Vary: 'Accept-Encoding', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'SAMEORIGIN', 'Referrer-Policy': 'no-referrer', }; if (req.headers['if-none-match'] === zaznam.etag) { res.writeHead(304, hlavicky); res.end(); return; } const prijme = String(req.headers['accept-encoding'] || ''); let telo = zaznam.telo; if (zaznam.br && /\bbr\b/.test(prijme)) { telo = zaznam.br; hlavicky['Content-Encoding'] = 'br'; } else if (zaznam.gzip && /\bgzip\b/.test(prijme)) { telo = zaznam.gzip; hlavicky['Content-Encoding'] = 'gzip'; } hlavicky['Content-Length'] = telo.length; res.writeHead(200, hlavicky); res.end(req.method === 'HEAD' ? undefined : telo); } // --------------------------------------------------------------------------- // Přihlášení k relé // --------------------------------------------------------------------------- const klice = new Map(); async function klicWebu(web, znovu) { const ulozeny = klice.get(web); const ted = Date.now(); if (ulozeny) { // Nový klíč se hledá nejvýš jednou za minutu, ať podvržený token nezahltí web. if (ted - ulozeny.pokus < 60e3 || (!znovu && ted - ulozeny.kdy < 3600e3)) return ulozeny.klic; ulozeny.pokus = ted; } try { const odpoved = await fetch(web + '/interpreter/brana-klic.json', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(8000), }); if (!odpoved.ok) throw new Error(`HTTP ${odpoved.status}`); const data = await odpoved.json(); const klic = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: String(data.klic || '') }, format: 'jwk' }); klice.set(web, { klic, kdy: ted, pokus: ted }); return klic; } catch (chyba) { // Výpadek webu nikoho neodpojí: platí dál poslední známý klíč. if (!ulozeny) throw new Error(`klíč webu ${web}: ${chyba.message || chyba}`); console.warn(`brána: klíč webu ${web} se nepodařilo obnovit (${chyba.message || chyba}), platí poslední známý`); return ulozeny.klic; } } async function overToken(token) { const [obsah, podpis] = String(token || '').split('.'); if (!obsah || !podpis) return null; let data; try { data = JSON.parse(Buffer.from(obsah, 'base64url').toString('utf8')); } catch { return null; } if (!data || typeof data !== 'object') return null; const web = String(data.w || '').replace(/\/+$/, ''); if (!WEBY.includes(web)) return null; if (!(Number(data.e) > Date.now() / 1000)) return null; const bajty = Buffer.from(podpis, 'base64url'); for (const znovu of [false, true]) { const klic = await klicWebu(web, znovu); if (crypto.verify(null, Buffer.from(obsah, 'utf8'), klic, bajty)) { return { web, uzivatel: Number(data.u) || 0 }; } } return null; } const spojeni = new Map(); function odmitni(socket, kod, text) { try { socket.end(`HTTP/1.1 ${kod} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { socket.destroy(); } } // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- nactiSoubory(); // Kontrola před nasazením: soubory i záplaty převzatého kódu se načtou // a server skončí, aniž by poslouchal. if (process.argv.includes('--kontrola')) { console.log(`kontrola v pořádku: ${soubory.size} souborů, weby ${WEBY.join(', ')}`); process.exit(0); } const server = http.createServer((req, res) => { if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); res.end(); return; } let cesta; try { cesta = decodeURIComponent(new URL(req.url, 'http://brana').pathname); } catch { res.writeHead(400); res.end(); return; } if (cesta === '/zdravi') { let celkem = 0; for (const n of spojeni.values()) celkem += n; res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ ok: true, spojeni: celkem, uzivatelu: spojeni.size })); return; } // Stránka z brány bez service workeru (první návštěva, sdílený odkaz): // zaváděcí stránka worker zaregistruje a stránku načte znovu. if (cesta.startsWith('/~/sj/')) cesta = '/zavadeci.html'; if (cesta.endsWith('/')) cesta += 'index.html'; const zaznam = soubory.get(cesta); if (!zaznam) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'X-Content-Type-Options': 'nosniff' }); res.end('Nenalezeno / Not found'); return; } posli(req, res, zaznam); }); server.on('upgrade', (req, socket, head) => { socket.on('error', () => socket.destroy()); let url; try { url = new URL(req.url, 'http://brana'); } catch { socket.destroy(); return; } // /wisp/ bez klíče nebo /wisp// (klient wispu chce adresu končící lomítkem). const cesta = /^\/wisp\/(?:([A-Za-z0-9_.-]+)\/)?$/.exec(url.pathname); if (!cesta) { odmitni(socket, 404, 'Not Found'); return; } if (req.headers.origin !== ADRESA) { odmitni(socket, 403, 'Forbidden'); return; } overToken(cesta[1] || '').then((kdo) => { if (!kdo) { odmitni(socket, 401, 'Unauthorized'); return; } const klic = kdo.web + '#' + kdo.uzivatel; const pocet = spojeni.get(klic) || 0; if (pocet >= MAX_SPOJENI) { odmitni(socket, 429, 'Too Many Requests'); return; } spojeni.set(klic, pocet + 1); socket.once('close', () => { const zbyva = (spojeni.get(klic) || 1) - 1; if (zbyva > 0) spojeni.set(klic, zbyva); else spojeni.delete(klic); }); // Wisp podle cesty rozlišuje svůj protokol a wsproxy, klíč v cestě by ho zmátl. req.url = '/wisp/'; wisp.routeRequest(req, socket, head); }).catch((chyba) => { console.warn('brána: ověření tokenu selhalo:', chyba && chyba.message); odmitni(socket, 503, 'Service Unavailable'); }); }); process.on('unhandledRejection', (chyba) => console.warn('brána:', chyba && chyba.message ? chyba.message : chyba)); server.listen(PORT, NASLOUCHA, () => { console.log(`Brána Coralu na ${NASLOUCHA}:${PORT}, adresa ${ADRESA}, weby ${WEBY.join(', ')}`); });