lunel-cli 0.1.34 → 0.1.36
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/dist/index.js +274 -53
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -44,6 +44,14 @@ let activeControlWs = null;
|
|
|
44
44
|
let activeDataWs = null;
|
|
45
45
|
const activeTunnels = new Map();
|
|
46
46
|
const PORT_SYNC_INTERVAL_MS = 30_000;
|
|
47
|
+
const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
|
|
48
|
+
const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
|
|
49
|
+
const TUNNEL_SETUP_BUDGET_MS = 18_000;
|
|
50
|
+
const PROXY_WS_CONNECT_RETRY_ATTEMPTS = 1;
|
|
51
|
+
const PROXY_WS_RETRY_JITTER_MIN_MS = 200;
|
|
52
|
+
const PROXY_WS_RETRY_JITTER_MAX_MS = 500;
|
|
53
|
+
const PROXY_TUNNEL_LINGER_MS = 1_200;
|
|
54
|
+
const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
|
|
47
55
|
let portSyncTimer = null;
|
|
48
56
|
let portScanInFlight = false;
|
|
49
57
|
let lastDiscoveredPorts = [];
|
|
@@ -54,6 +62,55 @@ function redactSensitive(input) {
|
|
|
54
62
|
.replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
|
|
55
63
|
.replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
|
|
56
64
|
}
|
|
65
|
+
function parseProxyControlFrame(raw) {
|
|
66
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
|
|
67
|
+
if (!text)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(text);
|
|
71
|
+
if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
|
|
72
|
+
return null;
|
|
73
|
+
if (parsed.action !== "fin" && parsed.action !== "rst")
|
|
74
|
+
return null;
|
|
75
|
+
return {
|
|
76
|
+
v: 1,
|
|
77
|
+
t: "proxy_ctrl",
|
|
78
|
+
action: parsed.action,
|
|
79
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function sendProxyControl(tunnel, action, reason) {
|
|
87
|
+
if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
|
|
88
|
+
return;
|
|
89
|
+
const frame = { v: 1, t: "proxy_ctrl", action, reason };
|
|
90
|
+
tunnel.proxyWs.send(JSON.stringify(frame));
|
|
91
|
+
}
|
|
92
|
+
function maybeFinalizeTunnel(tunnelId) {
|
|
93
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
94
|
+
if (!tunnel || tunnel.closing)
|
|
95
|
+
return;
|
|
96
|
+
if (!tunnel.localEnded || !tunnel.remoteEnded)
|
|
97
|
+
return;
|
|
98
|
+
if (tunnel.finalizeTimer)
|
|
99
|
+
return;
|
|
100
|
+
tunnel.finalizeTimer = setTimeout(() => {
|
|
101
|
+
const current = activeTunnels.get(tunnelId);
|
|
102
|
+
if (!current || current.closing)
|
|
103
|
+
return;
|
|
104
|
+
current.closing = true;
|
|
105
|
+
activeTunnels.delete(tunnelId);
|
|
106
|
+
if (!current.tcpSocket.destroyed) {
|
|
107
|
+
current.tcpSocket.destroy();
|
|
108
|
+
}
|
|
109
|
+
if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
|
|
110
|
+
current.proxyWs.close();
|
|
111
|
+
}
|
|
112
|
+
}, PROXY_TUNNEL_LINGER_MS);
|
|
113
|
+
}
|
|
57
114
|
// Popular development server ports to scan on connect
|
|
58
115
|
const DEV_PORTS = [
|
|
59
116
|
1234, // Parcel
|
|
@@ -1634,20 +1691,36 @@ async function scanDevPorts() {
|
|
|
1634
1691
|
const openPorts = [];
|
|
1635
1692
|
const checks = SCAN_PORTS.map((port) => {
|
|
1636
1693
|
return new Promise((resolve) => {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
socket.
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1694
|
+
let finished = false;
|
|
1695
|
+
let pending = LOOPBACK_HOSTS.length;
|
|
1696
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
1697
|
+
const socket = createConnection({ port, host });
|
|
1698
|
+
socket.setTimeout(200);
|
|
1699
|
+
socket.on("connect", () => {
|
|
1700
|
+
if (finished)
|
|
1701
|
+
return;
|
|
1702
|
+
finished = true;
|
|
1703
|
+
openPorts.push(port);
|
|
1704
|
+
socket.destroy();
|
|
1705
|
+
resolve();
|
|
1706
|
+
});
|
|
1707
|
+
const onDone = () => {
|
|
1708
|
+
if (finished)
|
|
1709
|
+
return;
|
|
1710
|
+
pending -= 1;
|
|
1711
|
+
if (pending <= 0) {
|
|
1712
|
+
finished = true;
|
|
1713
|
+
resolve();
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
socket.on("timeout", () => {
|
|
1717
|
+
socket.destroy();
|
|
1718
|
+
onDone();
|
|
1719
|
+
});
|
|
1720
|
+
socket.on("error", () => {
|
|
1721
|
+
onDone();
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1651
1724
|
});
|
|
1652
1725
|
});
|
|
1653
1726
|
await Promise.all(checks);
|
|
@@ -1698,53 +1771,132 @@ function startPortSync(ws) {
|
|
|
1698
1771
|
async function handleProxyConnect(payload) {
|
|
1699
1772
|
const tunnelId = payload.tunnelId;
|
|
1700
1773
|
const port = payload.port;
|
|
1774
|
+
const setupStartedAt = Date.now();
|
|
1775
|
+
const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
|
|
1701
1776
|
if (!tunnelId)
|
|
1702
1777
|
throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
|
|
1703
1778
|
if (!port)
|
|
1704
1779
|
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
1705
1780
|
if (!currentSessionCode && !currentSessionPassword)
|
|
1706
1781
|
throw Object.assign(new Error("no active session"), { code: "ENOENT" });
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1782
|
+
if (getRemainingSetupMs() <= 0) {
|
|
1783
|
+
throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
|
|
1784
|
+
}
|
|
1785
|
+
// 1. Open TCP connection to the local service (dual-stack localhost fallback)
|
|
1786
|
+
let tcpSocket = null;
|
|
1787
|
+
let tcpConnectError = null;
|
|
1788
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
1789
|
+
const remainingMs = getRemainingSetupMs();
|
|
1790
|
+
if (remainingMs <= 0) {
|
|
1791
|
+
throw Object.assign(new Error("Tunnel setup timeout before local TCP connect"), { code: "ETIMEOUT" });
|
|
1792
|
+
}
|
|
1793
|
+
const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
|
|
1794
|
+
const candidate = createConnection({ port, host });
|
|
1795
|
+
try {
|
|
1796
|
+
await new Promise((resolve, reject) => {
|
|
1797
|
+
const timeout = setTimeout(() => {
|
|
1798
|
+
candidate.destroy();
|
|
1799
|
+
reject(Object.assign(new Error(`TCP connect timeout to ${host}:${port}`), { code: "ETIMEOUT" }));
|
|
1800
|
+
}, tcpConnectTimeoutMs);
|
|
1801
|
+
candidate.on("connect", () => {
|
|
1802
|
+
clearTimeout(timeout);
|
|
1803
|
+
resolve();
|
|
1804
|
+
});
|
|
1805
|
+
candidate.on("error", (err) => {
|
|
1806
|
+
clearTimeout(timeout);
|
|
1807
|
+
reject(Object.assign(new Error(`TCP connect failed to ${host}:${port}: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
tcpSocket = candidate;
|
|
1811
|
+
break;
|
|
1812
|
+
}
|
|
1813
|
+
catch (error) {
|
|
1814
|
+
tcpConnectError = error;
|
|
1815
|
+
try {
|
|
1816
|
+
candidate.destroy();
|
|
1817
|
+
}
|
|
1818
|
+
catch {
|
|
1819
|
+
// ignore
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (!tcpSocket) {
|
|
1824
|
+
throw tcpConnectError || Object.assign(new Error(`TCP connect failed to localhost:${port}`), { code: "ECONNREFUSED" });
|
|
1825
|
+
}
|
|
1723
1826
|
// 2. Open proxy WebSocket to gateway
|
|
1724
1827
|
const wsBase = activeGatewayUrl.replace(/^http/, "ws");
|
|
1725
1828
|
const authQuery = currentSessionPassword
|
|
1726
1829
|
? `password=${encodeURIComponent(currentSessionPassword)}`
|
|
1727
1830
|
: `code=${encodeURIComponent(currentSessionCode)}`;
|
|
1728
1831
|
const proxyWsUrl = `${wsBase}/v1/ws/proxy?${authQuery}&tunnelId=${encodeURIComponent(tunnelId)}&role=cli`;
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
1735
|
-
}, 5000);
|
|
1736
|
-
proxyWs.on("open", () => {
|
|
1737
|
-
clearTimeout(timeout);
|
|
1738
|
-
resolve();
|
|
1739
|
-
});
|
|
1740
|
-
proxyWs.on("error", (err) => {
|
|
1741
|
-
clearTimeout(timeout);
|
|
1832
|
+
let proxyWs = null;
|
|
1833
|
+
let lastProxyError = null;
|
|
1834
|
+
for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
|
|
1835
|
+
const remainingMs = getRemainingSetupMs();
|
|
1836
|
+
if (remainingMs <= 0) {
|
|
1742
1837
|
tcpSocket.destroy();
|
|
1743
|
-
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1838
|
+
throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
|
|
1839
|
+
}
|
|
1840
|
+
const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
|
|
1841
|
+
const candidateWs = new WebSocket(proxyWsUrl);
|
|
1842
|
+
try {
|
|
1843
|
+
await new Promise((resolve, reject) => {
|
|
1844
|
+
const timeout = setTimeout(() => {
|
|
1845
|
+
candidateWs.close();
|
|
1846
|
+
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
1847
|
+
}, wsConnectTimeoutMs);
|
|
1848
|
+
candidateWs.on("open", () => {
|
|
1849
|
+
clearTimeout(timeout);
|
|
1850
|
+
resolve();
|
|
1851
|
+
});
|
|
1852
|
+
candidateWs.on("error", (err) => {
|
|
1853
|
+
clearTimeout(timeout);
|
|
1854
|
+
reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1855
|
+
});
|
|
1856
|
+
candidateWs.on("close", () => {
|
|
1857
|
+
clearTimeout(timeout);
|
|
1858
|
+
reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
|
|
1859
|
+
});
|
|
1860
|
+
});
|
|
1861
|
+
proxyWs = candidateWs;
|
|
1862
|
+
break;
|
|
1863
|
+
}
|
|
1864
|
+
catch (error) {
|
|
1865
|
+
lastProxyError = error;
|
|
1866
|
+
try {
|
|
1867
|
+
candidateWs.close();
|
|
1868
|
+
}
|
|
1869
|
+
catch {
|
|
1870
|
+
// ignore
|
|
1871
|
+
}
|
|
1872
|
+
if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
|
|
1873
|
+
break;
|
|
1874
|
+
}
|
|
1875
|
+
const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
|
|
1876
|
+
const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
|
|
1877
|
+
if (getRemainingSetupMs() <= jitterMs) {
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
1880
|
+
await new Promise((resolve) => setTimeout(resolve, jitterMs));
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (!proxyWs) {
|
|
1884
|
+
tcpSocket.destroy();
|
|
1885
|
+
const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
|
|
1886
|
+
throw err;
|
|
1887
|
+
}
|
|
1746
1888
|
// 3. Store the tunnel
|
|
1747
|
-
activeTunnels.set(tunnelId, {
|
|
1889
|
+
activeTunnels.set(tunnelId, {
|
|
1890
|
+
tunnelId,
|
|
1891
|
+
port,
|
|
1892
|
+
tcpSocket,
|
|
1893
|
+
proxyWs,
|
|
1894
|
+
localEnded: false,
|
|
1895
|
+
remoteEnded: false,
|
|
1896
|
+
finSent: false,
|
|
1897
|
+
finalizeTimer: null,
|
|
1898
|
+
closing: false,
|
|
1899
|
+
});
|
|
1748
1900
|
// 4. Pipe: TCP data -> proxy WS (as binary)
|
|
1749
1901
|
tcpSocket.on("data", (chunk) => {
|
|
1750
1902
|
if (proxyWs.readyState === WebSocket.OPEN) {
|
|
@@ -1753,18 +1905,70 @@ async function handleProxyConnect(payload) {
|
|
|
1753
1905
|
});
|
|
1754
1906
|
// 5. Pipe: proxy WS -> TCP socket (as binary)
|
|
1755
1907
|
proxyWs.on("message", (data) => {
|
|
1908
|
+
const control = parseProxyControlFrame(data);
|
|
1909
|
+
if (control) {
|
|
1910
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1911
|
+
if (!tunnel || tunnel.closing)
|
|
1912
|
+
return;
|
|
1913
|
+
if (control.action === "fin") {
|
|
1914
|
+
tunnel.remoteEnded = true;
|
|
1915
|
+
if (!tcpSocket.destroyed) {
|
|
1916
|
+
tcpSocket.end();
|
|
1917
|
+
}
|
|
1918
|
+
maybeFinalizeTunnel(tunnelId);
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
tunnel.closing = true;
|
|
1922
|
+
activeTunnels.delete(tunnelId);
|
|
1923
|
+
if (!tcpSocket.destroyed) {
|
|
1924
|
+
tcpSocket.destroy();
|
|
1925
|
+
}
|
|
1926
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1927
|
+
proxyWs.close();
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1756
1932
|
if (!tcpSocket.destroyed) {
|
|
1757
|
-
|
|
1933
|
+
const chunk = Buffer.isBuffer(data)
|
|
1934
|
+
? data
|
|
1935
|
+
: typeof data === "string"
|
|
1936
|
+
? Buffer.from(data)
|
|
1937
|
+
: Array.isArray(data)
|
|
1938
|
+
? Buffer.concat(data)
|
|
1939
|
+
: Buffer.from(data);
|
|
1940
|
+
tcpSocket.write(chunk);
|
|
1758
1941
|
}
|
|
1759
1942
|
});
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1943
|
+
const markLocalEnded = () => {
|
|
1944
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1945
|
+
if (!tunnel || tunnel.closing)
|
|
1946
|
+
return;
|
|
1947
|
+
tunnel.localEnded = true;
|
|
1948
|
+
if (!tunnel.finSent) {
|
|
1949
|
+
tunnel.finSent = true;
|
|
1950
|
+
sendProxyControl(tunnel, "fin");
|
|
1765
1951
|
}
|
|
1952
|
+
maybeFinalizeTunnel(tunnelId);
|
|
1953
|
+
};
|
|
1954
|
+
// 6. Half-close handling
|
|
1955
|
+
tcpSocket.on("end", () => {
|
|
1956
|
+
markLocalEnded();
|
|
1957
|
+
});
|
|
1958
|
+
tcpSocket.on("close", () => {
|
|
1959
|
+
markLocalEnded();
|
|
1766
1960
|
});
|
|
1767
1961
|
tcpSocket.on("error", () => {
|
|
1962
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1963
|
+
if (tunnel && !tunnel.finSent) {
|
|
1964
|
+
sendProxyControl(tunnel, "rst", "tcp_error");
|
|
1965
|
+
}
|
|
1966
|
+
if (tunnel) {
|
|
1967
|
+
tunnel.closing = true;
|
|
1968
|
+
if (tunnel.finalizeTimer) {
|
|
1969
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1768
1972
|
activeTunnels.delete(tunnelId);
|
|
1769
1973
|
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1770
1974
|
proxyWs.close();
|
|
@@ -1772,12 +1976,26 @@ async function handleProxyConnect(payload) {
|
|
|
1772
1976
|
});
|
|
1773
1977
|
// 7. Close cascade: WS closes -> close TCP
|
|
1774
1978
|
proxyWs.on("close", () => {
|
|
1979
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1980
|
+
if (tunnel) {
|
|
1981
|
+
tunnel.closing = true;
|
|
1982
|
+
if (tunnel.finalizeTimer) {
|
|
1983
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1775
1986
|
activeTunnels.delete(tunnelId);
|
|
1776
1987
|
if (!tcpSocket.destroyed) {
|
|
1777
1988
|
tcpSocket.destroy();
|
|
1778
1989
|
}
|
|
1779
1990
|
});
|
|
1780
1991
|
proxyWs.on("error", () => {
|
|
1992
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1993
|
+
if (tunnel) {
|
|
1994
|
+
tunnel.closing = true;
|
|
1995
|
+
if (tunnel.finalizeTimer) {
|
|
1996
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1781
1999
|
activeTunnels.delete(tunnelId);
|
|
1782
2000
|
if (!tcpSocket.destroyed) {
|
|
1783
2001
|
tcpSocket.destroy();
|
|
@@ -1787,6 +2005,9 @@ async function handleProxyConnect(payload) {
|
|
|
1787
2005
|
}
|
|
1788
2006
|
function cleanupAllTunnels() {
|
|
1789
2007
|
for (const [, tunnel] of activeTunnels) {
|
|
2008
|
+
if (tunnel.finalizeTimer) {
|
|
2009
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
2010
|
+
}
|
|
1790
2011
|
tunnel.tcpSocket.destroy();
|
|
1791
2012
|
if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
|
|
1792
2013
|
tunnel.proxyWs.close();
|