@decentnetwork/beagle 0.1.127 → 0.1.129
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/config/default-exits.yaml +2 -0
- package/dist/cli.js +1 -0
- package/dist/desktop/app.js +78 -14
- package/dist/exit-probe.d.ts +81 -0
- package/dist/exit-probe.js +228 -0
- package/dist/exits.d.ts +2 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +119 -44
- package/package.json +2 -2
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
exits:
|
|
27
27
|
# --- China (China-only / GFW-blocked-from-outside sites) ---
|
|
28
28
|
- name: cn
|
|
29
|
+
label: ea-1
|
|
29
30
|
userid: 5Aj6uQMd1cNRb9cGT4AwHCN4RdVZjfaGLtgKGhdP1LzN
|
|
30
31
|
virtual_ip: 10.86.1.15
|
|
31
32
|
region: china
|
|
32
33
|
- name: sh
|
|
34
|
+
label: ea-2
|
|
33
35
|
userid: 6D1PLSVqbSpcxnDMAd8ZXLWycYveeP2hU4g3igDQEqA7
|
|
34
36
|
virtual_ip: 10.86.1.16
|
|
35
37
|
region: china
|
package/dist/cli.js
CHANGED
|
@@ -174,6 +174,7 @@ async function main() {
|
|
|
174
174
|
messagesPath: resolve(args.configDir, "messages.json"),
|
|
175
175
|
servicePeersPath: resolve(args.configDir, "service-peers-local.json"),
|
|
176
176
|
greetingsPath: resolve(args.configDir, "friend-greetings.json"),
|
|
177
|
+
exitStatePath: resolve(args.configDir, "beagle-exit-state.json"),
|
|
177
178
|
callIce: {
|
|
178
179
|
keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
|
|
179
180
|
bootstrapNodes,
|
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.129";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -442,6 +442,10 @@ window.__DK_UI_VERSION="0.1.127";
|
|
|
442
442
|
cancelSend: (userid, ids) => dkPost("/api/file-cancel", { userid, ids }),
|
|
443
443
|
retrySend: (userid, ids) => dkPost("/api/file-retry", { userid, ids }),
|
|
444
444
|
setProfile: (name, description) => dkPost("/api/set-profile", { name, description }),
|
|
445
|
+
// Probe a failed exit once, now (or every failed exit with all=true). Exits
|
|
446
|
+
// that failed three times in a row are never probed again on their own.
|
|
447
|
+
reconnectExit: (ip) => dkPost("/api/exits/reconnect", { ip }),
|
|
448
|
+
reconnectAllExits: () => dkPost("/api/exits/reconnect", { all: true }),
|
|
445
449
|
// Used by the browser's welcome flow, which also picks an avatar and marks
|
|
446
450
|
// first run done. Harmless where there is no welcome flow: nothing calls it.
|
|
447
451
|
setProfileFull: (p) => dkPost("/api/set-profile", p),
|
|
@@ -577,22 +581,27 @@ window.__DK_UI_VERSION="0.1.127";
|
|
|
577
581
|
}, [peers, isProtocol, enabled]);
|
|
578
582
|
return fixes;
|
|
579
583
|
}
|
|
580
|
-
function useDaemonData() {
|
|
584
|
+
function useDaemonData(opts) {
|
|
585
|
+
const wantExits = !!(opts && opts.exits);
|
|
586
|
+
const wantExitsRef = React.useRef(wantExits);
|
|
587
|
+
wantExitsRef.current = wantExits;
|
|
581
588
|
const [snap, setSnap] = React.useState({
|
|
582
589
|
me: DK_ME_FALLBACK,
|
|
583
590
|
peers: [],
|
|
584
591
|
requests: [],
|
|
585
592
|
exits: [],
|
|
586
593
|
activeExit: null,
|
|
594
|
+
exitFeature: null,
|
|
595
|
+
routeFallback: null,
|
|
587
596
|
loaded: false,
|
|
588
597
|
locked: false
|
|
589
598
|
});
|
|
590
599
|
const [threads, setThreads] = React.useState({});
|
|
591
600
|
const refreshRef = React.useRef(null);
|
|
592
601
|
const refresh = React.useCallback(async () => {
|
|
593
|
-
var _a;
|
|
602
|
+
var _a, _b, _c;
|
|
594
603
|
try {
|
|
595
|
-
const d = await dkGet("/api/desktop");
|
|
604
|
+
const d = await dkGet(wantExitsRef.current ? "/api/desktop?exits=1" : "/api/desktop");
|
|
596
605
|
if (d && d.locked) {
|
|
597
606
|
setSnap((s) => ({ ...s, locked: true, loaded: true }));
|
|
598
607
|
return;
|
|
@@ -610,6 +619,8 @@ window.__DK_UI_VERSION="0.1.127";
|
|
|
610
619
|
requests: d.requests || [],
|
|
611
620
|
exits: d.exits || [],
|
|
612
621
|
activeExit: (_a = d.activeExit) != null ? _a : null,
|
|
622
|
+
exitFeature: (_b = d.exitFeature) != null ? _b : null,
|
|
623
|
+
routeFallback: (_c = d.routeFallback) != null ? _c : null,
|
|
613
624
|
loaded: true
|
|
614
625
|
});
|
|
615
626
|
} catch (e) {
|
|
@@ -621,6 +632,10 @@ window.__DK_UI_VERSION="0.1.127";
|
|
|
621
632
|
const t = setInterval(refresh, 2500);
|
|
622
633
|
return () => clearInterval(t);
|
|
623
634
|
}, [refresh]);
|
|
635
|
+
React.useEffect(() => {
|
|
636
|
+
if (wantExits)
|
|
637
|
+
refresh();
|
|
638
|
+
}, [wantExits, refresh]);
|
|
624
639
|
const inFlight = React.useRef(/* @__PURE__ */ new Set());
|
|
625
640
|
const cache = React.useRef({});
|
|
626
641
|
const [earlier, setEarlier] = React.useState({});
|
|
@@ -642,10 +657,10 @@ window.__DK_UI_VERSION="0.1.127";
|
|
|
642
657
|
setThreads((t) => Object.assign({}, t, { [peerId]: withDay }));
|
|
643
658
|
setEarlier((e) => e[peerId] === c.more ? e : Object.assign({}, e, { [peerId]: c.more }));
|
|
644
659
|
}, []);
|
|
645
|
-
const loadThread = React.useCallback(async (peerId,
|
|
660
|
+
const loadThread = React.useCallback(async (peerId, opts2) => {
|
|
646
661
|
if (!peerId)
|
|
647
662
|
return;
|
|
648
|
-
if (
|
|
663
|
+
if (opts2 && opts2.reset)
|
|
649
664
|
delete cache.current[peerId];
|
|
650
665
|
if (inFlight.current.has(peerId))
|
|
651
666
|
return;
|
|
@@ -6121,15 +6136,40 @@ ${peer.address}`
|
|
|
6121
6136
|
borderBottom: i < peers.length - 1 ? "1px solid var(--line)" : "none"
|
|
6122
6137
|
} }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: p, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: p.alias ? "var(--ui)" : "var(--mono)", fontSize: 13, fontWeight: 600, color: "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, p.alias || shortKey(p.userId, 8, 5)), p.agent && /* @__PURE__ */ React.createElement(Icon, { name: "bot", size: 12, color: "var(--faint)", stroke: 2 })), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, dim: true, copy: p.ip }, p.ip), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(RouteTag, { peer: p })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: p.wire === "64" ? "var(--warn)" : "var(--dim)" } }, p.wire === "64" ? "old\xB764" : "new\xB7163"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "message", size: "sm", title: T.openChat, onClick: () => onOpenChat(p.id) }), /* @__PURE__ */ React.createElement(Btn, { icon: "signal", size: "sm", title: T.ping })))));
|
|
6123
6138
|
}
|
|
6124
|
-
function
|
|
6139
|
+
function ReconnectBtn({ T, ip, onReconnect, label, icon = "refresh" }) {
|
|
6140
|
+
const [busy, setBusy] = React.useState(false);
|
|
6141
|
+
return /* @__PURE__ */ React.createElement(
|
|
6142
|
+
Btn,
|
|
6143
|
+
{
|
|
6144
|
+
size: "sm",
|
|
6145
|
+
icon,
|
|
6146
|
+
disabled: busy,
|
|
6147
|
+
onClick: async () => {
|
|
6148
|
+
if (busy || !onReconnect)
|
|
6149
|
+
return;
|
|
6150
|
+
setBusy(true);
|
|
6151
|
+
try {
|
|
6152
|
+
await onReconnect(ip);
|
|
6153
|
+
} finally {
|
|
6154
|
+
setBusy(false);
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
},
|
|
6158
|
+
busy ? T.exitChecking || "checking\u2026" : label || T.exitReconnect || "Reconnect"
|
|
6159
|
+
);
|
|
6160
|
+
}
|
|
6161
|
+
function ExitCard({ T, region, activeExit, onSetExit, onReconnect, featureOn }) {
|
|
6125
6162
|
return /* @__PURE__ */ React.createElement("div", { style: { borderRadius: 11, border: "1px solid var(--line)", overflow: "hidden", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, padding: "2px 7px", borderRadius: 5, background: "var(--chip)", color: "var(--dim)" } }, region.flag), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 13.5, fontWeight: 600, color: "var(--text)" } }, region.label), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), (() => {
|
|
6126
6163
|
const reach = region.nodes.filter((n) => n.reachable).length;
|
|
6127
|
-
const
|
|
6128
|
-
|
|
6164
|
+
const failed = region.nodes.filter((n) => n.status === "failed").length;
|
|
6165
|
+
const down = region.nodes.length - reach - failed;
|
|
6166
|
+
return /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: down + failed > 0 ? "var(--warn)" : "var(--faint)" } }, reach, "/", region.nodes.length, " ", T.up, failed > 0 ? ` \xB7 ${failed} ${T.exitFailed || "failed"}` : "", down > 0 ? ` \xB7 ${down} down` : "");
|
|
6129
6167
|
})()), /* @__PURE__ */ React.createElement("div", null, region.nodes.map((n, i) => {
|
|
6130
6168
|
const active = n.ip === activeExit;
|
|
6131
6169
|
const stuck = n.online && !n.reachable;
|
|
6132
|
-
|
|
6170
|
+
const failed = n.status === "failed";
|
|
6171
|
+
const usable = n.reachable && featureOn !== false;
|
|
6172
|
+
return /* @__PURE__ */ React.createElement("div", { key: n.ip, className: "dk-wrap", style: { display: "flex", alignItems: "center", gap: 12, rowGap: 6, padding: "10px 14px", borderBottom: i < region.nodes.length - 1 ? "1px solid var(--line)" : "none", background: active ? "color-mix(in oklab, var(--accent), transparent 92%)" : "transparent" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: n.reachable }), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: n.ip }, n.ip), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, n.host), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), n.reachable ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11.5, color: n.ping > 200 ? "var(--warn)" : "var(--dim)" } }, n.ping, "ms") : failed ? /* @__PURE__ */ React.createElement(Tag, { tone: "danger", title: "Failed 3 checks in a row \u2014 no longer checked automatically" }, T.exitFailed || "failed") : n.status === "unknown" ? /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, T.exitNotChecked || "not checked") : stuck ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "Announces online but IP won't pass \u2014 session desync or NAT-blocked" }, "online \xB7 no route") : n.status === "retrying" ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "No answer \u2014 will try again shortly" }, T.exitRetrying || "retrying", " \xB7 ", n.fails, "/3") : /* @__PURE__ */ React.createElement(Tag, { tone: "off" }, "offline"), failed ? /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: n.ip, onReconnect }) : active ? /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onSetExit(null) }, T.routing) : /* @__PURE__ */ React.createElement(Btn, { size: "sm", icon: "route", disabled: !usable, onClick: () => usable && onSetExit(n.ip), style: { opacity: usable ? 1 : 0.4 } }, T.routeThru));
|
|
6133
6173
|
})));
|
|
6134
6174
|
}
|
|
6135
6175
|
function LanEnableCard({ backend, onArm, onCancel }) {
|
|
@@ -6233,12 +6273,14 @@ ${peer.address}`
|
|
|
6233
6273
|
"\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
|
|
6234
6274
|
)));
|
|
6235
6275
|
}
|
|
6236
|
-
function NetworkTab({ T, me, peers, exits, activeExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6276
|
+
function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6277
|
+
const featureOn = !exitFeature || exitFeature.available !== false;
|
|
6278
|
+
const anyFailed = (exits || []).some((r) => (r.nodes || []).some((n) => n.status === "failed"));
|
|
6237
6279
|
if (backend && backend.switchable && !backend.hasVirtualLan) {
|
|
6238
6280
|
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement(LanEnableCard, { backend, onArm: onArmLan, onCancel: onCancelLan })));
|
|
6239
6281
|
}
|
|
6240
6282
|
const lanless = !!backend && backend.hasVirtualLan === false;
|
|
6241
|
-
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.peerRouting, count: peers.length }), /* @__PURE__ */ React.createElement(PeerTable, { T, peers, onOpenChat }), !peers.length && /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", borderRadius: 11, border: "1px dashed var(--line)", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.noPeersYet || "No friends yet \u2014 add one from Chat and it shows up here with its route.")), lanless ? null : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.exitNodes, trailing: /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { icon: "plus", size: "sm" }, T.addExit)) }), activeExit && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderRadius: 10, background: "color-mix(in oklab, var(--warn), transparent 90%)", border: "1px solid color-mix(in oklab, var(--warn), transparent 70%)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "route", size: 17, color: "var(--warn)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, T.egressVia), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: activeExit }, activeExit), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "unlink", size: "sm", onClick: () => onSetExit(null) }, T.stopRouting)), /* @__PURE__ */ React.createElement("div", { className: "dk-grid1", style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit }))))));
|
|
6283
|
+
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { className: "dk-page", style: { maxWidth: 1040, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 26 } }, /* @__PURE__ */ React.createElement(MyNode, { T, me, activeExit, peers, reqCount }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.peerRouting, count: peers.length }), /* @__PURE__ */ React.createElement(PeerTable, { T, peers, onOpenChat }), !peers.length && /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", borderRadius: 11, border: "1px dashed var(--line)", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.noPeersYet || "No friends yet \u2014 add one from Chat and it shows up here with its route.")), lanless ? null : /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 12 } }, /* @__PURE__ */ React.createElement(Section, { label: T.exitNodes, trailing: /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, anyFailed && onReconnectExit && /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: null, onReconnect: onReconnectExit, label: T.exitReconnectAll || "Reconnect all" }), /* @__PURE__ */ React.createElement(Btn, { icon: "plus", size: "sm" }, T.addExit)) }), !featureOn && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 4, padding: "11px 14px", borderRadius: 10, background: "var(--panel-2)", border: "1px dashed var(--line)" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 700, color: "var(--text)" } }, T.noExitAvailable || "No exit available \u2014 routing and proxy are off."), anyFailed && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 12, color: "var(--dim)" } }, T.noExitHint || "Every exit failed 3 times and is no longer checked automatically. Press Reconnect to try one again.")), routeFallback && routeFallback.from && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderRadius: 10, background: "color-mix(in oklab, var(--warn), transparent 90%)", border: "1px solid color-mix(in oklab, var(--warn), transparent 70%)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "route", size: 17, color: "var(--warn)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, T.routeFellBack || "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.")), activeExit && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderRadius: 10, background: "color-mix(in oklab, var(--warn), transparent 90%)", border: "1px solid color-mix(in oklab, var(--warn), transparent 70%)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "route", size: 17, color: "var(--warn)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, T.egressVia), /* @__PURE__ */ React.createElement(Mono, { size: 13, copy: activeExit }, activeExit), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "unlink", size: "sm", onClick: () => onSetExit(null) }, T.stopRouting)), /* @__PURE__ */ React.createElement("div", { className: "dk-grid1", style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } }, exits.map((r) => /* @__PURE__ */ React.createElement(ExitCard, { key: r.region, T, region: r, activeExit, onSetExit, onReconnect: onReconnectExit, featureOn }))))));
|
|
6242
6284
|
}
|
|
6243
6285
|
Object.assign(window, { NetworkTab });
|
|
6244
6286
|
var dkAudioCtx = null;
|
|
@@ -6650,6 +6692,15 @@ ${peer.address}`
|
|
|
6650
6692
|
routeThru: "route",
|
|
6651
6693
|
egressVia: "all egress routed via",
|
|
6652
6694
|
stopRouting: "stop",
|
|
6695
|
+
exitFailed: "failed",
|
|
6696
|
+
exitReconnect: "Reconnect",
|
|
6697
|
+
exitReconnectAll: "Reconnect all",
|
|
6698
|
+
exitChecking: "checking\u2026",
|
|
6699
|
+
exitNotChecked: "not checked",
|
|
6700
|
+
exitRetrying: "retrying",
|
|
6701
|
+
noExitAvailable: "No exit available \u2014 routing and proxy are off.",
|
|
6702
|
+
noExitHint: "Every exit failed 3 times and is no longer checked automatically. Press Reconnect to try one again.",
|
|
6703
|
+
routeFellBack: "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.",
|
|
6653
6704
|
identity: "Identity",
|
|
6654
6705
|
userId: "user id",
|
|
6655
6706
|
carrierAddr: "carrier address",
|
|
@@ -6852,6 +6903,15 @@ ${peer.address}`
|
|
|
6852
6903
|
routeThru: "\u8DEF\u7531",
|
|
6853
6904
|
egressVia: "\u5168\u90E8\u51FA\u53E3\u6D41\u91CF\u7ECF\u7531",
|
|
6854
6905
|
stopRouting: "\u505C\u6B62",
|
|
6906
|
+
exitFailed: "\u5DF2\u5931\u8D25",
|
|
6907
|
+
exitReconnect: "\u91CD\u65B0\u8FDE\u63A5",
|
|
6908
|
+
exitReconnectAll: "\u5168\u90E8\u91CD\u8FDE",
|
|
6909
|
+
exitChecking: "\u68C0\u6D4B\u4E2D\u2026",
|
|
6910
|
+
exitNotChecked: "\u672A\u68C0\u6D4B",
|
|
6911
|
+
exitRetrying: "\u91CD\u8BD5\u4E2D",
|
|
6912
|
+
noExitAvailable: "\u6CA1\u6709\u53EF\u7528\u7684\u51FA\u53E3 \u2014\u2014 \u8DEF\u7531\u548C\u4EE3\u7406\u5DF2\u5173\u95ED\u3002",
|
|
6913
|
+
noExitHint: "\u6240\u6709\u51FA\u53E3\u90FD\u8FDE\u7EED\u5931\u8D25 3 \u6B21,\u5DF2\u4E0D\u518D\u81EA\u52A8\u68C0\u6D4B\u3002\u70B9\u300C\u91CD\u65B0\u8FDE\u63A5\u300D\u518D\u8BD5\u4E00\u6B21\u3002",
|
|
6914
|
+
routeFellBack: "\u8DEF\u7531\u5DF2\u6539\u56DE\u76F4\u8FDE:\u5B83\u4F7F\u7528\u7684\u51FA\u53E3\u5168\u90E8\u5931\u8D25\u3002\u91CD\u65B0\u8FDE\u63A5\u5176\u4E2D\u4E00\u4E2A\u5373\u53EF\u6062\u590D\u3002",
|
|
6855
6915
|
identity: "\u8EAB\u4EFD",
|
|
6856
6916
|
userId: "\u7528\u6237 ID",
|
|
6857
6917
|
carrierAddr: "Carrier \u5730\u5740",
|
|
@@ -7146,7 +7206,7 @@ ${peer.address}`
|
|
|
7146
7206
|
return hasTab(tabs, want) || DK_PEOPLE_TABS.includes(want) && showPeople ? want : firstTab;
|
|
7147
7207
|
});
|
|
7148
7208
|
const [activeId, setActiveId] = React.useState(null);
|
|
7149
|
-
const data = useDaemonData();
|
|
7209
|
+
const data = useDaemonData({ exits: tab === "network" });
|
|
7150
7210
|
const me = data.me;
|
|
7151
7211
|
const [upd, setUpd] = React.useState(null);
|
|
7152
7212
|
React.useEffect(() => {
|
|
@@ -7509,6 +7569,10 @@ ${peer.address}`
|
|
|
7509
7569
|
const onEdit = (name, description) => dkApi.setProfile(name, description).then(data.refresh);
|
|
7510
7570
|
const onSetExit = () => {
|
|
7511
7571
|
};
|
|
7572
|
+
const onReconnectExit = (ip) => (ip ? dkApi.reconnectExit(ip) : dkApi.reconnectAllExits()).then((r) => {
|
|
7573
|
+
data.refresh();
|
|
7574
|
+
return r;
|
|
7575
|
+
});
|
|
7512
7576
|
const onOpenChat = (id) => {
|
|
7513
7577
|
setTab("chat");
|
|
7514
7578
|
openConversation(id);
|
|
@@ -7588,7 +7652,7 @@ ${peer.address}`
|
|
|
7588
7652
|
fontWeight: 600,
|
|
7589
7653
|
background: tab === n.id ? "var(--accent)" : "transparent",
|
|
7590
7654
|
color: tab === n.id ? "#fff" : "var(--dim)"
|
|
7591
|
-
} }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, isProtocol, protocolFix, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t, me, peers, phone, setTab }), tabs.map((x) => x.kind === "host" && tab === x.id ? /* @__PURE__ */ React.createElement(HostTabPane, { key: x.id, tab: x, props: { T, t, me, peers, phone, setTab }, lang: t.lang }) : null), tab === "profile" && /* @__PURE__ */ React.createElement(
|
|
7655
|
+
} }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, isProtocol, protocolFix, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, exitFeature: data.exitFeature, routeFallback: data.routeFallback, onReconnectExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t, me, peers, phone, setTab }), tabs.map((x) => x.kind === "host" && tab === x.id ? /* @__PURE__ */ React.createElement(HostTabPane, { key: x.id, tab: x, props: { T, t, me, peers, phone, setTab }, lang: t.lang }) : null), tab === "profile" && /* @__PURE__ */ React.createElement(
|
|
7592
7656
|
ProfileTab,
|
|
7593
7657
|
{
|
|
7594
7658
|
T,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export declare const EXIT_PROBE_PORT = 8888;
|
|
2
|
+
export declare const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
3
|
+
/** Consecutive failed probes after which an exit is `failed`. */
|
|
4
|
+
export declare const EXIT_MAX_FAILS = 3;
|
|
5
|
+
/** Wait after the 1st and 2nd failure before the next automatic try. */
|
|
6
|
+
export declare const EXIT_RETRY_BACKOFF_MS: number[];
|
|
7
|
+
/** How long a successful probe is trusted before the next one. */
|
|
8
|
+
export declare const EXIT_OK_CACHE_MS = 60000;
|
|
9
|
+
export type ExitStatus = "unknown" | "ok" | "retrying" | "failed";
|
|
10
|
+
export interface ExitRecord {
|
|
11
|
+
/** Consecutive failed probes (reset by a success). */
|
|
12
|
+
fails: number;
|
|
13
|
+
failed: boolean;
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
ping: number | null;
|
|
16
|
+
lastProbeAt: number;
|
|
17
|
+
/** No automatic probe before this time. */
|
|
18
|
+
nextProbeAt: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ExitView {
|
|
21
|
+
ip: string;
|
|
22
|
+
status: ExitStatus;
|
|
23
|
+
reachable: boolean;
|
|
24
|
+
ping: number | null;
|
|
25
|
+
fails: number;
|
|
26
|
+
checkedAt: number | null;
|
|
27
|
+
}
|
|
28
|
+
/** Routing that was switched to direct because its exits all failed. */
|
|
29
|
+
export interface RouteFallback {
|
|
30
|
+
from: string;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
export type ProbeFn = (ip: string) => Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
ping: number | null;
|
|
36
|
+
}>;
|
|
37
|
+
/** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
|
|
38
|
+
* proves the mesh path and the exit process are both live. ECONNREFUSED means
|
|
39
|
+
* the host answered (L3 reachable) but nothing listens — still reachable for
|
|
40
|
+
* triage; a timeout / no route is the real "can't connect". */
|
|
41
|
+
export declare function tcpProbe(ip: string, port?: number, timeoutMs?: number): Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
ping: number | null;
|
|
44
|
+
}>;
|
|
45
|
+
export declare class ExitProber {
|
|
46
|
+
#private;
|
|
47
|
+
constructor(opts?: {
|
|
48
|
+
path?: string;
|
|
49
|
+
probe?: ProbeFn;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
});
|
|
52
|
+
view(ip: string): ExitView;
|
|
53
|
+
isFailed(ip: string): boolean;
|
|
54
|
+
/** True while at least one of `ips` is not failed — i.e. there is an exit
|
|
55
|
+
* the route/proxy feature could still use. None configured = false. */
|
|
56
|
+
usable(ips: string[]): boolean;
|
|
57
|
+
/** Is an automatic probe of `ip` due now? Failed exits never are. */
|
|
58
|
+
due(ip: string): boolean;
|
|
59
|
+
/** Probe every exit in `ips` that is due (never a failed one, never one
|
|
60
|
+
* already being probed). Resolves when those probes are done. */
|
|
61
|
+
refresh(ips: string[]): Promise<void>;
|
|
62
|
+
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
63
|
+
* the exit normal again; a failure leaves a failed exit failed. */
|
|
64
|
+
reconnect(ip: string): Promise<ExitView>;
|
|
65
|
+
get fallback(): RouteFallback | null;
|
|
66
|
+
setFallback(f: RouteFallback | null): void;
|
|
67
|
+
}
|
|
68
|
+
/** The exits a routes.yaml `default` sends traffic through: an exit ip names
|
|
69
|
+
* itself, a region name names its routes.yaml exits plus the shipped exits of
|
|
70
|
+
* that region. "direct" / empty → none. */
|
|
71
|
+
export declare function exitsForTarget(target: string | undefined | null, regions: Array<{
|
|
72
|
+
name: string;
|
|
73
|
+
exits?: string[];
|
|
74
|
+
}>, shipped: Array<{
|
|
75
|
+
virtual_ip?: string;
|
|
76
|
+
region?: string;
|
|
77
|
+
}>, knownIps: Iterable<string>): string[];
|
|
78
|
+
/** routes.yaml text with its top-level `default:` set to `value`, keeping
|
|
79
|
+
* every other line (and comment) as written. Returns null when there is no
|
|
80
|
+
* top-level `default:` line to change. */
|
|
81
|
+
export declare function setRoutesDefault(text: string, value: string): string | null;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Exit reachability, with memory — written from the 2026-11-03 MacBook Air
|
|
2
|
+
// battery drain.
|
|
3
|
+
//
|
|
4
|
+
// The network panel used to TCP-probe every exit's :8888 from scratch on every
|
|
5
|
+
// /api/desktop poll (one every 2.5s per open tab), with no record of what had
|
|
6
|
+
// already failed. On the Air every exit was dead, so the daemon sat in a loop
|
|
7
|
+
// of SYNs to seven hosts that never answered — whether or not routing used an
|
|
8
|
+
// exit and whether or not anyone was looking at the panel.
|
|
9
|
+
//
|
|
10
|
+
// The rules this file holds:
|
|
11
|
+
// - a probe result is cached (a good one for OK_CACHE_MS, a bad one until
|
|
12
|
+
// its backoff runs out);
|
|
13
|
+
// - an exit that fails MAX_FAILS probes in a row is `failed` and is never
|
|
14
|
+
// probed automatically again — only `reconnect()` (a button press) does;
|
|
15
|
+
// - the record lives on disk, so a restart does not start the loop over.
|
|
16
|
+
import net from "node:net";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
18
|
+
export const EXIT_PROBE_PORT = 8888;
|
|
19
|
+
export const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
20
|
+
/** Consecutive failed probes after which an exit is `failed`. */
|
|
21
|
+
export const EXIT_MAX_FAILS = 3;
|
|
22
|
+
/** Wait after the 1st and 2nd failure before the next automatic try. */
|
|
23
|
+
export const EXIT_RETRY_BACKOFF_MS = [30_000, 120_000];
|
|
24
|
+
/** How long a successful probe is trusted before the next one. */
|
|
25
|
+
export const EXIT_OK_CACHE_MS = 60_000;
|
|
26
|
+
/** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
|
|
27
|
+
* proves the mesh path and the exit process are both live. ECONNREFUSED means
|
|
28
|
+
* the host answered (L3 reachable) but nothing listens — still reachable for
|
|
29
|
+
* triage; a timeout / no route is the real "can't connect". */
|
|
30
|
+
export function tcpProbe(ip, port = EXIT_PROBE_PORT, timeoutMs = EXIT_PROBE_TIMEOUT_MS) {
|
|
31
|
+
return new Promise((done) => {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
let settled = false;
|
|
34
|
+
let sock;
|
|
35
|
+
const finish = (ok) => {
|
|
36
|
+
if (settled)
|
|
37
|
+
return;
|
|
38
|
+
settled = true;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
try {
|
|
41
|
+
sock?.destroy();
|
|
42
|
+
}
|
|
43
|
+
catch { /* already gone */ }
|
|
44
|
+
done({ ok, ping: ok ? Date.now() - t0 : null });
|
|
45
|
+
};
|
|
46
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
sock = net.connect({ host: ip, port });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
finish(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
sock.once("connect", () => finish(true));
|
|
55
|
+
sock.once("error", (e) => finish(e.code === "ECONNREFUSED"));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export class ExitProber {
|
|
59
|
+
#path;
|
|
60
|
+
#probe;
|
|
61
|
+
#now;
|
|
62
|
+
#exits = new Map();
|
|
63
|
+
#fallback = null;
|
|
64
|
+
#inFlight = new Map();
|
|
65
|
+
constructor(opts = {}) {
|
|
66
|
+
this.#path = opts.path;
|
|
67
|
+
this.#probe = opts.probe ?? ((ip) => tcpProbe(ip));
|
|
68
|
+
this.#now = opts.now ?? Date.now;
|
|
69
|
+
this.#load();
|
|
70
|
+
}
|
|
71
|
+
#load() {
|
|
72
|
+
if (!this.#path || !existsSync(this.#path))
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const f = JSON.parse(readFileSync(this.#path, "utf-8"));
|
|
76
|
+
for (const [ip, r] of Object.entries(f?.exits ?? {})) {
|
|
77
|
+
if (!r || typeof r !== "object")
|
|
78
|
+
continue;
|
|
79
|
+
this.#exits.set(ip, {
|
|
80
|
+
fails: Number(r.fails) || 0,
|
|
81
|
+
failed: !!r.failed,
|
|
82
|
+
reachable: !!r.reachable,
|
|
83
|
+
ping: typeof r.ping === "number" ? r.ping : null,
|
|
84
|
+
lastProbeAt: Number(r.lastProbeAt) || 0,
|
|
85
|
+
nextProbeAt: Number(r.nextProbeAt) || 0,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
this.#fallback = f?.fallback && typeof f.fallback.from === "string" ? f.fallback : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// A broken state file costs at most three probes per exit, never the app.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#save() {
|
|
95
|
+
if (!this.#path)
|
|
96
|
+
return;
|
|
97
|
+
const body = { version: 1, exits: Object.fromEntries(this.#exits), fallback: this.#fallback };
|
|
98
|
+
try {
|
|
99
|
+
const tmp = `${this.#path}.tmp`;
|
|
100
|
+
writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n");
|
|
101
|
+
renameSync(tmp, this.#path);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Not persisting only means a restart re-learns; keep serving.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
#record(ip) {
|
|
108
|
+
let r = this.#exits.get(ip);
|
|
109
|
+
if (!r) {
|
|
110
|
+
r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0, nextProbeAt: 0 };
|
|
111
|
+
this.#exits.set(ip, r);
|
|
112
|
+
}
|
|
113
|
+
return r;
|
|
114
|
+
}
|
|
115
|
+
view(ip) {
|
|
116
|
+
const r = this.#exits.get(ip);
|
|
117
|
+
if (!r || !r.lastProbeAt) {
|
|
118
|
+
return { ip, status: r?.failed ? "failed" : "unknown", reachable: false, ping: null, fails: r?.fails ?? 0, checkedAt: null };
|
|
119
|
+
}
|
|
120
|
+
const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "retrying" : "unknown";
|
|
121
|
+
return { ip, status, reachable: !r.failed && r.reachable, ping: r.failed ? null : r.ping, fails: r.fails, checkedAt: r.lastProbeAt };
|
|
122
|
+
}
|
|
123
|
+
isFailed(ip) {
|
|
124
|
+
return !!this.#exits.get(ip)?.failed;
|
|
125
|
+
}
|
|
126
|
+
/** True while at least one of `ips` is not failed — i.e. there is an exit
|
|
127
|
+
* the route/proxy feature could still use. None configured = false. */
|
|
128
|
+
usable(ips) {
|
|
129
|
+
return ips.some((ip) => !this.isFailed(ip));
|
|
130
|
+
}
|
|
131
|
+
/** Is an automatic probe of `ip` due now? Failed exits never are. */
|
|
132
|
+
due(ip) {
|
|
133
|
+
const r = this.#exits.get(ip);
|
|
134
|
+
if (!r)
|
|
135
|
+
return true;
|
|
136
|
+
if (r.failed)
|
|
137
|
+
return false;
|
|
138
|
+
return this.#now() >= r.nextProbeAt;
|
|
139
|
+
}
|
|
140
|
+
#apply(ip, ok, ping, manual) {
|
|
141
|
+
const r = this.#record(ip);
|
|
142
|
+
const now = this.#now();
|
|
143
|
+
r.lastProbeAt = now;
|
|
144
|
+
if (ok) {
|
|
145
|
+
r.fails = 0;
|
|
146
|
+
r.failed = false;
|
|
147
|
+
r.reachable = true;
|
|
148
|
+
r.ping = ping;
|
|
149
|
+
r.nextProbeAt = now + EXIT_OK_CACHE_MS;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
r.reachable = false;
|
|
153
|
+
r.ping = null;
|
|
154
|
+
if (manual && r.failed) {
|
|
155
|
+
// A failed Reconnect leaves it failed; it does not start a new cycle.
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
r.fails += 1;
|
|
159
|
+
if (r.fails >= EXIT_MAX_FAILS)
|
|
160
|
+
r.failed = true;
|
|
161
|
+
}
|
|
162
|
+
r.nextProbeAt = r.failed ? Number.MAX_SAFE_INTEGER : now + EXIT_RETRY_BACKOFF_MS[Math.min(r.fails - 1, EXIT_RETRY_BACKOFF_MS.length - 1)];
|
|
163
|
+
}
|
|
164
|
+
this.#save();
|
|
165
|
+
}
|
|
166
|
+
#run(ip, manual) {
|
|
167
|
+
const running = this.#inFlight.get(ip);
|
|
168
|
+
if (running)
|
|
169
|
+
return running;
|
|
170
|
+
const p = this.#probe(ip)
|
|
171
|
+
.catch(() => ({ ok: false, ping: null }))
|
|
172
|
+
.then(({ ok, ping }) => this.#apply(ip, ok, ping, manual))
|
|
173
|
+
.finally(() => this.#inFlight.delete(ip));
|
|
174
|
+
this.#inFlight.set(ip, p);
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
177
|
+
/** Probe every exit in `ips` that is due (never a failed one, never one
|
|
178
|
+
* already being probed). Resolves when those probes are done. */
|
|
179
|
+
async refresh(ips) {
|
|
180
|
+
const due = [...new Set(ips)].filter((ip) => !this.#inFlight.has(ip) && this.due(ip));
|
|
181
|
+
await Promise.all(due.map((ip) => this.#run(ip, false)));
|
|
182
|
+
}
|
|
183
|
+
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
184
|
+
* the exit normal again; a failure leaves a failed exit failed. */
|
|
185
|
+
async reconnect(ip) {
|
|
186
|
+
const running = this.#inFlight.get(ip);
|
|
187
|
+
if (running)
|
|
188
|
+
await running;
|
|
189
|
+
if (!this.view(ip).reachable)
|
|
190
|
+
await this.#run(ip, true);
|
|
191
|
+
return this.view(ip);
|
|
192
|
+
}
|
|
193
|
+
get fallback() {
|
|
194
|
+
return this.#fallback;
|
|
195
|
+
}
|
|
196
|
+
setFallback(f) {
|
|
197
|
+
this.#fallback = f;
|
|
198
|
+
this.#save();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The exits a routes.yaml `default` sends traffic through: an exit ip names
|
|
202
|
+
* itself, a region name names its routes.yaml exits plus the shipped exits of
|
|
203
|
+
* that region. "direct" / empty → none. */
|
|
204
|
+
export function exitsForTarget(target, regions, shipped, knownIps) {
|
|
205
|
+
if (!target || target === "direct")
|
|
206
|
+
return [];
|
|
207
|
+
const out = new Set();
|
|
208
|
+
for (const ip of knownIps)
|
|
209
|
+
if (ip === target)
|
|
210
|
+
out.add(ip);
|
|
211
|
+
for (const r of regions)
|
|
212
|
+
if (r.name === target)
|
|
213
|
+
for (const ip of r.exits ?? [])
|
|
214
|
+
out.add(ip);
|
|
215
|
+
for (const e of shipped)
|
|
216
|
+
if (e.region === target && e.virtual_ip)
|
|
217
|
+
out.add(e.virtual_ip);
|
|
218
|
+
return [...out];
|
|
219
|
+
}
|
|
220
|
+
/** routes.yaml text with its top-level `default:` set to `value`, keeping
|
|
221
|
+
* every other line (and comment) as written. Returns null when there is no
|
|
222
|
+
* top-level `default:` line to change. */
|
|
223
|
+
export function setRoutesDefault(text, value) {
|
|
224
|
+
const re = /^default:[^\n]*$/m;
|
|
225
|
+
if (!re.test(text))
|
|
226
|
+
return null;
|
|
227
|
+
return text.replace(re, `default: ${value}`);
|
|
228
|
+
}
|
package/dist/exits.d.ts
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type IpcRequest, type IpcResponse } from "./ipc.js";
|
|
2
|
+
import { type ProbeFn } from "./exit-probe.js";
|
|
2
3
|
/** How this copy of beagle got onto disk — decides what "update" can do.
|
|
3
4
|
* npx cache paths contain _npx AND node_modules, so test _npx first. */
|
|
4
5
|
export declare function installMode(): "global" | "npx" | "dev" | "unknown";
|
|
@@ -36,6 +37,12 @@ export interface BeagleServerOptions {
|
|
|
36
37
|
* requests, shown as the first item of that chat. Beagle's own file (see
|
|
37
38
|
* friend-greetings.ts). Undefined = greetings are not kept. */
|
|
38
39
|
greetingsPath?: string;
|
|
40
|
+
/** Where the exit probe state (failure counts, failed exits) is kept.
|
|
41
|
+
* Default: beagle-exit-state.json next to routes.yaml. */
|
|
42
|
+
exitStatePath?: string;
|
|
43
|
+
/** How an exit is probed. Default: a TCP handshake to its :8888. Tests
|
|
44
|
+
* inject a counter here. */
|
|
45
|
+
exitProbe?: ProbeFn;
|
|
39
46
|
/** The swappable backend, so the UI can show which one is live and drive the
|
|
40
47
|
* LAN handoff. Optional: a caller that pins one backend just omits it and
|
|
41
48
|
* the network panel falls back to "no virtual LAN here". */
|
package/dist/server.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
// empty because the daemon accepts immediately.
|
|
14
14
|
//
|
|
15
15
|
import http from "node:http";
|
|
16
|
-
import net from "node:net";
|
|
17
16
|
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream, realpathSync } from "node:fs";
|
|
18
17
|
import { fileURLToPath } from "node:url";
|
|
19
18
|
import { dirname, join } from "node:path";
|
|
@@ -21,6 +20,7 @@ import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
|
|
|
21
20
|
import yaml from "js-yaml";
|
|
22
21
|
import { probeDaemon } from "./ipc.js";
|
|
23
22
|
import { DEFAULT_EXITS } from "./exits.js";
|
|
23
|
+
import { ExitProber, exitsForTarget, setRoutesDefault } from "./exit-probe.js";
|
|
24
24
|
import * as peerAddr from "@decentnetwork/peer";
|
|
25
25
|
import { bridgeRoster } from "./bridge-roster.js";
|
|
26
26
|
import { isSupervised } from "./relaunch.js";
|
|
@@ -35,11 +35,31 @@ import { globalPrefixOf } from "./global-prefix.js";
|
|
|
35
35
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
36
36
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
37
37
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
38
|
+
/** How an exit region is shown to users. The keys are routing keys (routes.yaml
|
|
39
|
+
* region names, the router's built-in domain lists) and stay as they are; the
|
|
40
|
+
* flag and label are display only. App strings never carry PRC place names
|
|
41
|
+
* (CLAUDE.md), so the "china" region is shown as East Asia. */
|
|
42
|
+
const REGION_META = {
|
|
43
|
+
china: { flag: "EA", label: "East Asia" },
|
|
44
|
+
japan: { flag: "JP", label: "Japan" },
|
|
45
|
+
us: { flag: "US", label: "United States" },
|
|
46
|
+
};
|
|
47
|
+
/** Set routes.yaml's top-level `default:` to `value`, keeping the rest of the
|
|
48
|
+
* file as written. False when there is no file or no such line to change. */
|
|
49
|
+
function writeRoutesDefault(path, value) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return false;
|
|
53
|
+
const next = setRoutesDefault(readFileSync(path, "utf-8"), value);
|
|
54
|
+
if (next === null)
|
|
55
|
+
return false;
|
|
56
|
+
writeFileSync(path, next, "utf-8");
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
43
63
|
/** Server-side fetch for the discovery directories (leaderboard / ENS names).
|
|
44
64
|
* Successes are cached, FAILURES ARE TOO, and concurrent callers share one
|
|
45
65
|
* request — see discover-cache.ts for the outage this comes from. */
|
|
@@ -949,6 +969,12 @@ export function startBeagleServer(opts) {
|
|
|
949
969
|
const port = opts.listenPort ?? 8765;
|
|
950
970
|
const log = opts.log ?? ((s) => console.log(s));
|
|
951
971
|
const greetings = opts.greetingsPath ? new GreetingStore(opts.greetingsPath) : null;
|
|
972
|
+
// Exit reachability with memory (see exit-probe.ts). One per server, state
|
|
973
|
+
// kept next to routes.yaml so a restart does not re-probe dead exits.
|
|
974
|
+
const exitProber = new ExitProber({
|
|
975
|
+
path: opts.exitStatePath ?? join(dirname(opts.routesPath), "beagle-exit-state.json"),
|
|
976
|
+
probe: opts.exitProbe,
|
|
977
|
+
});
|
|
952
978
|
const sendJson = (res, code, body) => {
|
|
953
979
|
res.writeHead(code, { "content-type": "application/json" });
|
|
954
980
|
res.end(JSON.stringify(body));
|
|
@@ -2099,7 +2125,8 @@ export function startBeagleServer(opts) {
|
|
|
2099
2125
|
channel: opts.meExtra?.channel ?? "@next",
|
|
2100
2126
|
wire: opts.meExtra?.wire ?? "163",
|
|
2101
2127
|
isExit: !!meExit,
|
|
2102
|
-
|
|
2128
|
+
// The display tag, not the routing key (see REGION_META).
|
|
2129
|
+
exitRegion: meExit?.region ? REGION_META[meExit.region]?.flag ?? meExit.region : null,
|
|
2103
2130
|
// null = backend didn't report it (daemon); the UI then hides the
|
|
2104
2131
|
// toggle rather than showing a state it cannot know.
|
|
2105
2132
|
autoAccept: typeof node.autoAccept === "boolean" ? node.autoAccept : null,
|
|
@@ -2205,11 +2232,6 @@ export function startBeagleServer(opts) {
|
|
|
2205
2232
|
onlineIps.add(ip);
|
|
2206
2233
|
}
|
|
2207
2234
|
}
|
|
2208
|
-
const regionMeta = {
|
|
2209
|
-
china: { flag: "CN", label: "China" },
|
|
2210
|
-
japan: { flag: "JP", label: "Japan" },
|
|
2211
|
-
us: { flag: "US", label: "United States" },
|
|
2212
|
-
};
|
|
2213
2235
|
const byRegion = new Map();
|
|
2214
2236
|
for (const e of DEFAULT_EXITS) {
|
|
2215
2237
|
if (!e.virtual_ip)
|
|
@@ -2219,7 +2241,7 @@ export function startBeagleServer(opts) {
|
|
|
2219
2241
|
byRegion.set(region, []);
|
|
2220
2242
|
byRegion.get(region).push({
|
|
2221
2243
|
ip: e.virtual_ip,
|
|
2222
|
-
host: e.name,
|
|
2244
|
+
host: e.label ?? e.name,
|
|
2223
2245
|
online: onlineUserids.has(e.userid) || onlineIps.has(e.virtual_ip),
|
|
2224
2246
|
reachable: false,
|
|
2225
2247
|
ping: null,
|
|
@@ -2240,41 +2262,56 @@ export function startBeagleServer(opts) {
|
|
|
2240
2262
|
// REACHABILITY (data plane): presence "online" only means the peer is
|
|
2241
2263
|
// announcing on the DHT — it does NOT mean IP packets get through (a
|
|
2242
2264
|
// desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
|
|
2243
|
-
// e.g. GFAX).
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2265
|
+
// e.g. GFAX). The proof is a TCP handshake to the exit's CONNECT-proxy
|
|
2266
|
+
// port — but see exit-probe.ts: probes are cached and backed off, a dead
|
|
2267
|
+
// exit goes `failed` after three tries and is never probed again on its
|
|
2268
|
+
// own, and nothing is probed at all unless the network panel is open
|
|
2269
|
+
// (?exits=1) or routing actually goes through an exit.
|
|
2246
2270
|
const allNodes = [...byRegion.values()].flat();
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2271
|
+
const allIps = allNodes.map((n) => n.ip);
|
|
2272
|
+
const qs = new URL(req.url || "", "http://localhost").searchParams;
|
|
2273
|
+
const panelOpen = qs.get("exits") === "1";
|
|
2274
|
+
const routeTarget = routes.default && routes.default !== "direct" ? routes.default : null;
|
|
2275
|
+
if (panelOpen || routeTarget)
|
|
2276
|
+
await exitProber.refresh(allIps);
|
|
2277
|
+
const views = new Map(allIps.map((ip) => [ip, exitProber.view(ip)]));
|
|
2278
|
+
for (const n of allNodes) {
|
|
2279
|
+
const v = views.get(n.ip);
|
|
2280
|
+
Object.assign(n, { reachable: v.reachable, ping: v.ping, status: v.status, fails: v.fails, checkedAt: v.checkedAt });
|
|
2281
|
+
}
|
|
2282
|
+
// routes.default on an exit whose every node failed would black-hole
|
|
2283
|
+
// the traffic it sends there: switch the default to direct (and say so)
|
|
2284
|
+
// until a Reconnect brings one of them back.
|
|
2285
|
+
let activeExit = routeTarget;
|
|
2286
|
+
if (routeTarget) {
|
|
2287
|
+
const targetIps = exitsForTarget(routeTarget, routes.regions ?? [], DEFAULT_EXITS, allIps);
|
|
2288
|
+
if (targetIps.length && targetIps.every((ip) => exitProber.isFailed(ip))) {
|
|
2289
|
+
if (writeRoutesDefault(opts.routesPath, "direct")) {
|
|
2290
|
+
exitProber.setFallback({ from: routeTarget, at: Date.now() });
|
|
2291
|
+
log(`[exits] every exit of routes.default "${routeTarget}" failed — default set to direct`);
|
|
2292
|
+
activeExit = null;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// The user (or a Reconnect) put routing back on an exit: the old
|
|
2297
|
+
// "fell back to direct" notice no longer describes anything.
|
|
2298
|
+
if (activeExit && exitProber.fallback)
|
|
2299
|
+
exitProber.setFallback(null);
|
|
2300
|
+
const usable = exitProber.usable(allIps);
|
|
2301
|
+
const exitFeature = {
|
|
2302
|
+
available: usable,
|
|
2303
|
+
reason: usable ? null : allIps.length ? "No exit available — every exit failed. Reconnect one to use routing." : "No exit available",
|
|
2304
|
+
};
|
|
2268
2305
|
const exits = [...byRegion.entries()].map(([region, nodes]) => ({
|
|
2269
2306
|
region,
|
|
2270
|
-
flag:
|
|
2271
|
-
label:
|
|
2307
|
+
flag: REGION_META[region]?.flag ?? region.slice(0, 2).toUpperCase(),
|
|
2308
|
+
label: REGION_META[region]?.label ?? region,
|
|
2272
2309
|
nodes,
|
|
2273
2310
|
reachableCount: nodes.filter((n) => n.reachable).length,
|
|
2274
2311
|
unreachableCount: nodes.filter((n) => !n.reachable).length,
|
|
2312
|
+
failedCount: nodes.filter((n) => n.status === "failed").length,
|
|
2275
2313
|
}));
|
|
2276
|
-
|
|
2277
|
-
sendJson(res, 200, { me, peers, requests, exits, activeExit });
|
|
2314
|
+
sendJson(res, 200, { me, peers, requests, exits, activeExit, exitFeature, routeFallback: exitProber.fallback });
|
|
2278
2315
|
return;
|
|
2279
2316
|
}
|
|
2280
2317
|
// The locally adopted service peers: the app ships a list, and this is
|
|
@@ -2613,6 +2650,42 @@ export function startBeagleServer(opts) {
|
|
|
2613
2650
|
}
|
|
2614
2651
|
return;
|
|
2615
2652
|
}
|
|
2653
|
+
// The network panel's Reconnect: probe a failed exit once, now. Only the
|
|
2654
|
+
// exits this node knows about — the page does not get to make the daemon
|
|
2655
|
+
// dial arbitrary hosts. {all:true} = every failed exit.
|
|
2656
|
+
if (req.method === "POST" && url === "/api/exits/reconnect") {
|
|
2657
|
+
const body = (await readBody(req));
|
|
2658
|
+
let routes = { regions: [], default: "direct" };
|
|
2659
|
+
if (existsSync(opts.routesPath)) {
|
|
2660
|
+
try {
|
|
2661
|
+
routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
|
|
2662
|
+
}
|
|
2663
|
+
catch { /* keep direct */ }
|
|
2664
|
+
}
|
|
2665
|
+
const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
|
|
2666
|
+
for (const r of routes.regions ?? [])
|
|
2667
|
+
for (const ip of r.exits ?? [])
|
|
2668
|
+
known.add(ip);
|
|
2669
|
+
const targets = body.all
|
|
2670
|
+
? [...known].filter((ip) => exitProber.isFailed(ip))
|
|
2671
|
+
: typeof body.ip === "string" && known.has(body.ip) ? [body.ip] : [];
|
|
2672
|
+
if (!body.all && !targets.length)
|
|
2673
|
+
return sendJson(res, 400, { ok: false, error: "unknown exit" });
|
|
2674
|
+
const results = await Promise.all(targets.map((ip) => exitProber.reconnect(ip)));
|
|
2675
|
+
// Routing that fell back to direct goes back once one of its exits works.
|
|
2676
|
+
const fb = exitProber.fallback;
|
|
2677
|
+
let restored = null;
|
|
2678
|
+
if (fb) {
|
|
2679
|
+
const fbIps = exitsForTarget(fb.from, routes.regions ?? [], DEFAULT_EXITS, known);
|
|
2680
|
+
if (fbIps.some((ip) => exitProber.view(ip).reachable)) {
|
|
2681
|
+
if ((routes.default ?? "direct") === "direct" && writeRoutesDefault(opts.routesPath, fb.from))
|
|
2682
|
+
restored = fb.from;
|
|
2683
|
+
exitProber.setFallback(null);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2616
2689
|
if (req.method === "GET" && url === "/api/routes") {
|
|
2617
2690
|
let routes = { regions: [], default: "direct" };
|
|
2618
2691
|
if (existsSync(opts.routesPath)) {
|
|
@@ -2837,8 +2910,8 @@ const PAGE = `<!doctype html>
|
|
|
2837
2910
|
<div id="exits"></div>
|
|
2838
2911
|
<div class="add">
|
|
2839
2912
|
<input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
|
|
2840
|
-
<input id="exitRegion" placeholder="region (
|
|
2841
|
-
<datalist id="regionList"><option>
|
|
2913
|
+
<input id="exitRegion" placeholder="region (e.g. japan, us)" autocomplete="off" list="regionList" style="flex:1"/>
|
|
2914
|
+
<datalist id="regionList"><option>japan</option><option>us</option></datalist>
|
|
2842
2915
|
<button onclick="addExit()">Add exit</button>
|
|
2843
2916
|
</div>
|
|
2844
2917
|
|
|
@@ -2955,6 +3028,8 @@ async function sendChat(){
|
|
|
2955
3028
|
document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
|
|
2956
3029
|
|
|
2957
3030
|
// ---- Exit nodes (routes.yaml) ----
|
|
3031
|
+
// Display names for routing keys; the key itself stays in routes.yaml.
|
|
3032
|
+
const REGION_LABEL = { china: 'East Asia' };
|
|
2958
3033
|
let routesObj = { regions: [], default: 'direct' };
|
|
2959
3034
|
async function refreshExits(){
|
|
2960
3035
|
let r; try { r = await api('/api/routes'); } catch(e){ return; }
|
|
@@ -2965,7 +3040,7 @@ async function refreshExits(){
|
|
|
2965
3040
|
document.getElementById('ecount').textContent = total ? '('+total+')' : '';
|
|
2966
3041
|
document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
|
|
2967
3042
|
<div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
|
|
2968
|
-
<div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
|
|
3043
|
+
<div class="name">\${esc(REGION_LABEL[rg.name]||rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
|
|
2969
3044
|
\${(rg.exits||[]).map(ip=>\`<div class="sub" style="display:flex; gap:.5rem; align-items:center"><span style="flex:1">\${esc(ip)}</span><button class="reject" onclick="removeExit('\${esc(rg.name)}','\${esc(ip)}')">×</button></div>\`).join('')}
|
|
2970
3045
|
</div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
|
|
2971
3046
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.129",
|
|
4
4
|
"description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"start": "node dist/cli.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@decentnetwork/beagle-ui": "^0.2.
|
|
29
|
+
"@decentnetwork/beagle-ui": "^0.2.37",
|
|
30
30
|
"@decentnetwork/chat-components": "^0.1.3",
|
|
31
31
|
"@decentnetwork/lan": "^0.1.313",
|
|
32
32
|
"@decentnetwork/peer": "^0.1.170",
|