- amerc-api /api/showcase: register subdomain + GoDaddy per-user wildcard DNS automation - server/showcase-edge: ngrok-style HTTP+WS reverse tunnel (ws), broker-token verified - nginx: apex /showcase/ws (broker) + wildcard vhost for *.<user>.amerc.ai showcases - Mansion page: services hub, Showcase registration + kebab delivery-center instructions - verified on the public internet (http://demo1.artheru.amerc.ai/ via local-port tunnel) - reference broker sent to kebab for their delivery center + ws/http demo
79 lines
5.0 KiB
JavaScript
79 lines
5.0 KiB
JavaScript
// amerc showcase-edge — reverse proxy (ngrok-style). Inbound HTTP/WS from the internet on
|
|
// <show>.<user>.amerc.ai is tunneled over a broker WS to the user's local port.
|
|
// Node 22 + ws (pure JS). env: PORT, AMERC_SECRET.
|
|
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
|
|
const PORT = Number(process.env.PORT || 5191);
|
|
const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me';
|
|
|
|
const binds = new Map(); // host -> { ws (broker), port }
|
|
const pendingHttp = new Map(); // reqId -> res
|
|
const browserWs = new Map(); // connId -> client ws
|
|
|
|
function verifyEdgeToken(token) {
|
|
if (!token || !token.includes('.')) return null;
|
|
const [body, sig] = token.split('.');
|
|
const expect = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
|
|
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
|
|
try { const d = JSON.parse(Buffer.from(body, 'base64url').toString()); return d.exp < Math.floor(Date.now() / 1000) ? null : d; }
|
|
catch { return null; }
|
|
}
|
|
const hostOf = (req) => String(req.headers.host || '').split(':')[0].toLowerCase();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const host = hostOf(req);
|
|
if (host === 'amerc.ai' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, service: 'showcase-edge', binds: binds.size })); }
|
|
const bind = binds.get(host);
|
|
if (!bind || bind.ws.readyState !== WebSocket.OPEN) {
|
|
res.writeHead(502, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
return res.end(`<!doctype html><meta charset=utf-8><title>amerc showcase</title><body style="font-family:Georgia,serif;background:#0e0a14;color:#e7d4b6;text-align:center;padding:60px"><h1>🏰 amerc showcase</h1><p>No service is bound to <b>${host}</b> yet.</p><p>Open the kebab <b>delivery center (交付中心)</b> and map a local port to this showcase.</p></body>`);
|
|
}
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const reqId = crypto.randomUUID();
|
|
pendingHttp.set(reqId, res);
|
|
try { bind.ws.send(JSON.stringify({ type: 'http_req', reqId, method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('base64') })); }
|
|
catch { pendingHttp.delete(reqId); try { res.writeHead(502); res.end('broker gone'); } catch {} }
|
|
setTimeout(() => { if (pendingHttp.has(reqId)) { pendingHttp.delete(reqId); try { res.writeHead(504); res.end('upstream timeout'); } catch {} } }, 30000);
|
|
});
|
|
});
|
|
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
server.on('upgrade', (req, socket, head) => {
|
|
const url = new URL(req.url, 'http://x'); const host = hostOf(req);
|
|
if (url.pathname === '/showcase/ws' && url.searchParams.get('role') === 'edge') {
|
|
wss.handleUpgrade(req, socket, head, (ws) => onBroker(ws, url));
|
|
} else {
|
|
const bind = binds.get(host);
|
|
if (!bind || bind.ws.readyState !== WebSocket.OPEN) { socket.destroy(); return; }
|
|
wss.handleUpgrade(req, socket, head, (ws) => onBrowserWs(ws, req, bind));
|
|
}
|
|
});
|
|
|
|
function onBroker(ws, url) {
|
|
const tok = verifyEdgeToken(url.searchParams.get('token'));
|
|
if (!tok) { try { ws.close(4001, 'bad token'); } catch {} return; }
|
|
ws.on('message', (data) => {
|
|
let m; try { m = JSON.parse(data); } catch { return; }
|
|
if (m.type === 'bind') { if (m.host === tok.host) { binds.set(m.host, { ws, port: m.port }); ws.send(JSON.stringify({ type: 'bound', host: m.host })); } else ws.send(JSON.stringify({ type: 'error', error: 'token not valid for ' + m.host })); }
|
|
else if (m.type === 'http_res') { const res = pendingHttp.get(m.reqId); if (res) { pendingHttp.delete(m.reqId); try { const h = m.headers || {}; delete h['content-length']; delete h['transfer-encoding']; res.writeHead(m.status || 200, h); res.end(Buffer.from(m.body || '', 'base64')); } catch {} } }
|
|
else if (m.type === 'ws_msg') { const c = browserWs.get(m.connId); if (c && c.readyState === WebSocket.OPEN) c.send(m.binary ? Buffer.from(m.data, 'base64') : m.data); }
|
|
else if (m.type === 'ws_close') { const c = browserWs.get(m.connId); if (c) try { c.close(); } catch {} browserWs.delete(m.connId); }
|
|
});
|
|
ws.on('close', () => { for (const [h, b] of binds) if (b.ws === ws) binds.delete(h); });
|
|
ws.on('error', () => {});
|
|
}
|
|
|
|
function onBrowserWs(ws, req, bind) {
|
|
const connId = crypto.randomUUID(); browserWs.set(connId, ws);
|
|
try { bind.ws.send(JSON.stringify({ type: 'ws_open', connId, url: req.url, headers: req.headers })); } catch {}
|
|
ws.on('message', (data, isBinary) => { try { bind.ws.send(JSON.stringify({ type: 'ws_msg', connId, data: isBinary ? data.toString('base64') : data.toString(), binary: !!isBinary })); } catch {} });
|
|
ws.on('close', () => { try { bind.ws.send(JSON.stringify({ type: 'ws_close', connId })); } catch {} browserWs.delete(connId); });
|
|
ws.on('error', () => {});
|
|
}
|
|
|
|
server.listen(PORT, '127.0.0.1', () => console.log(`showcase-edge on 127.0.0.1:${PORT}`));
|