@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.
Binary file
Binary file
Binary file
Binary file
@@ -297,6 +297,7 @@ export declare function cmdProxyRouter(args: {
297
297
  mode?: string;
298
298
  all?: boolean;
299
299
  routes?: string;
300
+ egress?: string;
300
301
  configDir?: string;
301
302
  }): Promise<void>;
302
303
  /**
@@ -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
  })
@@ -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 = net.connect(port, host);
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 = 1000;
76
- const HEALTH_TIMEOUT_MS = 1500;
77
- const DOWN_AFTER_FAILS = 2;
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 healthy = exits.filter((e) => e.healthy && e.region === region);
181
- if (healthy.length === 0)
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 healthy; // priority order (input order)
185
- return [...healthy].sort((a, b) => a.active - b.active || a.served - b.served);
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 = net.connect(Number(portStr) || 443, host);
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
@@ -134,6 +134,7 @@ export interface ProxyConfig {
134
134
  allowHosts?: string[];
135
135
  allowConnectPorts?: number[];
136
136
  auditTunnels?: boolean;
137
+ egress?: string;
137
138
  }
138
139
  export interface FriendsConfig {
139
140
  /** When true (default), the running daemon auto-accepts incoming
@@ -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 ? React.createElement(
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: dkFileUrl(m.file.name), download: m.file.name, title: "download" },
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.kind || "file", size: 18, stroke: 1.9 })),
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
- // Download a received file by name, served from <configDir>/downloads.
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": "application/octet-stream",
318
- "content-disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`,
356
+ "content-type": ctype,
357
+ "content-disposition": disposition,
358
+ "accept-ranges": "bytes",
359
+ "content-length": String(size),
319
360
  });
320
- res.end(readFileSync(file));
361
+ createReadStream(file).pipe(res);
321
362
  return;
322
363
  }
323
364
  if (req.method === "GET" && url === "/api/state") {
@@ -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.131",
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",