@yz-social/civildefense.io 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/announce.js +24 -0
- package/docs/YZ-Brief.pdf +0 -0
- package/index.js +2 -0
- package/movie/camera.jpg +0 -0
- package/movie/movie.js +151 -0
- package/movie/script.js +272 -0
- package/movie/test.js +2 -0
- package/nginx/nginx.conf +83 -0
- package/nginx/yz.social +81 -0
- package/package.json +34 -0
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -0
- package/public/about/CivilDefense.mp4 +0 -0
- package/public/about/In case of Nazis, use CivilDefense.io.png +0 -0
- package/public/about/broadcast.png +0 -0
- package/public/about/civil-defense.png +0 -0
- package/public/about/conversation-and-image-high.png +0 -0
- package/public/about/conversation-and-image.png +0 -0
- package/public/about/en.html +157 -0
- package/public/about/es.html +142 -0
- package/public/about/flood-fire-ice-high.png +0 -0
- package/public/about/flood-fire-ice.png +0 -0
- package/public/about/hero-high.png +0 -0
- package/public/about/hero.png +0 -0
- package/public/about/index.html +8 -0
- package/public/about/nazis.png +0 -0
- package/public/about/script.js +69 -0
- package/public/about/streaming-radio-high.png +0 -0
- package/public/about/streaming-radio.png +0 -0
- package/public/about/style.css +306 -0
- package/public/control-safe-rectangle.html +40 -0
- package/public/favicon.ico +0 -0
- package/public/images/Achtung.png +0 -0
- package/public/images/YZ Owl.png +0 -0
- package/public/images/civil-defense-122.png +0 -0
- package/public/images/civil-defense-192.png +0 -0
- package/public/images/civil-defense-240.png +0 -0
- package/public/images/civil-defense-512.png +0 -0
- package/public/images/civil-defense.png +0 -0
- package/public/images/hero-small.png +0 -0
- package/public/images/qr-scan.svg +2 -0
- package/public/images/qr.png +0 -0
- package/public/images/qr.svg +155 -0
- package/public/images/recenter.svg +5 -0
- package/public/images/share.png +0 -0
- package/public/images/share.svg +2 -0
- package/public/index.html +170 -0
- package/public/javascripts/agent.js +324 -0
- package/public/javascripts/display.js +25 -0
- package/public/javascripts/hashtags.js +205 -0
- package/public/javascripts/main.js +394 -0
- package/public/javascripts/map.js +725 -0
- package/public/javascripts/p2pWebNetwork.js +245 -0
- package/public/javascripts/s2.js +77 -0
- package/public/javascripts/scripting.js +112 -0
- package/public/javascripts/service-manager.js +149 -0
- package/public/javascripts/translations.js +139 -0
- package/public/javascripts/versions.js +23 -0
- package/public/manifest.json +25 -0
- package/public/owl.ico +0 -0
- package/public/platformer.html +52 -0
- package/public/robots.txt +6 -0
- package/public/service-worker.js +218 -0
- package/public/stylesheets/style.css +442 -0
- package/routes/index.js +123 -0
- package/server/app.js +116 -0
- package/server/bridge.js +397 -0
- package/server/dirname.js +15 -0
- package/server/getLocation.js +18 -0
- package/server/identity.js +111 -0
- package/server/location.json +12 -0
- package/spec/axonSpec.gratuitousNameChangeForSignal +246 -0
- package/spec/axonSpec.js +280 -0
- package/spec/axonSpec.jsRemoveThePartAfterJS +252 -0
- package/spec/civildefenseSpec.js +61 -0
- package/spec/pubsubSpec.js +128 -0
- package/spec/support/jasmine.mjs +14 -0
package/server/bridge.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
const { BigInt } = globalThis; // for linters
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import NodeTurn from 'node-turn';
|
|
4
|
+
import { KERNEL_VERSION, makeNonce } from '@axona/protocol';
|
|
5
|
+
import { appVersion } from '../public/javascripts/versions.js';
|
|
6
|
+
import { BridgeAxonaNode } from './bridge_axona_node.js'; // FIXME
|
|
7
|
+
|
|
8
|
+
// TODO: Clean up and simplify.
|
|
9
|
+
// TODO: There's some backward compatible cruft that we do not need. Kill it.
|
|
10
|
+
|
|
11
|
+
export async function bridge({server,
|
|
12
|
+
log = console.info, logErr = console.error, logDebug = console.log,
|
|
13
|
+
VERSION = appVersion,
|
|
14
|
+
MIN_PEER_VERSION = KERNEL_VERSION, // FIXME
|
|
15
|
+
HELLO_TIMEOUT_MS = 5e3,
|
|
16
|
+
}) {
|
|
17
|
+
|
|
18
|
+
const CLOSE_UPGRADE_REQUIRED = 4426; // mirrors HTTP 426 "Upgrade Required"
|
|
19
|
+
const externalIp = '99.122.55.96';
|
|
20
|
+
const internalIp = '192.168.1.145';
|
|
21
|
+
const turnServer = new NodeTurn({
|
|
22
|
+
externalIps: [internalIp], //fixme [externalIp],
|
|
23
|
+
//minPort: 51021, maxPort: 61000, // Avoiding conflicts on the AT&T BRG320 Gateway
|
|
24
|
+
authMech: 'none',
|
|
25
|
+
// authMech: 'long-term',
|
|
26
|
+
// realm: 'yz',
|
|
27
|
+
// credentials: {dummy: 'junk'},
|
|
28
|
+
debugLevel: 'debug'//fixme 'info'
|
|
29
|
+
});
|
|
30
|
+
turnServer.start();
|
|
31
|
+
|
|
32
|
+
const TURN_URLS = `turn:localhost:3478?transport=udp`;
|
|
33
|
+
function makeTurnCredential(peerId) {
|
|
34
|
+
//if (!TURN_AUTH_SECRET) return null;
|
|
35
|
+
// const expiry = Math.floor(Date.now() / 1000) + TURN_TTL_SECONDS;
|
|
36
|
+
const username = 'dummy'; //fixme `${expiry}:${peerId}`;
|
|
37
|
+
const credential = 'junk'; //fixme
|
|
38
|
+
// const credential = crypto
|
|
39
|
+
// .createHmac('sha1', TURN_AUTH_SECRET)
|
|
40
|
+
// .update(username)
|
|
41
|
+
// .digest('base64');
|
|
42
|
+
return { urls: TURN_URLS, username, credential/*, ttlSeconds: TURN_TTL_SECONDS*/ };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// FIXME
|
|
46
|
+
function flagDayFloor(version) {
|
|
47
|
+
return { floor: MIN_PEER_VERSION, ns: 'kernel' };
|
|
48
|
+
}
|
|
49
|
+
/** Three-component numeric semver compare; returns true iff a >= b. */
|
|
50
|
+
function gteVersion(a, b) {
|
|
51
|
+
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
52
|
+
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
const ai = pa[i] ?? 0, bi = pb[i] ?? 0;
|
|
55
|
+
if (ai > bi) return true;
|
|
56
|
+
if (ai < bi) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// BigInt-aware JSON: the Axona wire protocol uses BigInt node IDs
|
|
62
|
+
// throughout (path[], queried set, fromId, etc.). Native
|
|
63
|
+
// JSON.stringify throws on BigInts; we use a replacer that emits
|
|
64
|
+
// "<digits>n" suffixed strings, mirrored by an inverse reviver on
|
|
65
|
+
// the receive side. This is a transitional convention while the
|
|
66
|
+
// canonical hex-encoding from the wire spec is rolled out across
|
|
67
|
+
// every field.
|
|
68
|
+
function bigintReplacer(_key, value) {
|
|
69
|
+
if (typeof value === 'bigint') return value.toString() + 'n';
|
|
70
|
+
if (value instanceof Set) return [...value];
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bigintReviver(_key, value) {
|
|
75
|
+
if (typeof value === 'string' && /^-?\d+n$/.test(value)) {
|
|
76
|
+
return BigInt(value.slice(0, -1));
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The bridge runs its own AxonaPeer as a server-class highway node.
|
|
82
|
+
// Its WebSocket transport piggybacks on the existing browser-bridge
|
|
83
|
+
// WebSocket connections; no node-webrtc dependency. See
|
|
84
|
+
// `bridge_axona_node.js` and `ws_transport.js` for the wire shape.
|
|
85
|
+
const bridgeNode = new BridgeAxonaNode({
|
|
86
|
+
sendToConn: (connId, msg) => sendTo(connId, msg),
|
|
87
|
+
isConnOpen: (connId) => connections.has(connId),
|
|
88
|
+
// axona/4 — close a connection with the Upgrade-Required code when its
|
|
89
|
+
// peer can't complete the authenticated handshake (e.g. it speaks the
|
|
90
|
+
// legacy axona/3 hello). This is the clean, proto-level upgrade
|
|
91
|
+
// signal: the peer's kernel prints "[axona] UPGRADE REQUIRED …" on a
|
|
92
|
+
// 4426 close. (The WS-level version gate can't separate v3 from v4
|
|
93
|
+
// because the peer app version is already numerically above any kernel
|
|
94
|
+
// threshold; the proto at the hello layer is the real boundary.)
|
|
95
|
+
closeConn: (connId, reason) => {
|
|
96
|
+
const conn = connections.get(connId);
|
|
97
|
+
if (conn?.ws) { try { conn.ws.close(CLOSE_UPGRADE_REQUIRED, reason); } catch { /* dying */ } }
|
|
98
|
+
},
|
|
99
|
+
log: (event, detail) => logDebug(`axona:${event}`, detail),
|
|
100
|
+
});
|
|
101
|
+
await bridgeNode.start();
|
|
102
|
+
log('axona-ready', {
|
|
103
|
+
nodeId: bridgeNode.identity.id,
|
|
104
|
+
region: bridgeNode.identity.region.label,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** @type {Map<string, {ws: any, ip: string, since: number, lastSeenAt: number, pings: number, pongs: number, signalsRelayed: number, ua: string}>} */
|
|
108
|
+
const connections = new Map();
|
|
109
|
+
function sendTo(peerId, msg) {
|
|
110
|
+
const conn = connections.get(peerId);
|
|
111
|
+
if (!conn) return false;
|
|
112
|
+
try {
|
|
113
|
+
conn.ws.send(JSON.stringify(msg, bigintReplacer));
|
|
114
|
+
return true;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logErr('send-failed', { connId: peerId, type: msg.type, err: err.message });
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Broadcast to every peer except `exceptId` (typically the originator).
|
|
122
|
+
* Skips connections that haven't completed the client-hello version
|
|
123
|
+
* check — they should never appear in peer-list or get peer-joined
|
|
124
|
+
* notifications. */
|
|
125
|
+
function broadcast(msg, exceptId = null) {
|
|
126
|
+
let count = 0;
|
|
127
|
+
for (const [id, conn] of connections) {
|
|
128
|
+
if (id === exceptId) continue;
|
|
129
|
+
if (!conn.admitted) continue;
|
|
130
|
+
try {
|
|
131
|
+
conn.ws.send(JSON.stringify(msg, bigintReplacer));
|
|
132
|
+
count++;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
logErr('broadcast-send-failed', { connId: id, type: msg.type, err: err.message });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return count;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let connSeq = 0;
|
|
141
|
+
const wss = new WebSocketServer({server });
|
|
142
|
+
|
|
143
|
+
wss.on('connection', (ws, req) => {
|
|
144
|
+
const id = `c${(++connSeq).toString(36)}`;
|
|
145
|
+
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
|
146
|
+
?? req.socket.remoteAddress
|
|
147
|
+
?? 'unknown';
|
|
148
|
+
const ua = req.headers['user-agent'] ?? '';
|
|
149
|
+
const since = Date.now();
|
|
150
|
+
|
|
151
|
+
// Snapshot the *existing* peer set BEFORE adding the new one — this
|
|
152
|
+
// is what we'll send back as `peer-list`. If we registered first,
|
|
153
|
+
// the new peer would see itself in its own list.
|
|
154
|
+
const existingPeers = [...connections.keys()];
|
|
155
|
+
|
|
156
|
+
const conn = {
|
|
157
|
+
ws, ip, since,
|
|
158
|
+
lastSeenAt: since,
|
|
159
|
+
pings: 0, pongs: 0, signalsRelayed: 0,
|
|
160
|
+
ua,
|
|
161
|
+
admitted: false, // flipped to true after client-hello version check
|
|
162
|
+
helloTimer: null,
|
|
163
|
+
peerVersion: null,
|
|
164
|
+
};
|
|
165
|
+
connections.set(id, conn);
|
|
166
|
+
|
|
167
|
+
log('connect', { connId: id, ip, total: connections.size, ua: ua.slice(0, 80) });
|
|
168
|
+
|
|
169
|
+
// 1. Tell the peer the version gate exists *before* we close. This
|
|
170
|
+
// isn't a `welcome` — peer-list / peer-joined / hello are
|
|
171
|
+
// deferred until client-hello passes. The peer side sends
|
|
172
|
+
// 'client-hello' immediately after open; if nothing arrives
|
|
173
|
+
// within HELLO_TIMEOUT_MS, we close with the upgrade-required
|
|
174
|
+
// code so old clients trying to ride through get an obvious
|
|
175
|
+
// failure mode instead of a silent ghost connection.
|
|
176
|
+
sendTo(id, {
|
|
177
|
+
type: 'version-gate',
|
|
178
|
+
minPeerVersion: MIN_PEER_VERSION,
|
|
179
|
+
serverT: Date.now(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
conn.helloTimer = setTimeout(() => {
|
|
183
|
+
if (conn.admitted) return;
|
|
184
|
+
logErr('client-hello-timeout', { connId: id, ms: HELLO_TIMEOUT_MS });
|
|
185
|
+
try {
|
|
186
|
+
ws.close(CLOSE_UPGRADE_REQUIRED,
|
|
187
|
+
`client-hello not received within ${HELLO_TIMEOUT_MS}ms; ` +
|
|
188
|
+
`min peer v${MIN_PEER_VERSION} required`);
|
|
189
|
+
} catch {}
|
|
190
|
+
}, HELLO_TIMEOUT_MS);
|
|
191
|
+
|
|
192
|
+
function admitConnection() {
|
|
193
|
+
clearTimeout(conn.helloTimer);
|
|
194
|
+
conn.helloTimer = null;
|
|
195
|
+
conn.admitted = true;
|
|
196
|
+
|
|
197
|
+
// 1a. Mint a short-lived TURN credential (2h expiry) bundled
|
|
198
|
+
// into welcome so peer JS never sees a long-term secret.
|
|
199
|
+
const turn = makeTurnCredential(id);
|
|
200
|
+
// axona/4 — mint a fresh per-connection nonce; it (with the connId)
|
|
201
|
+
// is the channel-binding value the peer folds into its signed hello,
|
|
202
|
+
// and we fold into ours. A hello captured on one connection can't
|
|
203
|
+
// be replayed onto another.
|
|
204
|
+
const serverNonce = makeNonce();
|
|
205
|
+
conn.serverNonce = serverNonce;
|
|
206
|
+
sendTo(id, {
|
|
207
|
+
type: 'welcome',
|
|
208
|
+
connId: id,
|
|
209
|
+
serverT: Date.now(),
|
|
210
|
+
version: VERSION,
|
|
211
|
+
kernelVersion: KERNEL_VERSION,
|
|
212
|
+
serverNonce,
|
|
213
|
+
turn,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// 2. Tell the new peer about everyone ALREADY admitted.
|
|
217
|
+
const admittedPeers = [];
|
|
218
|
+
for (const [otherId, otherConn] of connections) {
|
|
219
|
+
if (otherId === id) continue;
|
|
220
|
+
if (!otherConn.admitted) continue;
|
|
221
|
+
admittedPeers.push(otherId);
|
|
222
|
+
}
|
|
223
|
+
sendTo(id, { type: 'peer-list', peers: admittedPeers, serverT: Date.now() });
|
|
224
|
+
|
|
225
|
+
// 3. Tell existing admitted peers that someone new arrived.
|
|
226
|
+
const announcedTo = broadcast(
|
|
227
|
+
{ type: 'peer-joined', peerId: id, serverT: Date.now() },
|
|
228
|
+
id,
|
|
229
|
+
);
|
|
230
|
+
log('peer-announce', { connId: id, peers: admittedPeers.length, announcedTo });
|
|
231
|
+
|
|
232
|
+
// 4. Axona bootstrap-offer. The peer's BridgeTransport replies
|
|
233
|
+
// with an authenticated hello-ack proving its nodeId; that's
|
|
234
|
+
// when our bridge node admits this browser into its synaptome.
|
|
235
|
+
bridgeNode.sendHello(id, serverNonce);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
ws.on('message', (data, isBinary) => {
|
|
239
|
+
// Any inbound bytes — even a malformed payload — count as proof
|
|
240
|
+
// the peer is still alive. Stamping liveness before parse means
|
|
241
|
+
// a peer that briefly sends junk doesn't get kicked for being
|
|
242
|
+
// idle on top of that.
|
|
243
|
+
conn.lastSeenAt = Date.now();
|
|
244
|
+
|
|
245
|
+
if (isBinary) {
|
|
246
|
+
logDebug('binary-dropped', { connId: id, bytes: data.length });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
let msg;
|
|
250
|
+
try {
|
|
251
|
+
msg = JSON.parse(data.toString(), bigintReviver);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logErr('bad-json', { connId: id, err: err.message });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Version gate. Before client-hello passes, the ONLY message we
|
|
258
|
+
// accept is client-hello itself. Everything else (ping, signal,
|
|
259
|
+
// axona, etc.) is silently dropped so we don't leak state — and
|
|
260
|
+
// never relay axona-protocol frames from un-validated peers.
|
|
261
|
+
if (!conn.admitted) {
|
|
262
|
+
if (msg.type !== 'client-hello') {
|
|
263
|
+
logDebug('pre-hello-message-dropped', { connId: id, type: msg.type });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const peerVersion = typeof msg.version === 'string' ? msg.version : null;
|
|
267
|
+
conn.peerVersion = peerVersion;
|
|
268
|
+
if (!peerVersion) {
|
|
269
|
+
logErr('client-hello-missing-version', { connId: id });
|
|
270
|
+
try {
|
|
271
|
+
ws.close(CLOSE_UPGRADE_REQUIRED,
|
|
272
|
+
`client-hello must include 'version' (min v${MIN_PEER_VERSION})`);
|
|
273
|
+
} catch {}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Two-stage gate: the absolute floor (MIN_PEER_VERSION) AND the
|
|
277
|
+
// namespace-aware flag-day floor for the v2.9.0 envelope (C-2/E-4).
|
|
278
|
+
// Report whichever bound actually binds (the higher of the two), so
|
|
279
|
+
// the close reason names the version the client must reach.
|
|
280
|
+
const { floor, ns } = flagDayFloor(peerVersion);
|
|
281
|
+
const effectiveMin = gteVersion(floor, MIN_PEER_VERSION) ? floor : MIN_PEER_VERSION;
|
|
282
|
+
if (!gteVersion(peerVersion, MIN_PEER_VERSION) || !gteVersion(peerVersion, floor)) {
|
|
283
|
+
logErr('client-hello-too-old', {
|
|
284
|
+
connId: id, peerVersion, ns, floor, minPeerVersion: MIN_PEER_VERSION, effectiveMin,
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
ws.close(CLOSE_UPGRADE_REQUIRED,
|
|
288
|
+
`peer v${peerVersion} below minimum v${effectiveMin} (${ns}); ` +
|
|
289
|
+
`reload axona.net / the demo to upgrade`);
|
|
290
|
+
} catch {}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
log('client-hello-admitted', {
|
|
294
|
+
connId: id, peerVersion, ns, floor, minPeerVersion: MIN_PEER_VERSION,
|
|
295
|
+
});
|
|
296
|
+
admitConnection();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
switch (msg.type) {
|
|
301
|
+
case 'ping': {
|
|
302
|
+
conn.pings++;
|
|
303
|
+
try {
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
type: 'pong',
|
|
306
|
+
t: msg.t, // echo client timestamp unchanged
|
|
307
|
+
serverT: Date.now(),
|
|
308
|
+
}));
|
|
309
|
+
conn.pongs++;
|
|
310
|
+
//fixme logDebug('pong', { connId: id, n: conn.pings });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
logErr('pong-send-failed', { connId: id, err: err.message });
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case 'axona': {
|
|
318
|
+
// Axona wire frame from the peer. The transport unpacks
|
|
319
|
+
// req/res/ntf, dispatches to handlers, and writes the
|
|
320
|
+
// response back through the same connection.
|
|
321
|
+
if (msg.payload && typeof msg.payload === 'object') {
|
|
322
|
+
bridgeNode.handleAxonaFrame(id, msg.payload);
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'signal': {
|
|
328
|
+
// Relay opaque SDP / ICE between peers. The bridge does not
|
|
329
|
+
// inspect `payload` — it only validates that `to` is connected
|
|
330
|
+
// and rewrites the addressing so the recipient knows who sent it.
|
|
331
|
+
const to = msg.to;
|
|
332
|
+
if (typeof to !== 'string') {
|
|
333
|
+
logErr('signal-missing-to', { connId: id });
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (!connections.has(to)) {
|
|
337
|
+
// Recipient is gone — silently drop. This is a normal race:
|
|
338
|
+
// a peer-left event raced past the signaling message. We
|
|
339
|
+
// don't surface an error to the sender because the sender
|
|
340
|
+
// will receive `peer-left` and clean up on its own.
|
|
341
|
+
logDebug('signal-drop-unknown-to', { connId: id, to });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
const delivered = sendTo(to, {
|
|
345
|
+
type: 'signal',
|
|
346
|
+
from: id,
|
|
347
|
+
payload: msg.payload,
|
|
348
|
+
});
|
|
349
|
+
if (delivered) {
|
|
350
|
+
conn.signalsRelayed++;
|
|
351
|
+
logDebug('signal-relay', { from: id, to, n: conn.signalsRelayed });
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
default:
|
|
357
|
+
logDebug('unknown-type', { connId: id, type: msg.type });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
ws.on('close', (code, reason) => {
|
|
362
|
+
const lifeS = Math.floor((Date.now() - since) / 1000);
|
|
363
|
+
if (conn.helloTimer) {
|
|
364
|
+
clearTimeout(conn.helloTimer);
|
|
365
|
+
conn.helloTimer = null;
|
|
366
|
+
}
|
|
367
|
+
connections.delete(id);
|
|
368
|
+
|
|
369
|
+
// Let the embedded Axona node clean up its bindings + reject any
|
|
370
|
+
// pending requests to this peer.
|
|
371
|
+
bridgeNode.handleConnClosed(id);
|
|
372
|
+
|
|
373
|
+
// Tell everyone remaining that this peer is gone. They'll tear
|
|
374
|
+
// down their RTCPeerConnection for this id.
|
|
375
|
+
const notifiedCount = broadcast(
|
|
376
|
+
{ type: 'peer-left', peerId: id, serverT: Date.now() },
|
|
377
|
+
null, // peer is already removed from the registry
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
log('disconnect', {
|
|
381
|
+
connId: id,
|
|
382
|
+
code,
|
|
383
|
+
reason: reason?.toString() ?? '',
|
|
384
|
+
lifeS,
|
|
385
|
+
pings: conn.pings,
|
|
386
|
+
pongs: conn.pongs,
|
|
387
|
+
signals: conn.signalsRelayed,
|
|
388
|
+
notified: notifiedCount,
|
|
389
|
+
remaining: connections.size,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
ws.on('error', (err) => {
|
|
394
|
+
logErr('ws-error', { connId: id, err: err.message });
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
import() resolves against the directory that contains the import.
|
|
3
|
+
But NodeJS fs operations resolve against the current working directory from which node was invoked.
|
|
4
|
+
That wasn't terribly inconvenient when require() was used, because it defined __dirname.
|
|
5
|
+
Here we export __dirname and resolve().
|
|
6
|
+
REQUIRES: that this file be imported from this directory.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
export const __dirname = path.dirname(__filename); // Only meaningful if loaded from same directory as this file.
|
|
13
|
+
export function resolve(relativePathname) {
|
|
14
|
+
return path.resolve(__dirname, relativePathname);
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Exports the current estimated location, based on IP address.
|
|
3
|
+
The value is cached in location.json, which can also be hand-edited, cleared, etc.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import { resolve } from './dirname.js';
|
|
7
|
+
const filename = './location.json';
|
|
8
|
+
export const data = await import(filename, {with: { type: 'json' }})
|
|
9
|
+
.catch(async () => {
|
|
10
|
+
const response = await fetch('https://ipinfo.io/json');
|
|
11
|
+
const string = await response.text();
|
|
12
|
+
console.log('Estimating location as', string);
|
|
13
|
+
await fs.writeFile(resolve(filename), string, 'utf8');
|
|
14
|
+
return {default: JSON.parse(string)};
|
|
15
|
+
});
|
|
16
|
+
export const [lat, lng] = data.default.loc.split(',').map(parseFloat);
|
|
17
|
+
export const location = {lat, lng};
|
|
18
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// =====================================================================
|
|
2
|
+
// identity.js — bridge's hybrid (legacy 64-bit + kernel 264-bit) identity.
|
|
3
|
+
//
|
|
4
|
+
// v0.3 (kernel 3.0.0): migrated to the kernel's createNodeIdentity (the
|
|
5
|
+
// connection/node identity factory; deriveIdentity was renamed) so the
|
|
6
|
+
// bridge is a kernel-conformant peer in the v1.0 wire protocol — signed
|
|
7
|
+
// envelopes work, peer topics derive correctly under the structured
|
|
8
|
+
// { region, name } addressing model.
|
|
9
|
+
//
|
|
10
|
+
// Returned shape (matches axona-peer/src/identity.js after #46):
|
|
11
|
+
//
|
|
12
|
+
// Legacy (preserved for ws_transport / bridge_axona_node — they
|
|
13
|
+
// carry BigInt nodeIds in the hello/hello-ack envelopes that
|
|
14
|
+
// peer browsers still parse via hexToId)
|
|
15
|
+
// id — BigInt 64-bit, top 64 bits of kernel hex id.
|
|
16
|
+
// Same S2 prefix at the top (preserves geographic
|
|
17
|
+
// routing locality); bottom 56 bits deterministic
|
|
18
|
+
// from sha256(pubkey).
|
|
19
|
+
// geoBits — 8
|
|
20
|
+
// region — { lat, lng, label, id: 'bridge' }
|
|
21
|
+
// createdAt — ms
|
|
22
|
+
//
|
|
23
|
+
// Kernel (new)
|
|
24
|
+
// idHex — 66-char hex (kernel's full 264-bit nodeId)
|
|
25
|
+
// pubkey — Uint8Array (Ed25519)
|
|
26
|
+
// privateKey — Web Crypto Ed25519 CryptoKey
|
|
27
|
+
// pubkeyHex — 64-char hex
|
|
28
|
+
//
|
|
29
|
+
// Persistence: NONE (Phase 2). The bridge transport id is EPHEMERAL — a fresh
|
|
30
|
+
// kernel identity is derived on every start; nothing is written to disk. The
|
|
31
|
+
// bridge directory dedups + ranks on the bridge URL (not the signer), so a
|
|
32
|
+
// rotating signer just re-publishes the same-URL directory entry on restart;
|
|
33
|
+
// clients still discover, rank, and fail over to it. No bridge-identity.json,
|
|
34
|
+
// no BRIDGE_IDENTITY_PATH.
|
|
35
|
+
// =====================================================================
|
|
36
|
+
|
|
37
|
+
import { createNodeIdentity as kernelCreateNodeIdentity } from '@axona/protocol';
|
|
38
|
+
|
|
39
|
+
const GEO_BITS = 8;
|
|
40
|
+
|
|
41
|
+
const DEFAULT_LAT = 37.5;
|
|
42
|
+
const DEFAULT_LNG = -122.3;
|
|
43
|
+
|
|
44
|
+
function regionFromEnv() {
|
|
45
|
+
const lat = parseFloat(process.env.BRIDGE_LAT ?? DEFAULT_LAT);
|
|
46
|
+
const lng = parseFloat(process.env.BRIDGE_LNG ?? DEFAULT_LNG);
|
|
47
|
+
const label = process.env.BRIDGE_REGION_LABEL
|
|
48
|
+
?? `bridge (${lat.toFixed(2)}, ${lng.toFixed(2)})`;
|
|
49
|
+
return { lat, lng, label, id: 'bridge' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} BridgeIdentity
|
|
54
|
+
* @property {bigint} id legacy 64-bit BigInt (top 64 bits of kernel hex)
|
|
55
|
+
* @property {number} geoBits 8
|
|
56
|
+
* @property {Object} region { lat, lng, label, id }
|
|
57
|
+
* @property {number} createdAt ms
|
|
58
|
+
* @property {string} idHex kernel 66-char hex node ID
|
|
59
|
+
* @property {Uint8Array} pubkey
|
|
60
|
+
* @property {CryptoKey} privateKey
|
|
61
|
+
* @property {string} pubkeyHex
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive a fresh node/connection identity. ASYNC — kernel
|
|
66
|
+
* createNodeIdentity uses Web Crypto Ed25519 keygen (async).
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<BridgeIdentity>}
|
|
69
|
+
*/
|
|
70
|
+
export async function loadOrDeriveIdentity() {
|
|
71
|
+
// Phase 2: the bridge transport id is EPHEMERAL — never persisted. The bridge
|
|
72
|
+
// mints a fresh kernel node identity on every start (no bridge-identity.json).
|
|
73
|
+
// The bridge directory + first-party reputation are keyed on the bridge URL,
|
|
74
|
+
// not on the (now-rotating) signer, so clients still find + rank it across
|
|
75
|
+
// restarts; a fresh signer simply re-publishes the same-URL directory entry.
|
|
76
|
+
const labels = regionFromEnv();
|
|
77
|
+
const kernel = await kernelCreateNodeIdentity({ lat: labels.lat, lng: labels.lng });
|
|
78
|
+
return buildHybrid(kernel, labels);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build the hybrid identity object from a kernel identity. */
|
|
82
|
+
function buildHybrid(kernel, regionLabels) {
|
|
83
|
+
// v1.1: full-width 264-bit node ID — same address space as
|
|
84
|
+
// topic IDs so K-closest XOR distance is meaningful (top 8 bits
|
|
85
|
+
// = S2 region prefix on both peer IDs and topic IDs). Replaces
|
|
86
|
+
// the previous .slice(0, 16) which left the bridge in a 64-bit
|
|
87
|
+
// mesh while topics were 264-bit.
|
|
88
|
+
const idBig = BigInt('0x' + kernel.id);
|
|
89
|
+
return {
|
|
90
|
+
// Legacy field name; value is now 264-bit BigInt.
|
|
91
|
+
id: idBig,
|
|
92
|
+
geoBits: GEO_BITS,
|
|
93
|
+
region: {
|
|
94
|
+
lat: kernel.region.lat,
|
|
95
|
+
lng: kernel.region.lng,
|
|
96
|
+
label: regionLabels.label ?? `bridge (${kernel.region.lat.toFixed(2)}, ${kernel.region.lng.toFixed(2)})`,
|
|
97
|
+
id: regionLabels.id ?? 'bridge',
|
|
98
|
+
},
|
|
99
|
+
createdAt: kernel.createdAt,
|
|
100
|
+
// Kernel
|
|
101
|
+
idHex: kernel.id,
|
|
102
|
+
pubkey: kernel.pubkey,
|
|
103
|
+
privateKey: kernel.privateKey,
|
|
104
|
+
pubkeyHex: kernel.pubkeyHex,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Format a 264-bit BigInt nodeId as a 66-char hex string (v1.1 wire
|
|
109
|
+
* convention: top 8 bits = S2 region prefix, rest = pubkey-derived
|
|
110
|
+
* hash; same width as topic IDs). */
|
|
111
|
+
export function idToHex(id) { return id.toString(16).padStart(66, '0'); }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ip": "99.122.55.96",
|
|
3
|
+
"hostname": "99-122-55-96.lightspeed.sntcca.sbcglobal.net",
|
|
4
|
+
"city": "Redwood City",
|
|
5
|
+
"region": "California",
|
|
6
|
+
"country": "US",
|
|
7
|
+
"loc": "37.4852,-122.2364",
|
|
8
|
+
"org": "AS7018 AT&T Enterprises, LLC",
|
|
9
|
+
"postal": "94061",
|
|
10
|
+
"timezone": "America/Los_Angeles",
|
|
11
|
+
"readme": "https://ipinfo.io/missingauth"
|
|
12
|
+
}
|