@decentnetwork/beagle 0.1.128 → 0.1.130
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/desktop/app.js +27 -11
- package/dist/exit-probe.d.ts +16 -11
- package/dist/exit-probe.js +35 -29
- package/dist/exits.d.ts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +83 -41
- 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/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.130";
|
|
2
2
|
"use strict";
|
|
3
3
|
(() => {
|
|
4
4
|
// packages/beagle-ui/dist/beagle-ui.js
|
|
@@ -446,6 +446,8 @@ window.__DK_UI_VERSION="0.1.128";
|
|
|
446
446
|
// that failed three times in a row are never probed again on their own.
|
|
447
447
|
reconnectExit: (ip) => dkPost("/api/exits/reconnect", { ip }),
|
|
448
448
|
reconnectAllExits: () => dkPost("/api/exits/reconnect", { all: true }),
|
|
449
|
+
// Every exit once, now, failed ones included.
|
|
450
|
+
refreshExits: () => dkPost("/api/exits/refresh", {}),
|
|
449
451
|
// Used by the browser's welcome flow, which also picks an avatar and marks
|
|
450
452
|
// first run done. Harmless where there is no welcome flow: nothing calls it.
|
|
451
453
|
setProfileFull: (p) => dkPost("/api/set-profile", p),
|
|
@@ -585,6 +587,7 @@ window.__DK_UI_VERSION="0.1.128";
|
|
|
585
587
|
const wantExits = !!(opts && opts.exits);
|
|
586
588
|
const wantExitsRef = React.useRef(wantExits);
|
|
587
589
|
wantExitsRef.current = wantExits;
|
|
590
|
+
const exitsOpenedRef = React.useRef(false);
|
|
588
591
|
const [snap, setSnap] = React.useState({
|
|
589
592
|
me: DK_ME_FALLBACK,
|
|
590
593
|
peers: [],
|
|
@@ -601,7 +604,12 @@ window.__DK_UI_VERSION="0.1.128";
|
|
|
601
604
|
const refresh = React.useCallback(async () => {
|
|
602
605
|
var _a, _b, _c;
|
|
603
606
|
try {
|
|
604
|
-
|
|
607
|
+
let path = "/api/desktop";
|
|
608
|
+
if (wantExitsRef.current) {
|
|
609
|
+
path += exitsOpenedRef.current ? "?exits=1&open=1" : "?exits=1";
|
|
610
|
+
exitsOpenedRef.current = false;
|
|
611
|
+
}
|
|
612
|
+
const d = await dkGet(path);
|
|
605
613
|
if (d && d.locked) {
|
|
606
614
|
setSnap((s) => ({ ...s, locked: true, loaded: true }));
|
|
607
615
|
return;
|
|
@@ -633,8 +641,10 @@ window.__DK_UI_VERSION="0.1.128";
|
|
|
633
641
|
return () => clearInterval(t);
|
|
634
642
|
}, [refresh]);
|
|
635
643
|
React.useEffect(() => {
|
|
636
|
-
if (wantExits)
|
|
644
|
+
if (wantExits) {
|
|
645
|
+
exitsOpenedRef.current = true;
|
|
637
646
|
refresh();
|
|
647
|
+
}
|
|
638
648
|
}, [wantExits, refresh]);
|
|
639
649
|
const inFlight = React.useRef(/* @__PURE__ */ new Set());
|
|
640
650
|
const cache = React.useRef({});
|
|
@@ -6169,7 +6179,7 @@ ${peer.address}`
|
|
|
6169
6179
|
const stuck = n.online && !n.reachable;
|
|
6170
6180
|
const failed = n.status === "failed";
|
|
6171
6181
|
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
|
|
6182
|
+
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; Reconnect to try again" }, 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 === "down" ? /* @__PURE__ */ React.createElement(Tag, { tone: "warn", title: "No answer \u2014 checked again only when you open this panel or press Refresh; 3 in a row and it is no longer checked" }, T.exitDown || "down", " \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));
|
|
6173
6183
|
})));
|
|
6174
6184
|
}
|
|
6175
6185
|
function LanEnableCard({ backend, onArm, onCancel }) {
|
|
@@ -6273,14 +6283,14 @@ ${peer.address}`
|
|
|
6273
6283
|
"\u53D6\u6D88,\u6062\u590D\u6D88\u606F"
|
|
6274
6284
|
)));
|
|
6275
6285
|
}
|
|
6276
|
-
function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6286
|
+
function NetworkTab({ T, me, peers, exits, activeExit, exitFeature, routeFallback, onReconnectExit, onRefreshExits, reqCount, onSetExit, onOpenChat, backend, onArmLan, onCancelLan }) {
|
|
6277
6287
|
const featureOn = !exitFeature || exitFeature.available !== false;
|
|
6278
6288
|
const anyFailed = (exits || []).some((r) => (r.nodes || []).some((n) => n.status === "failed"));
|
|
6279
6289
|
if (backend && backend.switchable && !backend.hasVirtualLan) {
|
|
6280
6290
|
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 })));
|
|
6281
6291
|
}
|
|
6282
6292
|
const lanless = !!backend && backend.hasVirtualLan === false;
|
|
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 }))))));
|
|
6293
|
+
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 } }, onRefreshExits && (exits || []).length > 0 && /* @__PURE__ */ React.createElement(ReconnectBtn, { T, ip: null, onReconnect: () => onRefreshExits(), label: T.exitRefresh || "Refresh" }), 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 }))))));
|
|
6284
6294
|
}
|
|
6285
6295
|
Object.assign(window, { NetworkTab });
|
|
6286
6296
|
var dkAudioCtx = null;
|
|
@@ -6697,9 +6707,10 @@ ${peer.address}`
|
|
|
6697
6707
|
exitReconnectAll: "Reconnect all",
|
|
6698
6708
|
exitChecking: "checking\u2026",
|
|
6699
6709
|
exitNotChecked: "not checked",
|
|
6700
|
-
|
|
6710
|
+
exitDown: "down",
|
|
6711
|
+
exitRefresh: "Refresh",
|
|
6701
6712
|
noExitAvailable: "No exit available \u2014 routing and proxy are off.",
|
|
6702
|
-
noExitHint: "Every exit failed 3
|
|
6713
|
+
noExitHint: "Every exit failed 3 checks in a row and is no longer checked. Press Reconnect to try one again.",
|
|
6703
6714
|
routeFellBack: "Routing fell back to direct: every exit it used failed. Reconnect one to route through it again.",
|
|
6704
6715
|
identity: "Identity",
|
|
6705
6716
|
userId: "user id",
|
|
@@ -6908,9 +6919,10 @@ ${peer.address}`
|
|
|
6908
6919
|
exitReconnectAll: "\u5168\u90E8\u91CD\u8FDE",
|
|
6909
6920
|
exitChecking: "\u68C0\u6D4B\u4E2D\u2026",
|
|
6910
6921
|
exitNotChecked: "\u672A\u68C0\u6D4B",
|
|
6911
|
-
|
|
6922
|
+
exitDown: "\u4E0D\u901A",
|
|
6923
|
+
exitRefresh: "\u5237\u65B0",
|
|
6912
6924
|
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
|
|
6925
|
+
noExitHint: "\u6240\u6709\u51FA\u53E3\u90FD\u8FDE\u7EED 3 \u6B21\u68C0\u6D4B\u5931\u8D25,\u5DF2\u4E0D\u518D\u68C0\u6D4B\u3002\u70B9\u300C\u91CD\u65B0\u8FDE\u63A5\u300D\u518D\u8BD5\u4E00\u6B21\u3002",
|
|
6914
6926
|
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",
|
|
6915
6927
|
identity: "\u8EAB\u4EFD",
|
|
6916
6928
|
userId: "\u7528\u6237 ID",
|
|
@@ -7573,6 +7585,10 @@ ${peer.address}`
|
|
|
7573
7585
|
data.refresh();
|
|
7574
7586
|
return r;
|
|
7575
7587
|
});
|
|
7588
|
+
const onRefreshExits = () => dkApi.refreshExits().then((r) => {
|
|
7589
|
+
data.refresh();
|
|
7590
|
+
return r;
|
|
7591
|
+
});
|
|
7576
7592
|
const onOpenChat = (id) => {
|
|
7577
7593
|
setTab("chat");
|
|
7578
7594
|
openConversation(id);
|
|
@@ -7652,7 +7668,7 @@ ${peer.address}`
|
|
|
7652
7668
|
fontWeight: 600,
|
|
7653
7669
|
background: tab === n.id ? "var(--accent)" : "transparent",
|
|
7654
7670
|
color: tab === n.id ? "#fff" : "var(--dim)"
|
|
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(
|
|
7671
|
+
} }, /* @__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, onRefreshExits, 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(
|
|
7656
7672
|
ProfileTab,
|
|
7657
7673
|
{
|
|
7658
7674
|
T,
|
package/dist/exit-probe.d.ts
CHANGED
|
@@ -2,11 +2,12 @@ export declare const EXIT_PROBE_PORT = 8888;
|
|
|
2
2
|
export declare const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
3
3
|
/** Consecutive failed probes after which an exit is `failed`. */
|
|
4
4
|
export declare const EXIT_MAX_FAILS = 3;
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
5
|
+
/** Panel polls (?exits=1) closer together than this are one panel visit, so
|
|
6
|
+
* they cost one probe round, not one per poll. Long, because a background
|
|
7
|
+
* browser tab is throttled to a poll a minute and must not count as
|
|
8
|
+
* re-opening the panel each time. Re-opening the tab says so explicitly. */
|
|
9
|
+
export declare const EXIT_PANEL_SESSION_GAP_MS: number;
|
|
10
|
+
export type ExitStatus = "unknown" | "ok" | "down" | "failed";
|
|
10
11
|
export interface ExitRecord {
|
|
11
12
|
/** Consecutive failed probes (reset by a success). */
|
|
12
13
|
fails: number;
|
|
@@ -14,8 +15,6 @@ export interface ExitRecord {
|
|
|
14
15
|
reachable: boolean;
|
|
15
16
|
ping: number | null;
|
|
16
17
|
lastProbeAt: number;
|
|
17
|
-
/** No automatic probe before this time. */
|
|
18
|
-
nextProbeAt: number;
|
|
19
18
|
}
|
|
20
19
|
export interface ExitView {
|
|
21
20
|
ip: string;
|
|
@@ -54,14 +53,20 @@ export declare class ExitProber {
|
|
|
54
53
|
/** True while at least one of `ips` is not failed — i.e. there is an exit
|
|
55
54
|
* the route/proxy feature could still use. None configured = false. */
|
|
56
55
|
usable(ips: string[]): boolean;
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
56
|
+
/** A network-panel poll arrived (?exits=1). True when it is a new visit —
|
|
57
|
+
* `opened` (the UI's first poll after the tab opens) or the first poll
|
|
58
|
+
* after a long gap — and so earns one probe round. Every other poll of
|
|
59
|
+
* the same visit is false: an open panel costs nothing. */
|
|
60
|
+
panelPoll(opened: boolean): boolean;
|
|
61
|
+
/** One access: probe every exit in `ips` once — never a failed one, never
|
|
62
|
+
* one already being probed. Resolves when those probes are done. */
|
|
61
63
|
refresh(ips: string[]): Promise<void>;
|
|
62
64
|
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
63
65
|
* the exit normal again; a failure leaves a failed exit failed. */
|
|
64
66
|
reconnect(ip: string): Promise<ExitView>;
|
|
67
|
+
/** The Refresh button: every exit once, failed ones included (with
|
|
68
|
+
* Reconnect's rule: a failed one that fails again stays failed). */
|
|
69
|
+
refreshAll(ips: string[]): Promise<ExitView[]>;
|
|
65
70
|
get fallback(): RouteFallback | null;
|
|
66
71
|
setFallback(f: RouteFallback | null): void;
|
|
67
72
|
}
|
package/dist/exit-probe.js
CHANGED
|
@@ -7,22 +7,25 @@
|
|
|
7
7
|
// of SYNs to seven hosts that never answered — whether or not routing used an
|
|
8
8
|
// exit and whether or not anyone was looking at the panel.
|
|
9
9
|
//
|
|
10
|
-
// The rules this file holds:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// -
|
|
10
|
+
// The rules this file holds (Weili 2026-11-05: "有访问的时候再查,联系失败后停止查询"
|
|
11
|
+
// — check when something is accessed, stop checking once contact fails):
|
|
12
|
+
// - nothing here runs on a timer. An exit is probed only on an access: the
|
|
13
|
+
// network panel being OPENED (not while it stays open — see panelPoll),
|
|
14
|
+
// a Refresh / Reconnect press, or routing being set to go through it;
|
|
15
|
+
// - each failed access is one failure; MAX_FAILS in a row and the exit is
|
|
16
|
+
// `failed` and only Reconnect / Refresh probes it again. A success resets;
|
|
17
|
+
// - the record lives on disk, so a restart does not start over.
|
|
16
18
|
import net from "node:net";
|
|
17
19
|
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
18
20
|
export const EXIT_PROBE_PORT = 8888;
|
|
19
21
|
export const EXIT_PROBE_TIMEOUT_MS = 2000;
|
|
20
22
|
/** Consecutive failed probes after which an exit is `failed`. */
|
|
21
23
|
export const EXIT_MAX_FAILS = 3;
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
/** Panel polls (?exits=1) closer together than this are one panel visit, so
|
|
25
|
+
* they cost one probe round, not one per poll. Long, because a background
|
|
26
|
+
* browser tab is throttled to a poll a minute and must not count as
|
|
27
|
+
* re-opening the panel each time. Re-opening the tab says so explicitly. */
|
|
28
|
+
export const EXIT_PANEL_SESSION_GAP_MS = 5 * 60_000;
|
|
26
29
|
/** One TCP handshake to the exit's CONNECT-proxy port. A completed handshake
|
|
27
30
|
* proves the mesh path and the exit process are both live. ECONNREFUSED means
|
|
28
31
|
* the host answered (L3 reachable) but nothing listens — still reachable for
|
|
@@ -62,6 +65,7 @@ export class ExitProber {
|
|
|
62
65
|
#exits = new Map();
|
|
63
66
|
#fallback = null;
|
|
64
67
|
#inFlight = new Map();
|
|
68
|
+
#lastPanelPoll = -Infinity;
|
|
65
69
|
constructor(opts = {}) {
|
|
66
70
|
this.#path = opts.path;
|
|
67
71
|
this.#probe = opts.probe ?? ((ip) => tcpProbe(ip));
|
|
@@ -82,7 +86,6 @@ export class ExitProber {
|
|
|
82
86
|
reachable: !!r.reachable,
|
|
83
87
|
ping: typeof r.ping === "number" ? r.ping : null,
|
|
84
88
|
lastProbeAt: Number(r.lastProbeAt) || 0,
|
|
85
|
-
nextProbeAt: Number(r.nextProbeAt) || 0,
|
|
86
89
|
});
|
|
87
90
|
}
|
|
88
91
|
this.#fallback = f?.fallback && typeof f.fallback.from === "string" ? f.fallback : null;
|
|
@@ -107,7 +110,7 @@ export class ExitProber {
|
|
|
107
110
|
#record(ip) {
|
|
108
111
|
let r = this.#exits.get(ip);
|
|
109
112
|
if (!r) {
|
|
110
|
-
r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0
|
|
113
|
+
r = { fails: 0, failed: false, reachable: false, ping: null, lastProbeAt: 0 };
|
|
111
114
|
this.#exits.set(ip, r);
|
|
112
115
|
}
|
|
113
116
|
return r;
|
|
@@ -117,7 +120,7 @@ export class ExitProber {
|
|
|
117
120
|
if (!r || !r.lastProbeAt) {
|
|
118
121
|
return { ip, status: r?.failed ? "failed" : "unknown", reachable: false, ping: null, fails: r?.fails ?? 0, checkedAt: null };
|
|
119
122
|
}
|
|
120
|
-
const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "
|
|
123
|
+
const status = r.failed ? "failed" : r.reachable ? "ok" : r.fails > 0 ? "down" : "unknown";
|
|
121
124
|
return { ip, status, reachable: !r.failed && r.reachable, ping: r.failed ? null : r.ping, fails: r.fails, checkedAt: r.lastProbeAt };
|
|
122
125
|
}
|
|
123
126
|
isFailed(ip) {
|
|
@@ -128,14 +131,15 @@ export class ExitProber {
|
|
|
128
131
|
usable(ips) {
|
|
129
132
|
return ips.some((ip) => !this.isFailed(ip));
|
|
130
133
|
}
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
/** A network-panel poll arrived (?exits=1). True when it is a new visit —
|
|
135
|
+
* `opened` (the UI's first poll after the tab opens) or the first poll
|
|
136
|
+
* after a long gap — and so earns one probe round. Every other poll of
|
|
137
|
+
* the same visit is false: an open panel costs nothing. */
|
|
138
|
+
panelPoll(opened) {
|
|
139
|
+
const now = this.#now();
|
|
140
|
+
const fresh = opened || now - this.#lastPanelPoll > EXIT_PANEL_SESSION_GAP_MS;
|
|
141
|
+
this.#lastPanelPoll = now;
|
|
142
|
+
return fresh;
|
|
139
143
|
}
|
|
140
144
|
#apply(ip, ok, ping, manual) {
|
|
141
145
|
const r = this.#record(ip);
|
|
@@ -146,7 +150,6 @@ export class ExitProber {
|
|
|
146
150
|
r.failed = false;
|
|
147
151
|
r.reachable = true;
|
|
148
152
|
r.ping = ping;
|
|
149
|
-
r.nextProbeAt = now + EXIT_OK_CACHE_MS;
|
|
150
153
|
}
|
|
151
154
|
else {
|
|
152
155
|
r.reachable = false;
|
|
@@ -159,7 +162,6 @@ export class ExitProber {
|
|
|
159
162
|
if (r.fails >= EXIT_MAX_FAILS)
|
|
160
163
|
r.failed = true;
|
|
161
164
|
}
|
|
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
165
|
}
|
|
164
166
|
this.#save();
|
|
165
167
|
}
|
|
@@ -174,11 +176,11 @@ export class ExitProber {
|
|
|
174
176
|
this.#inFlight.set(ip, p);
|
|
175
177
|
return p;
|
|
176
178
|
}
|
|
177
|
-
/**
|
|
178
|
-
* already being probed
|
|
179
|
+
/** One access: probe every exit in `ips` once — never a failed one, never
|
|
180
|
+
* one already being probed. Resolves when those probes are done. */
|
|
179
181
|
async refresh(ips) {
|
|
180
|
-
const
|
|
181
|
-
await Promise.all(
|
|
182
|
+
const todo = [...new Set(ips)].filter((ip) => !this.#inFlight.has(ip) && !this.isFailed(ip));
|
|
183
|
+
await Promise.all(todo.map((ip) => this.#run(ip, false)));
|
|
182
184
|
}
|
|
183
185
|
/** The Reconnect button: one probe now, whatever the state. Success makes
|
|
184
186
|
* the exit normal again; a failure leaves a failed exit failed. */
|
|
@@ -186,10 +188,14 @@ export class ExitProber {
|
|
|
186
188
|
const running = this.#inFlight.get(ip);
|
|
187
189
|
if (running)
|
|
188
190
|
await running;
|
|
189
|
-
|
|
190
|
-
await this.#run(ip, true);
|
|
191
|
+
await this.#run(ip, true);
|
|
191
192
|
return this.view(ip);
|
|
192
193
|
}
|
|
194
|
+
/** The Refresh button: every exit once, failed ones included (with
|
|
195
|
+
* Reconnect's rule: a failed one that fails again stays failed). */
|
|
196
|
+
async refreshAll(ips) {
|
|
197
|
+
return Promise.all([...new Set(ips)].map((ip) => this.reconnect(ip)));
|
|
198
|
+
}
|
|
193
199
|
get fallback() {
|
|
194
200
|
return this.#fallback;
|
|
195
201
|
}
|
package/dist/exits.d.ts
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface BeagleServerOptions {
|
|
|
43
43
|
/** How an exit is probed. Default: a TCP handshake to its :8888. Tests
|
|
44
44
|
* inject a counter here. */
|
|
45
45
|
exitProbe?: ProbeFn;
|
|
46
|
+
/** Clock for the exit prober (tests). Default Date.now. */
|
|
47
|
+
exitNow?: () => number;
|
|
46
48
|
/** The swappable backend, so the UI can show which one is live and drive the
|
|
47
49
|
* LAN handoff. Optional: a caller that pins one backend just omits it and
|
|
48
50
|
* the network panel falls back to "no virtual LAN here". */
|
package/dist/server.js
CHANGED
|
@@ -35,6 +35,15 @@ 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
|
+
/** 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
|
+
};
|
|
38
47
|
/** Set routes.yaml's top-level `default:` to `value`, keeping the rest of the
|
|
39
48
|
* file as written. False when there is no file or no such line to change. */
|
|
40
49
|
function writeRoutesDefault(path, value) {
|
|
@@ -965,7 +974,48 @@ export function startBeagleServer(opts) {
|
|
|
965
974
|
const exitProber = new ExitProber({
|
|
966
975
|
path: opts.exitStatePath ?? join(dirname(opts.routesPath), "beagle-exit-state.json"),
|
|
967
976
|
probe: opts.exitProbe,
|
|
977
|
+
now: opts.exitNow,
|
|
968
978
|
});
|
|
979
|
+
const readRoutes = () => {
|
|
980
|
+
const none = { regions: [], default: "direct" };
|
|
981
|
+
if (!existsSync(opts.routesPath))
|
|
982
|
+
return none;
|
|
983
|
+
try {
|
|
984
|
+
return yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? none;
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return none;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const knownExitIps = (routes) => {
|
|
991
|
+
const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
|
|
992
|
+
for (const r of routes.regions ?? [])
|
|
993
|
+
for (const ip of r.exits ?? [])
|
|
994
|
+
known.add(ip);
|
|
995
|
+
return known;
|
|
996
|
+
};
|
|
997
|
+
const routeTargetOf = (routes) => routes.default && routes.default !== "direct" ? routes.default : null;
|
|
998
|
+
/** Routing goes through an exit: probe that route's exits once (an access),
|
|
999
|
+
* never again on a timer. */
|
|
1000
|
+
const probeRoute = async (routes) => {
|
|
1001
|
+
const target = routeTargetOf(routes);
|
|
1002
|
+
if (!target)
|
|
1003
|
+
return;
|
|
1004
|
+
await exitProber.refresh(exitsForTarget(target, routes.regions ?? [], DEFAULT_EXITS, knownExitIps(routes)));
|
|
1005
|
+
};
|
|
1006
|
+
/** A fallback to direct whose exits work again goes back to its route. */
|
|
1007
|
+
const restoreFallback = (routes) => {
|
|
1008
|
+
const fb = exitProber.fallback;
|
|
1009
|
+
if (!fb)
|
|
1010
|
+
return null;
|
|
1011
|
+
const fbIps = exitsForTarget(fb.from, routes.regions ?? [], DEFAULT_EXITS, knownExitIps(routes));
|
|
1012
|
+
if (!fbIps.some((ip) => exitProber.view(ip).reachable))
|
|
1013
|
+
return null;
|
|
1014
|
+
exitProber.setFallback(null);
|
|
1015
|
+
return (routes.default ?? "direct") === "direct" && writeRoutesDefault(opts.routesPath, fb.from) ? fb.from : null;
|
|
1016
|
+
};
|
|
1017
|
+
// Daemon start with routing through an exit = one access.
|
|
1018
|
+
void probeRoute(readRoutes()).catch(() => undefined);
|
|
969
1019
|
const sendJson = (res, code, body) => {
|
|
970
1020
|
res.writeHead(code, { "content-type": "application/json" });
|
|
971
1021
|
res.end(JSON.stringify(body));
|
|
@@ -2116,7 +2166,8 @@ export function startBeagleServer(opts) {
|
|
|
2116
2166
|
channel: opts.meExtra?.channel ?? "@next",
|
|
2117
2167
|
wire: opts.meExtra?.wire ?? "163",
|
|
2118
2168
|
isExit: !!meExit,
|
|
2119
|
-
|
|
2169
|
+
// The display tag, not the routing key (see REGION_META).
|
|
2170
|
+
exitRegion: meExit?.region ? REGION_META[meExit.region]?.flag ?? meExit.region : null,
|
|
2120
2171
|
// null = backend didn't report it (daemon); the UI then hides the
|
|
2121
2172
|
// toggle rather than showing a state it cannot know.
|
|
2122
2173
|
autoAccept: typeof node.autoAccept === "boolean" ? node.autoAccept : null,
|
|
@@ -2222,11 +2273,6 @@ export function startBeagleServer(opts) {
|
|
|
2222
2273
|
onlineIps.add(ip);
|
|
2223
2274
|
}
|
|
2224
2275
|
}
|
|
2225
|
-
const regionMeta = {
|
|
2226
|
-
china: { flag: "CN", label: "China" },
|
|
2227
|
-
japan: { flag: "JP", label: "Japan" },
|
|
2228
|
-
us: { flag: "US", label: "United States" },
|
|
2229
|
-
};
|
|
2230
2276
|
const byRegion = new Map();
|
|
2231
2277
|
for (const e of DEFAULT_EXITS) {
|
|
2232
2278
|
if (!e.virtual_ip)
|
|
@@ -2236,7 +2282,7 @@ export function startBeagleServer(opts) {
|
|
|
2236
2282
|
byRegion.set(region, []);
|
|
2237
2283
|
byRegion.get(region).push({
|
|
2238
2284
|
ip: e.virtual_ip,
|
|
2239
|
-
host: e.name,
|
|
2285
|
+
host: e.label ?? e.name,
|
|
2240
2286
|
online: onlineUserids.has(e.userid) || onlineIps.has(e.virtual_ip),
|
|
2241
2287
|
reachable: false,
|
|
2242
2288
|
ping: null,
|
|
@@ -2258,16 +2304,16 @@ export function startBeagleServer(opts) {
|
|
|
2258
2304
|
// announcing on the DHT — it does NOT mean IP packets get through (a
|
|
2259
2305
|
// desynced/NAT-blocked exit shows "online" while `ping 10.86.x` times out,
|
|
2260
2306
|
// e.g. GFAX). The proof is a TCP handshake to the exit's CONNECT-proxy
|
|
2261
|
-
// port — but see exit-probe.ts:
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2307
|
+
// port — but see exit-probe.ts: nothing is probed on a timer. Opening
|
|
2308
|
+
// the network panel is one probe round (?exits=1&open=1, or the first
|
|
2309
|
+
// ?exits=1 after a long gap); the panel staying open costs nothing; a
|
|
2310
|
+
// dead exit goes `failed` after three failed accesses and only
|
|
2311
|
+
// Reconnect / Refresh touch it again.
|
|
2265
2312
|
const allNodes = [...byRegion.values()].flat();
|
|
2266
2313
|
const allIps = allNodes.map((n) => n.ip);
|
|
2267
2314
|
const qs = new URL(req.url || "", "http://localhost").searchParams;
|
|
2268
|
-
const
|
|
2269
|
-
|
|
2270
|
-
if (panelOpen || routeTarget)
|
|
2315
|
+
const routeTarget = routeTargetOf(routes);
|
|
2316
|
+
if (qs.get("exits") === "1" && exitProber.panelPoll(qs.get("open") === "1"))
|
|
2271
2317
|
await exitProber.refresh(allIps);
|
|
2272
2318
|
const views = new Map(allIps.map((ip) => [ip, exitProber.view(ip)]));
|
|
2273
2319
|
for (const n of allNodes) {
|
|
@@ -2299,8 +2345,8 @@ export function startBeagleServer(opts) {
|
|
|
2299
2345
|
};
|
|
2300
2346
|
const exits = [...byRegion.entries()].map(([region, nodes]) => ({
|
|
2301
2347
|
region,
|
|
2302
|
-
flag:
|
|
2303
|
-
label:
|
|
2348
|
+
flag: REGION_META[region]?.flag ?? region.slice(0, 2).toUpperCase(),
|
|
2349
|
+
label: REGION_META[region]?.label ?? region,
|
|
2304
2350
|
nodes,
|
|
2305
2351
|
reachableCount: nodes.filter((n) => n.reachable).length,
|
|
2306
2352
|
unreachableCount: nodes.filter((n) => !n.reachable).length,
|
|
@@ -2650,34 +2696,24 @@ export function startBeagleServer(opts) {
|
|
|
2650
2696
|
// dial arbitrary hosts. {all:true} = every failed exit.
|
|
2651
2697
|
if (req.method === "POST" && url === "/api/exits/reconnect") {
|
|
2652
2698
|
const body = (await readBody(req));
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
try {
|
|
2656
|
-
routes = yaml.load(readFileSync(opts.routesPath, "utf-8")) ?? routes;
|
|
2657
|
-
}
|
|
2658
|
-
catch { /* keep direct */ }
|
|
2659
|
-
}
|
|
2660
|
-
const known = new Set(DEFAULT_EXITS.flatMap((e) => (e.virtual_ip ? [e.virtual_ip] : [])));
|
|
2661
|
-
for (const r of routes.regions ?? [])
|
|
2662
|
-
for (const ip of r.exits ?? [])
|
|
2663
|
-
known.add(ip);
|
|
2699
|
+
const routes = readRoutes();
|
|
2700
|
+
const known = knownExitIps(routes);
|
|
2664
2701
|
const targets = body.all
|
|
2665
2702
|
? [...known].filter((ip) => exitProber.isFailed(ip))
|
|
2666
2703
|
: typeof body.ip === "string" && known.has(body.ip) ? [body.ip] : [];
|
|
2667
2704
|
if (!body.all && !targets.length)
|
|
2668
2705
|
return sendJson(res, 400, { ok: false, error: "unknown exit" });
|
|
2669
2706
|
const results = await Promise.all(targets.map((ip) => exitProber.reconnect(ip)));
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
}
|
|
2707
|
+
const restored = restoreFallback(routes);
|
|
2708
|
+
sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
// The panel's Refresh: every known exit once, now — failed ones too.
|
|
2712
|
+
if (req.method === "POST" && url === "/api/exits/refresh") {
|
|
2713
|
+
const routes = readRoutes();
|
|
2714
|
+
const known = knownExitIps(routes);
|
|
2715
|
+
const results = await exitProber.refreshAll([...known]);
|
|
2716
|
+
const restored = restoreFallback(routes);
|
|
2681
2717
|
sendJson(res, 200, { ok: true, exits: results, restored, available: exitProber.usable([...known]) });
|
|
2682
2718
|
return;
|
|
2683
2719
|
}
|
|
@@ -2704,7 +2740,11 @@ export function startBeagleServer(opts) {
|
|
|
2704
2740
|
const body = await readBody(req);
|
|
2705
2741
|
try {
|
|
2706
2742
|
const routes = JSON.parse(body.routes ?? "{}");
|
|
2743
|
+
const before = routeTargetOf(readRoutes());
|
|
2707
2744
|
writeFileSync(opts.routesPath, yaml.dump(routes, { lineWidth: -1 }), "utf-8");
|
|
2745
|
+
// Routing just set to go through an exit = one access of its exits.
|
|
2746
|
+
if (routeTargetOf(routes) && routeTargetOf(routes) !== before)
|
|
2747
|
+
void probeRoute(routes).catch(() => undefined);
|
|
2708
2748
|
sendJson(res, 200, { ok: true });
|
|
2709
2749
|
}
|
|
2710
2750
|
catch (err) {
|
|
@@ -2905,8 +2945,8 @@ const PAGE = `<!doctype html>
|
|
|
2905
2945
|
<div id="exits"></div>
|
|
2906
2946
|
<div class="add">
|
|
2907
2947
|
<input id="exitIp" placeholder="exit virtual IP (e.g. 10.86.1.15)" autocomplete="off" style="flex:2"/>
|
|
2908
|
-
<input id="exitRegion" placeholder="region (
|
|
2909
|
-
<datalist id="regionList"><option>
|
|
2948
|
+
<input id="exitRegion" placeholder="region (e.g. japan, us)" autocomplete="off" list="regionList" style="flex:1"/>
|
|
2949
|
+
<datalist id="regionList"><option>japan</option><option>us</option></datalist>
|
|
2910
2950
|
<button onclick="addExit()">Add exit</button>
|
|
2911
2951
|
</div>
|
|
2912
2952
|
|
|
@@ -3023,6 +3063,8 @@ async function sendChat(){
|
|
|
3023
3063
|
document.getElementById('chatInput').addEventListener('keydown', e=>{ if(e.key==='Enter') sendChat(); });
|
|
3024
3064
|
|
|
3025
3065
|
// ---- Exit nodes (routes.yaml) ----
|
|
3066
|
+
// Display names for routing keys; the key itself stays in routes.yaml.
|
|
3067
|
+
const REGION_LABEL = { china: 'East Asia' };
|
|
3026
3068
|
let routesObj = { regions: [], default: 'direct' };
|
|
3027
3069
|
async function refreshExits(){
|
|
3028
3070
|
let r; try { r = await api('/api/routes'); } catch(e){ return; }
|
|
@@ -3033,7 +3075,7 @@ async function refreshExits(){
|
|
|
3033
3075
|
document.getElementById('ecount').textContent = total ? '('+total+')' : '';
|
|
3034
3076
|
document.getElementById('exits').innerHTML = regions.length ? regions.map((rg)=> \`
|
|
3035
3077
|
<div class="row" style="flex-direction:column; align-items:stretch; gap:.3rem">
|
|
3036
|
-
<div class="name">\${esc(rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
|
|
3078
|
+
<div class="name">\${esc(REGION_LABEL[rg.name]||rg.name)} \${(rg.exits||[]).length? '':'<span class="sub">(empty → direct)</span>'}</div>
|
|
3037
3079
|
\${(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('')}
|
|
3038
3080
|
</div>\`).join('') : '<div class="empty">No routes.yaml regions — using auto-discovery.</div>';
|
|
3039
3081
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/beagle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.130",
|
|
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.38",
|
|
30
30
|
"@decentnetwork/chat-components": "^0.1.3",
|
|
31
31
|
"@decentnetwork/lan": "^0.1.313",
|
|
32
32
|
"@decentnetwork/peer": "^0.1.170",
|