@decentnetwork/lan 0.1.130 → 0.1.132
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 +2 -1
- package/dist/types.d.ts +1 -0
- package/dist/ui/desktop/app.js +41 -3
- package/dist/ui/server.js +46 -5
- package/package.json +2 -2
|
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.
|
|
@@ -240,7 +241,7 @@ export function startMultiExitRouter(opts) {
|
|
|
240
241
|
// Google etc. aren't black-holed through a foreign exit.
|
|
241
242
|
function directConnect(target, client, head) {
|
|
242
243
|
const [host, portStr] = target.split(":");
|
|
243
|
-
const up =
|
|
244
|
+
const up = egressConnect(Number(portStr) || 443, host, opts.egressBindIp);
|
|
244
245
|
up.setTimeout(CONNECT_TIMEOUT_MS, () => up.destroy());
|
|
245
246
|
up.once("connect", () => {
|
|
246
247
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/lan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.132",
|
|
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",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
81
|
"@decentnetwork/dora": "^0.1.11",
|
|
82
|
-
"@decentnetwork/peer": "^0.1.
|
|
82
|
+
"@decentnetwork/peer": "^0.1.69",
|
|
83
83
|
"ink": "^5.2.1",
|
|
84
84
|
"js-yaml": "^4.1.0",
|
|
85
85
|
"react": "^18.3.1",
|