@decentnetwork/lan 0.1.131 → 0.1.133
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/bin/tun-helper-darwin-amd64 +0 -0
- package/bin/tun-helper-darwin-arm64 +0 -0
- package/bin/tun-helper-linux-amd64 +0 -0
- package/bin/tun-helper-linux-arm64 +0 -0
- package/dist/cli/commands.d.ts +1 -0
- package/dist/cli/commands.js +13 -0
- package/dist/cli/index.js +2 -0
- package/dist/daemon/server.js +9 -0
- package/dist/proxy/connect-proxy.d.ts +4 -0
- package/dist/proxy/connect-proxy.js +2 -2
- package/dist/proxy/egress.d.ts +11 -0
- package/dist/proxy/egress.js +65 -0
- package/dist/proxy/multi-exit-router.d.ts +5 -0
- package/dist/proxy/multi-exit-router.js +24 -8
- package/dist/types.d.ts +1 -0
- package/dist/ui/desktop/app.js +41 -3
- package/dist/ui/server.js +46 -5
- package/docs/CONFIGURATION.md +3 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/commands.d.ts
CHANGED
package/dist/cli/commands.js
CHANGED
|
@@ -15,6 +15,7 @@ import { AclEngine } from "../acl/acl-engine.js";
|
|
|
15
15
|
import { AuditLog } from "../acl/audit.js";
|
|
16
16
|
import yaml from "js-yaml";
|
|
17
17
|
import { startMultiExitRouter } from "../proxy/multi-exit-router.js";
|
|
18
|
+
import { resolveEgressBindIp } from "../proxy/egress.js";
|
|
18
19
|
import { startFriendUi } from "../ui/server.js";
|
|
19
20
|
/**
|
|
20
21
|
* Refuse to open a second Carrier peer with this identity if the
|
|
@@ -1321,6 +1322,17 @@ export async function cmdProxyRouter(args) {
|
|
|
1321
1322
|
}
|
|
1322
1323
|
defaultRegion = "direct";
|
|
1323
1324
|
}
|
|
1325
|
+
// Egress source binding for the DIRECT (no-region) path — keep it on the
|
|
1326
|
+
// physical NIC instead of a Tailscale/overlay interface. CLI --egress wins
|
|
1327
|
+
// over config proxy.egress.
|
|
1328
|
+
const egressOpt = args.egress ?? config.proxy?.egress;
|
|
1329
|
+
const egressBindIp = resolveEgressBindIp(egressOpt);
|
|
1330
|
+
if (egressOpt && egressBindIp) {
|
|
1331
|
+
console.log(`Direct egress bound to physical source ${egressBindIp} (egress=${egressOpt}).`);
|
|
1332
|
+
}
|
|
1333
|
+
else if (egressOpt) {
|
|
1334
|
+
console.log(`egress=${egressOpt} requested but no physical NIC address found; using OS default egress.`);
|
|
1335
|
+
}
|
|
1324
1336
|
startMultiExitRouter({
|
|
1325
1337
|
exits,
|
|
1326
1338
|
regions,
|
|
@@ -1328,6 +1340,7 @@ export async function cmdProxyRouter(args) {
|
|
|
1328
1340
|
listenHost: args.listen ?? "127.0.0.1",
|
|
1329
1341
|
listenPort: args.port ?? 8889,
|
|
1330
1342
|
mode,
|
|
1343
|
+
egressBindIp,
|
|
1331
1344
|
});
|
|
1332
1345
|
// Keep the process alive; the router runs until Ctrl-C / signal.
|
|
1333
1346
|
await new Promise(() => { });
|
package/dist/cli/index.js
CHANGED
|
@@ -329,6 +329,7 @@ async function main() {
|
|
|
329
329
|
.option("listen", { type: "string", default: "127.0.0.1", describe: "Listen host (use 0.0.0.0 to reach from Windows → WSL)" })
|
|
330
330
|
.option("mode", { choices: ["loadbalance", "failover"], default: "loadbalance" })
|
|
331
331
|
.option("all", { type: "boolean", default: false, describe: "Route ALL hosts through exits (default: China-only split tunnel)" })
|
|
332
|
+
.option("egress", { type: "string", describe: "Bind the DIRECT internet dial to the physical NIC: 'physical' (auto) or an explicit source IPv4 — keeps egress off Tailscale/overlay" })
|
|
332
333
|
.option("config-dir", { type: "string" }), async (argv) => {
|
|
333
334
|
await cmdProxyRouter({
|
|
334
335
|
exit: argv.exit,
|
|
@@ -337,6 +338,7 @@ async function main() {
|
|
|
337
338
|
listen: argv.listen,
|
|
338
339
|
mode: argv.mode,
|
|
339
340
|
all: argv.all,
|
|
341
|
+
egress: argv.egress,
|
|
340
342
|
configDir: argv["config-dir"],
|
|
341
343
|
});
|
|
342
344
|
})
|
package/dist/daemon/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import { AuditLog } from "../acl/audit.js";
|
|
|
14
14
|
import { AclEngine } from "../acl/acl-engine.js";
|
|
15
15
|
import { PacketRouter } from "../router/packet-router.js";
|
|
16
16
|
import { ConnectProxy } from "../proxy/connect-proxy.js";
|
|
17
|
+
import { resolveEgressBindIp } from "../proxy/egress.js";
|
|
17
18
|
import { DoraIntegration } from "../dora/dora-integration.js";
|
|
18
19
|
import { DnsServer } from "../dns/server.js";
|
|
19
20
|
import { IpcServer, ipcSocketPath } from "./ipc.js";
|
|
@@ -785,11 +786,19 @@ export class DaemonServer {
|
|
|
785
786
|
}
|
|
786
787
|
else {
|
|
787
788
|
try {
|
|
789
|
+
const egressBindIp = resolveEgressBindIp(this.config.proxy.egress);
|
|
790
|
+
if (this.config.proxy.egress && egressBindIp) {
|
|
791
|
+
this.logger.info(`Proxy egress bound to physical source ${egressBindIp} (config proxy.egress=${this.config.proxy.egress})`);
|
|
792
|
+
}
|
|
793
|
+
else if (this.config.proxy.egress) {
|
|
794
|
+
this.logger.warn(`Proxy egress=${this.config.proxy.egress} requested but no physical NIC address found; using OS default egress`);
|
|
795
|
+
}
|
|
788
796
|
this.connectProxy = new ConnectProxy({
|
|
789
797
|
bindIp: tunIp,
|
|
790
798
|
port: this.config.proxy.port,
|
|
791
799
|
allowHosts: this.config.proxy.allowHosts ?? [],
|
|
792
800
|
allowConnectPorts: this.config.proxy.allowConnectPorts,
|
|
801
|
+
egressBindIp,
|
|
793
802
|
resolvePeerName: (srcIp) => this.ipam.resolveIp(srcIp)?.name,
|
|
794
803
|
// Per-tunnel audit is opt-in: on a busy exit it floods the
|
|
795
804
|
// log (the 13 GB audit.log bug). Off by default; ACL and
|
|
@@ -31,6 +31,10 @@ export interface ConnectProxyOptions {
|
|
|
31
31
|
allowHosts?: string[];
|
|
32
32
|
/** TCP ports the proxy will dial upstream. Defaults to [443, 80]. */
|
|
33
33
|
allowConnectPorts?: number[];
|
|
34
|
+
/** Bind outbound (upstream) dials to this source IP so egress stays on the
|
|
35
|
+
* physical NIC instead of a Tailscale/overlay interface. Undefined = OS
|
|
36
|
+
* chooses. Resolve via resolveEgressBindIp() from config `proxy.egress`. */
|
|
37
|
+
egressBindIp?: string;
|
|
34
38
|
/** Hook for resolving a source IP to a friendly peer name (audit/log). */
|
|
35
39
|
resolvePeerName?: (sourceIp: string) => string | undefined;
|
|
36
40
|
/** Called when a tunnel opens. */
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* peer is granted port 8888).
|
|
21
21
|
*/
|
|
22
22
|
import http from "http";
|
|
23
|
-
import net from "net";
|
|
24
23
|
import { Logger } from "../utils/logger.js";
|
|
24
|
+
import { egressConnect } from "./egress.js";
|
|
25
25
|
export class ConnectProxy {
|
|
26
26
|
opts;
|
|
27
27
|
server = null;
|
|
@@ -128,7 +128,7 @@ export class ConnectProxy {
|
|
|
128
128
|
let closed = false;
|
|
129
129
|
let lastBytes = -1;
|
|
130
130
|
let idleTimer;
|
|
131
|
-
const upstream =
|
|
131
|
+
const upstream = egressConnect(port, host, this.opts.egressBindIp);
|
|
132
132
|
const tearDown = (reason) => {
|
|
133
133
|
if (closed)
|
|
134
134
|
return;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
/** Primary physical (non-overlay, non-CGNAT) IPv4 — the address to bind egress
|
|
3
|
+
* to so traffic leaves the real NIC instead of a Tailscale/overlay interface.
|
|
4
|
+
* Prefers a public/global address over an RFC1918 one when both exist. */
|
|
5
|
+
export declare function physicalEgressIp(): string | undefined;
|
|
6
|
+
/** Resolve the configured egress option to a concrete bind IP (or undefined to
|
|
7
|
+
* let the OS choose). Accepts "physical"/true (auto-pick the real NIC) or an
|
|
8
|
+
* explicit IPv4 string. */
|
|
9
|
+
export declare function resolveEgressBindIp(opt?: string | boolean): string | undefined;
|
|
10
|
+
/** net.connect, optionally binding the outbound source to `bindIp`. */
|
|
11
|
+
export declare function egressConnect(port: number, host: string, bindIp?: string): net.Socket;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Egress source binding for exit proxies.
|
|
2
|
+
//
|
|
3
|
+
// When a node acts as an internet exit (e.g. the CCTV exit), its outbound dials
|
|
4
|
+
// follow the host's routing table. If the host also runs Tailscale / a VPN with
|
|
5
|
+
// an exit node, that traffic can hairpin out through the overlay instead of the
|
|
6
|
+
// real NIC — adding latency and defeating the point of a local exit. Binding the
|
|
7
|
+
// dial's source address to the physical NIC's IP keeps egress on the underlying
|
|
8
|
+
// physical network. Opt-in via `proxy.egress` in config: "physical" (auto-pick
|
|
9
|
+
// the real NIC) or an explicit source IP.
|
|
10
|
+
//
|
|
11
|
+
// NOTE: source-IP binding picks the egress interface only when the routing table
|
|
12
|
+
// would otherwise pick a virtual one by source selection. If the host installs a
|
|
13
|
+
// VPN *default route* (e.g. Tailscale exit-node mode), the kernel still routes by
|
|
14
|
+
// destination — in that case disable the host's exit-node setting or add a policy
|
|
15
|
+
// route. This option covers the common multi-homed case (physical NIC + overlay).
|
|
16
|
+
import net from "net";
|
|
17
|
+
import { networkInterfaces } from "os";
|
|
18
|
+
// Overlay / virtual interfaces whose addresses must NOT be used for egress.
|
|
19
|
+
const VIRTUAL_IFACE_RE = /^(utun|tun|tap|wg|tailscale|ts\d|zt|ham|agentnet|awdl|llw|gif|stf|bridge|vnic|vmnet|veth|docker|br-|virbr|kube|cni|flannel|cali)/i;
|
|
20
|
+
function isCgnat(addr) {
|
|
21
|
+
const o = addr.split(".").map(Number);
|
|
22
|
+
return o.length === 4 && o[0] === 100 && o[1] >= 64 && o[1] <= 127;
|
|
23
|
+
}
|
|
24
|
+
// Never usable as an egress source: CGNAT (Tailscale), link-local/APIPA
|
|
25
|
+
// (169.254), and loopback.
|
|
26
|
+
function isUnusableEgress(addr) {
|
|
27
|
+
return isCgnat(addr) || addr.startsWith("169.254.") || addr.startsWith("127.");
|
|
28
|
+
}
|
|
29
|
+
/** Primary physical (non-overlay, non-CGNAT) IPv4 — the address to bind egress
|
|
30
|
+
* to so traffic leaves the real NIC instead of a Tailscale/overlay interface.
|
|
31
|
+
* Prefers a public/global address over an RFC1918 one when both exist. */
|
|
32
|
+
export function physicalEgressIp() {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
35
|
+
if (!list || VIRTUAL_IFACE_RE.test(name))
|
|
36
|
+
continue;
|
|
37
|
+
for (const info of list) {
|
|
38
|
+
if (info.family !== "IPv4" || info.internal)
|
|
39
|
+
continue;
|
|
40
|
+
if (isUnusableEgress(info.address))
|
|
41
|
+
continue;
|
|
42
|
+
candidates.push(info.address);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (candidates.length === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
// Prefer a non-RFC1918 (public) address — that's the real internet-facing NIC.
|
|
48
|
+
const isPrivate = (a) => a.startsWith("10.") || a.startsWith("192.168.") ||
|
|
49
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(a);
|
|
50
|
+
return candidates.find((a) => !isPrivate(a)) ?? candidates[0];
|
|
51
|
+
}
|
|
52
|
+
/** Resolve the configured egress option to a concrete bind IP (or undefined to
|
|
53
|
+
* let the OS choose). Accepts "physical"/true (auto-pick the real NIC) or an
|
|
54
|
+
* explicit IPv4 string. */
|
|
55
|
+
export function resolveEgressBindIp(opt) {
|
|
56
|
+
if (opt === true || opt === "physical")
|
|
57
|
+
return physicalEgressIp();
|
|
58
|
+
if (typeof opt === "string" && opt && opt !== "physical")
|
|
59
|
+
return opt;
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/** net.connect, optionally binding the outbound source to `bindIp`. */
|
|
63
|
+
export function egressConnect(port, host, bindIp) {
|
|
64
|
+
return bindIp ? net.connect({ port, host, localAddress: bindIp }) : net.connect(port, host);
|
|
65
|
+
}
|
|
@@ -28,6 +28,11 @@ export interface MultiExitRouterOptions {
|
|
|
28
28
|
listenHost?: string;
|
|
29
29
|
listenPort?: number;
|
|
30
30
|
mode?: "loadbalance" | "failover";
|
|
31
|
+
/** Bind the DIRECT (no-region) internet dial to this source IP so egress
|
|
32
|
+
* stays on the physical NIC, not a Tailscale/overlay interface. Only applies
|
|
33
|
+
* to direct dials — exit dials must keep the OS source so they reach the
|
|
34
|
+
* exit's agentnet vIP over the TUN. Resolve via resolveEgressBindIp(). */
|
|
35
|
+
egressBindIp?: string;
|
|
31
36
|
log?: (msg: string) => void;
|
|
32
37
|
}
|
|
33
38
|
export declare const BUILTIN_REGION_DOMAINS: Record<string, string[]>;
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import net from "node:net";
|
|
21
|
+
import { egressConnect } from "./egress.js";
|
|
21
22
|
// Built-in domain lists, so a user can reference a region by name in
|
|
22
23
|
// routes.yaml without hand-listing every domain. Override by giving the
|
|
23
24
|
// region its own `domains` list.
|
|
@@ -72,9 +73,15 @@ function hostMatches(host, suffixes) {
|
|
|
72
73
|
host = (host || "").toLowerCase();
|
|
73
74
|
return suffixes.some((s) => host === (s.startsWith(".") ? s.slice(1) : s) || host.endsWith(s));
|
|
74
75
|
}
|
|
75
|
-
const HEALTH_INTERVAL_MS =
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const HEALTH_INTERVAL_MS = 1500;
|
|
77
|
+
// Probe connect timeout. A single remote exit reached over agentnet can sit at
|
|
78
|
+
// 300-900ms RTT and spike higher under load (CCTV opens dozens of tunnels), so a
|
|
79
|
+
// tight timeout makes a perfectly usable exit "flap". Keep this generously above
|
|
80
|
+
// the worst observed RTT.
|
|
81
|
+
const HEALTH_TIMEOUT_MS = 4000;
|
|
82
|
+
// Consecutive probe failures before marking an exit DOWN. Higher = more tolerant
|
|
83
|
+
// of transient jitter on a long-haul path (don't blackout a region on one blip).
|
|
84
|
+
const DOWN_AFTER_FAILS = 3;
|
|
78
85
|
const CONNECT_TIMEOUT_MS = 8000;
|
|
79
86
|
/**
|
|
80
87
|
* Start the multi-exit router. Returns a stop() that closes the listener.
|
|
@@ -177,12 +184,21 @@ export function startMultiExitRouter(opts) {
|
|
|
177
184
|
}
|
|
178
185
|
// ---- exit selection (scoped to one region) --------------------------------
|
|
179
186
|
function pickOrder(region) {
|
|
180
|
-
const
|
|
181
|
-
|
|
187
|
+
const inRegion = exits.filter((e) => e.region === region);
|
|
188
|
+
const healthy = inRegion.filter((e) => e.healthy);
|
|
189
|
+
// Best-effort fallback: if NO exit currently passes health checks but the
|
|
190
|
+
// region HAS exits, try them anyway instead of 503-ing the user. A single
|
|
191
|
+
// high-latency exit under load makes the probe flap DOWN/UP every few
|
|
192
|
+
// seconds (observed: China exit at ~900ms RTT serving CCTV), yet the actual
|
|
193
|
+
// CONNECT tunnels keep working. The real forward() has its own timeout +
|
|
194
|
+
// retry, so trying a "down" exit costs little and avoids blacking out the
|
|
195
|
+
// whole region on a transient probe blip. Prefer healthy when we have them.
|
|
196
|
+
const pool = healthy.length > 0 ? healthy : inRegion;
|
|
197
|
+
if (pool.length === 0)
|
|
182
198
|
return [];
|
|
183
199
|
if (mode === "failover")
|
|
184
|
-
return
|
|
185
|
-
return [...
|
|
200
|
+
return pool; // priority order (input order)
|
|
201
|
+
return [...pool].sort((a, b) => a.active - b.active || a.served - b.served);
|
|
186
202
|
}
|
|
187
203
|
// Whether ANY exit is configured for a region (healthy or not). Lets us tell
|
|
188
204
|
// "region has no exits at all → fall through" from "exits all down → 503".
|
|
@@ -240,7 +256,7 @@ export function startMultiExitRouter(opts) {
|
|
|
240
256
|
// Google etc. aren't black-holed through a foreign exit.
|
|
241
257
|
function directConnect(target, client, head) {
|
|
242
258
|
const [host, portStr] = target.split(":");
|
|
243
|
-
const up =
|
|
259
|
+
const up = egressConnect(Number(portStr) || 443, host, opts.egressBindIp);
|
|
244
260
|
up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy());
|
|
245
261
|
up.once("connect", () => {
|
|
246
262
|
up.setTimeout(0);
|
package/dist/types.d.ts
CHANGED
package/dist/ui/desktop/app.js
CHANGED
|
@@ -141,6 +141,16 @@ function dkFileSize(n) {
|
|
|
141
141
|
function dkFileUrl(name) {
|
|
142
142
|
return "/api/file-download?name=" + encodeURIComponent(name);
|
|
143
143
|
}
|
|
144
|
+
function dkFileDownloadUrl(name) {
|
|
145
|
+
return dkFileUrl(name) + "&dl=1";
|
|
146
|
+
}
|
|
147
|
+
function dkFileMediaKind(name) {
|
|
148
|
+
const ext = (String(name || "").split(".").pop() || "").toLowerCase();
|
|
149
|
+
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "heic", "bmp"].includes(ext)) return "image";
|
|
150
|
+
if (["mp4", "mov", "webm", "m4v", "mkv", "avi"].includes(ext)) return "video";
|
|
151
|
+
if (["mp3", "m4a", "aac", "wav", "ogg", "flac", "opus"].includes(ext)) return "audio";
|
|
152
|
+
return "file";
|
|
153
|
+
}
|
|
144
154
|
function dkDayLabel(ts) {
|
|
145
155
|
if (!ts) return "Today";
|
|
146
156
|
const d = new Date(ts), now = /* @__PURE__ */ new Date();
|
|
@@ -271,6 +281,7 @@ function useDaemonData() {
|
|
|
271
281
|
name: m.file.name,
|
|
272
282
|
size: dkFileSize(m.file.size),
|
|
273
283
|
dir: m.dir,
|
|
284
|
+
media: dkFileMediaKind(m.file.name),
|
|
274
285
|
status: m.file.status,
|
|
275
286
|
pct: m.file.status === "sending" && m.file.size ? Math.min(100, Math.floor((m.file.sent || 0) / m.file.size * 100)) : void 0
|
|
276
287
|
} : void 0,
|
|
@@ -1021,10 +1032,37 @@ const inputStyle = {
|
|
|
1021
1032
|
};
|
|
1022
1033
|
function Msg({ m, peer, T }) {
|
|
1023
1034
|
const mine = m.from === "me";
|
|
1024
|
-
return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0" } }, !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ?
|
|
1035
|
+
return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: mine ? "flex-end" : "flex-start", alignItems: "flex-end", gap: 8, margin: "3px 0" } }, !mine && /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 24, radius: 6, dot: false }), /* @__PURE__ */ React.createElement("div", { style: { maxWidth: "64%", display: "flex", flexDirection: "column", alignItems: mine ? "flex-end" : "flex-start" } }, m.file ? !mine && m.file.status !== "sending" && m.file.status !== "failed" && (m.file.media === "image" || m.file.media === "video" || m.file.media === "audio") ? (
|
|
1036
|
+
// Received media → inline preview / player, with name, size, download.
|
|
1037
|
+
/* @__PURE__ */ React.createElement("div", { style: {
|
|
1038
|
+
display: "flex",
|
|
1039
|
+
flexDirection: "column",
|
|
1040
|
+
gap: 6,
|
|
1041
|
+
maxWidth: 300,
|
|
1042
|
+
background: "var(--bub-them)",
|
|
1043
|
+
border: "1px solid var(--line)",
|
|
1044
|
+
borderRadius: 12,
|
|
1045
|
+
padding: 6
|
|
1046
|
+
} }, m.file.media === "image" && /* @__PURE__ */ React.createElement("a", { href: dkFileUrl(m.file.name), target: "_blank", rel: "noreferrer", style: { display: "block", lineHeight: 0 } }, /* @__PURE__ */ React.createElement(
|
|
1047
|
+
"img",
|
|
1048
|
+
{
|
|
1049
|
+
src: dkFileUrl(m.file.name),
|
|
1050
|
+
alt: m.file.name,
|
|
1051
|
+
style: { display: "block", maxWidth: "100%", maxHeight: 280, borderRadius: 8, objectFit: "cover" }
|
|
1052
|
+
}
|
|
1053
|
+
)), m.file.media === "video" && /* @__PURE__ */ React.createElement(
|
|
1054
|
+
"video",
|
|
1055
|
+
{
|
|
1056
|
+
src: dkFileUrl(m.file.name),
|
|
1057
|
+
controls: true,
|
|
1058
|
+
preload: "metadata",
|
|
1059
|
+
style: { maxWidth: "100%", maxHeight: 320, borderRadius: 8, background: "#000" }
|
|
1060
|
+
}
|
|
1061
|
+
), m.file.media === "audio" && /* @__PURE__ */ React.createElement("audio", { src: dkFileUrl(m.file.name), controls: true, style: { width: "100%" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "0 2px" } }, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)", flexShrink: 0 } }, m.file.size), /* @__PURE__ */ React.createElement("a", { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download", style: { display: "inline-flex", flexShrink: 0 } }, /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "var(--accent)" }))))
|
|
1062
|
+
) : React.createElement(
|
|
1025
1063
|
mine ? "div" : "a",
|
|
1026
1064
|
{
|
|
1027
|
-
...mine ? {} : { href:
|
|
1065
|
+
...mine ? {} : { href: dkFileDownloadUrl(m.file.name), download: m.file.name, title: "download" },
|
|
1028
1066
|
style: {
|
|
1029
1067
|
display: "flex",
|
|
1030
1068
|
alignItems: "center",
|
|
@@ -1038,7 +1076,7 @@ function Msg({ m, peer, T }) {
|
|
|
1038
1076
|
cursor: mine ? "default" : "pointer"
|
|
1039
1077
|
}
|
|
1040
1078
|
},
|
|
1041
|
-
/* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.
|
|
1079
|
+
/* @__PURE__ */ React.createElement("div", { style: { width: 34, height: 34, borderRadius: 7, flexShrink: 0, background: mine ? "rgba(255,255,255,0.16)" : "var(--chip)", display: "flex", alignItems: "center", justifyContent: "center", color: mine ? "#fff" : "var(--accent)" } }, /* @__PURE__ */ React.createElement(Icon, { name: m.file.media === "image" ? "image" : m.file.media === "video" ? "video" : m.file.media === "audio" ? "play" : "file", size: 18, stroke: 1.9 })),
|
|
1042
1080
|
/* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, m.file.status === "sending" ? `${m.file.size} \xB7 sending ${m.file.pct != null ? m.file.pct + "%" : "\u2026"}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 download`)),
|
|
1043
1081
|
mine ? m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)" } }, m.file.pct != null ? m.file.pct + "%" : "\u2026") : m.file.status === "failed" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 16, stroke: 2, color: "var(--accent)" })
|
|
1044
1082
|
) : /* @__PURE__ */ React.createElement("div", { style: {
|
package/dist/ui/server.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// empty because the daemon accepts immediately.
|
|
14
14
|
//
|
|
15
15
|
import http from "node:http";
|
|
16
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import yaml from "js-yaml";
|
|
@@ -291,7 +291,10 @@ export function startFriendUi(opts) {
|
|
|
291
291
|
}
|
|
292
292
|
return;
|
|
293
293
|
}
|
|
294
|
-
//
|
|
294
|
+
// Serve a received file by name, from <configDir>/downloads. Used both for
|
|
295
|
+
// download (?dl=1 → attachment) and inline preview/playback (no dl → the
|
|
296
|
+
// right media content-type + inline disposition + HTTP Range so <video>/
|
|
297
|
+
// <audio> can seek without buffering the whole file).
|
|
295
298
|
if (req.method === "GET" && url === "/api/file-download") {
|
|
296
299
|
if (!opts.downloadsDir) {
|
|
297
300
|
res.writeHead(404);
|
|
@@ -307,17 +310,55 @@ export function startFriendUi(opts) {
|
|
|
307
310
|
res.end("not found");
|
|
308
311
|
return;
|
|
309
312
|
}
|
|
313
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
314
|
+
const MIME = {
|
|
315
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
316
|
+
webp: "image/webp", svg: "image/svg+xml", heic: "image/heic", bmp: "image/bmp",
|
|
317
|
+
mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", m4v: "video/x-m4v",
|
|
318
|
+
mkv: "video/x-matroska", avi: "video/x-msvideo",
|
|
319
|
+
mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", wav: "audio/wav",
|
|
320
|
+
ogg: "audio/ogg", flac: "audio/flac", opus: "audio/opus",
|
|
321
|
+
pdf: "application/pdf",
|
|
322
|
+
};
|
|
323
|
+
const ctype = MIME[ext] || "application/octet-stream";
|
|
324
|
+
const isMedia = ctype.startsWith("image/") || ctype.startsWith("video/") || ctype.startsWith("audio/");
|
|
325
|
+
const wantDownload = q.get("dl") === "1" || !isMedia;
|
|
310
326
|
// Content-Disposition must be ASCII — Node throws ERR_INVALID_CHAR on a
|
|
311
327
|
// non-ASCII header value (filenames from macOS contain U+202F before
|
|
312
328
|
// "PM", and others may be Chinese, etc.). Provide an ASCII-only
|
|
313
329
|
// `filename=` fallback plus the RFC 5987 `filename*=UTF-8''…` with the
|
|
314
330
|
// real (percent-encoded) name for clients that support it.
|
|
315
331
|
const asciiName = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "");
|
|
332
|
+
const disposition = `${wantDownload ? "attachment" : "inline"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
333
|
+
const size = statSync(file).size;
|
|
334
|
+
const range = req.headers.range;
|
|
335
|
+
// Honour a single-range request (what browsers send for media seeking).
|
|
336
|
+
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
337
|
+
if (m && !wantDownload) {
|
|
338
|
+
let start = m[1] ? parseInt(m[1], 10) : 0;
|
|
339
|
+
let end = m[2] ? parseInt(m[2], 10) : size - 1;
|
|
340
|
+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= size) {
|
|
341
|
+
res.writeHead(416, { "content-range": `bytes */${size}` });
|
|
342
|
+
res.end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
res.writeHead(206, {
|
|
346
|
+
"content-type": ctype,
|
|
347
|
+
"content-disposition": disposition,
|
|
348
|
+
"accept-ranges": "bytes",
|
|
349
|
+
"content-range": `bytes ${start}-${end}/${size}`,
|
|
350
|
+
"content-length": String(end - start + 1),
|
|
351
|
+
});
|
|
352
|
+
createReadStream(file, { start, end }).pipe(res);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
316
355
|
res.writeHead(200, {
|
|
317
|
-
"content-type":
|
|
318
|
-
"content-disposition":
|
|
356
|
+
"content-type": ctype,
|
|
357
|
+
"content-disposition": disposition,
|
|
358
|
+
"accept-ranges": "bytes",
|
|
359
|
+
"content-length": String(size),
|
|
319
360
|
});
|
|
320
|
-
|
|
361
|
+
createReadStream(file).pipe(res);
|
|
321
362
|
return;
|
|
322
363
|
}
|
|
323
364
|
if (req.method === "GET" && url === "/api/state") {
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -43,6 +43,9 @@ paths:
|
|
|
43
43
|
proxy:
|
|
44
44
|
enabled: false # `agentnet proxy enable` flips this
|
|
45
45
|
port: 8888
|
|
46
|
+
egress: "" # ""=OS default; "physical"=bind outbound dials to
|
|
47
|
+
# the real NIC (skip Tailscale/overlay); or an
|
|
48
|
+
# explicit source IPv4. See docs/CHINA-EXIT.md.
|
|
46
49
|
|
|
47
50
|
friends:
|
|
48
51
|
autoAccept: true # accept incoming friend-requests automatically
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.133",
|
|
4
4
|
"description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|