lunel-cli 0.1.34 → 0.1.35
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 +204 -24
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -44,6 +44,13 @@ 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;
|
|
47
54
|
let portSyncTimer = null;
|
|
48
55
|
let portScanInFlight = false;
|
|
49
56
|
let lastDiscoveredPorts = [];
|
|
@@ -54,6 +61,55 @@ function redactSensitive(input) {
|
|
|
54
61
|
.replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
|
|
55
62
|
.replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
|
|
56
63
|
}
|
|
64
|
+
function parseProxyControlFrame(raw) {
|
|
65
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
|
|
66
|
+
if (!text)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(text);
|
|
70
|
+
if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
|
|
71
|
+
return null;
|
|
72
|
+
if (parsed.action !== "fin" && parsed.action !== "rst")
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
v: 1,
|
|
76
|
+
t: "proxy_ctrl",
|
|
77
|
+
action: parsed.action,
|
|
78
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function sendProxyControl(tunnel, action, reason) {
|
|
86
|
+
if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
|
|
87
|
+
return;
|
|
88
|
+
const frame = { v: 1, t: "proxy_ctrl", action, reason };
|
|
89
|
+
tunnel.proxyWs.send(JSON.stringify(frame));
|
|
90
|
+
}
|
|
91
|
+
function maybeFinalizeTunnel(tunnelId) {
|
|
92
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
93
|
+
if (!tunnel || tunnel.closing)
|
|
94
|
+
return;
|
|
95
|
+
if (!tunnel.localEnded || !tunnel.remoteEnded)
|
|
96
|
+
return;
|
|
97
|
+
if (tunnel.finalizeTimer)
|
|
98
|
+
return;
|
|
99
|
+
tunnel.finalizeTimer = setTimeout(() => {
|
|
100
|
+
const current = activeTunnels.get(tunnelId);
|
|
101
|
+
if (!current || current.closing)
|
|
102
|
+
return;
|
|
103
|
+
current.closing = true;
|
|
104
|
+
activeTunnels.delete(tunnelId);
|
|
105
|
+
if (!current.tcpSocket.destroyed) {
|
|
106
|
+
current.tcpSocket.destroy();
|
|
107
|
+
}
|
|
108
|
+
if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
|
|
109
|
+
current.proxyWs.close();
|
|
110
|
+
}
|
|
111
|
+
}, PROXY_TUNNEL_LINGER_MS);
|
|
112
|
+
}
|
|
57
113
|
// Popular development server ports to scan on connect
|
|
58
114
|
const DEV_PORTS = [
|
|
59
115
|
1234, // Parcel
|
|
@@ -1698,19 +1754,25 @@ function startPortSync(ws) {
|
|
|
1698
1754
|
async function handleProxyConnect(payload) {
|
|
1699
1755
|
const tunnelId = payload.tunnelId;
|
|
1700
1756
|
const port = payload.port;
|
|
1757
|
+
const setupStartedAt = Date.now();
|
|
1758
|
+
const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
|
|
1701
1759
|
if (!tunnelId)
|
|
1702
1760
|
throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
|
|
1703
1761
|
if (!port)
|
|
1704
1762
|
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
1705
1763
|
if (!currentSessionCode && !currentSessionPassword)
|
|
1706
1764
|
throw Object.assign(new Error("no active session"), { code: "ENOENT" });
|
|
1765
|
+
if (getRemainingSetupMs() <= 0) {
|
|
1766
|
+
throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
|
|
1767
|
+
}
|
|
1707
1768
|
// 1. Open TCP connection to the local service
|
|
1708
1769
|
const tcpSocket = createConnection({ port, host: "127.0.0.1" });
|
|
1770
|
+
const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, getRemainingSetupMs()));
|
|
1709
1771
|
await new Promise((resolve, reject) => {
|
|
1710
1772
|
const timeout = setTimeout(() => {
|
|
1711
1773
|
tcpSocket.destroy();
|
|
1712
1774
|
reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
|
|
1713
|
-
},
|
|
1775
|
+
}, tcpConnectTimeoutMs);
|
|
1714
1776
|
tcpSocket.on("connect", () => {
|
|
1715
1777
|
clearTimeout(timeout);
|
|
1716
1778
|
resolve();
|
|
@@ -1726,25 +1788,74 @@ async function handleProxyConnect(payload) {
|
|
|
1726
1788
|
? `password=${encodeURIComponent(currentSessionPassword)}`
|
|
1727
1789
|
: `code=${encodeURIComponent(currentSessionCode)}`;
|
|
1728
1790
|
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);
|
|
1791
|
+
let proxyWs = null;
|
|
1792
|
+
let lastProxyError = null;
|
|
1793
|
+
for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
|
|
1794
|
+
const remainingMs = getRemainingSetupMs();
|
|
1795
|
+
if (remainingMs <= 0) {
|
|
1742
1796
|
tcpSocket.destroy();
|
|
1743
|
-
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1797
|
+
throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
|
|
1798
|
+
}
|
|
1799
|
+
const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
|
|
1800
|
+
const candidateWs = new WebSocket(proxyWsUrl);
|
|
1801
|
+
try {
|
|
1802
|
+
await new Promise((resolve, reject) => {
|
|
1803
|
+
const timeout = setTimeout(() => {
|
|
1804
|
+
candidateWs.close();
|
|
1805
|
+
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
1806
|
+
}, wsConnectTimeoutMs);
|
|
1807
|
+
candidateWs.on("open", () => {
|
|
1808
|
+
clearTimeout(timeout);
|
|
1809
|
+
resolve();
|
|
1810
|
+
});
|
|
1811
|
+
candidateWs.on("error", (err) => {
|
|
1812
|
+
clearTimeout(timeout);
|
|
1813
|
+
reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1814
|
+
});
|
|
1815
|
+
candidateWs.on("close", () => {
|
|
1816
|
+
clearTimeout(timeout);
|
|
1817
|
+
reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
proxyWs = candidateWs;
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1823
|
+
catch (error) {
|
|
1824
|
+
lastProxyError = error;
|
|
1825
|
+
try {
|
|
1826
|
+
candidateWs.close();
|
|
1827
|
+
}
|
|
1828
|
+
catch {
|
|
1829
|
+
// ignore
|
|
1830
|
+
}
|
|
1831
|
+
if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
|
|
1832
|
+
break;
|
|
1833
|
+
}
|
|
1834
|
+
const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
|
|
1835
|
+
const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
|
|
1836
|
+
if (getRemainingSetupMs() <= jitterMs) {
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
await new Promise((resolve) => setTimeout(resolve, jitterMs));
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (!proxyWs) {
|
|
1843
|
+
tcpSocket.destroy();
|
|
1844
|
+
const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
|
|
1845
|
+
throw err;
|
|
1846
|
+
}
|
|
1746
1847
|
// 3. Store the tunnel
|
|
1747
|
-
activeTunnels.set(tunnelId, {
|
|
1848
|
+
activeTunnels.set(tunnelId, {
|
|
1849
|
+
tunnelId,
|
|
1850
|
+
port,
|
|
1851
|
+
tcpSocket,
|
|
1852
|
+
proxyWs,
|
|
1853
|
+
localEnded: false,
|
|
1854
|
+
remoteEnded: false,
|
|
1855
|
+
finSent: false,
|
|
1856
|
+
finalizeTimer: null,
|
|
1857
|
+
closing: false,
|
|
1858
|
+
});
|
|
1748
1859
|
// 4. Pipe: TCP data -> proxy WS (as binary)
|
|
1749
1860
|
tcpSocket.on("data", (chunk) => {
|
|
1750
1861
|
if (proxyWs.readyState === WebSocket.OPEN) {
|
|
@@ -1753,18 +1864,70 @@ async function handleProxyConnect(payload) {
|
|
|
1753
1864
|
});
|
|
1754
1865
|
// 5. Pipe: proxy WS -> TCP socket (as binary)
|
|
1755
1866
|
proxyWs.on("message", (data) => {
|
|
1867
|
+
const control = parseProxyControlFrame(data);
|
|
1868
|
+
if (control) {
|
|
1869
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1870
|
+
if (!tunnel || tunnel.closing)
|
|
1871
|
+
return;
|
|
1872
|
+
if (control.action === "fin") {
|
|
1873
|
+
tunnel.remoteEnded = true;
|
|
1874
|
+
if (!tcpSocket.destroyed) {
|
|
1875
|
+
tcpSocket.end();
|
|
1876
|
+
}
|
|
1877
|
+
maybeFinalizeTunnel(tunnelId);
|
|
1878
|
+
}
|
|
1879
|
+
else {
|
|
1880
|
+
tunnel.closing = true;
|
|
1881
|
+
activeTunnels.delete(tunnelId);
|
|
1882
|
+
if (!tcpSocket.destroyed) {
|
|
1883
|
+
tcpSocket.destroy();
|
|
1884
|
+
}
|
|
1885
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1886
|
+
proxyWs.close();
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1756
1891
|
if (!tcpSocket.destroyed) {
|
|
1757
|
-
|
|
1892
|
+
const chunk = Buffer.isBuffer(data)
|
|
1893
|
+
? data
|
|
1894
|
+
: typeof data === "string"
|
|
1895
|
+
? Buffer.from(data)
|
|
1896
|
+
: Array.isArray(data)
|
|
1897
|
+
? Buffer.concat(data)
|
|
1898
|
+
: Buffer.from(data);
|
|
1899
|
+
tcpSocket.write(chunk);
|
|
1758
1900
|
}
|
|
1759
1901
|
});
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1902
|
+
const markLocalEnded = () => {
|
|
1903
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1904
|
+
if (!tunnel || tunnel.closing)
|
|
1905
|
+
return;
|
|
1906
|
+
tunnel.localEnded = true;
|
|
1907
|
+
if (!tunnel.finSent) {
|
|
1908
|
+
tunnel.finSent = true;
|
|
1909
|
+
sendProxyControl(tunnel, "fin");
|
|
1765
1910
|
}
|
|
1911
|
+
maybeFinalizeTunnel(tunnelId);
|
|
1912
|
+
};
|
|
1913
|
+
// 6. Half-close handling
|
|
1914
|
+
tcpSocket.on("end", () => {
|
|
1915
|
+
markLocalEnded();
|
|
1916
|
+
});
|
|
1917
|
+
tcpSocket.on("close", () => {
|
|
1918
|
+
markLocalEnded();
|
|
1766
1919
|
});
|
|
1767
1920
|
tcpSocket.on("error", () => {
|
|
1921
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1922
|
+
if (tunnel && !tunnel.finSent) {
|
|
1923
|
+
sendProxyControl(tunnel, "rst", "tcp_error");
|
|
1924
|
+
}
|
|
1925
|
+
if (tunnel) {
|
|
1926
|
+
tunnel.closing = true;
|
|
1927
|
+
if (tunnel.finalizeTimer) {
|
|
1928
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1768
1931
|
activeTunnels.delete(tunnelId);
|
|
1769
1932
|
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1770
1933
|
proxyWs.close();
|
|
@@ -1772,12 +1935,26 @@ async function handleProxyConnect(payload) {
|
|
|
1772
1935
|
});
|
|
1773
1936
|
// 7. Close cascade: WS closes -> close TCP
|
|
1774
1937
|
proxyWs.on("close", () => {
|
|
1938
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1939
|
+
if (tunnel) {
|
|
1940
|
+
tunnel.closing = true;
|
|
1941
|
+
if (tunnel.finalizeTimer) {
|
|
1942
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1775
1945
|
activeTunnels.delete(tunnelId);
|
|
1776
1946
|
if (!tcpSocket.destroyed) {
|
|
1777
1947
|
tcpSocket.destroy();
|
|
1778
1948
|
}
|
|
1779
1949
|
});
|
|
1780
1950
|
proxyWs.on("error", () => {
|
|
1951
|
+
const tunnel = activeTunnels.get(tunnelId);
|
|
1952
|
+
if (tunnel) {
|
|
1953
|
+
tunnel.closing = true;
|
|
1954
|
+
if (tunnel.finalizeTimer) {
|
|
1955
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1781
1958
|
activeTunnels.delete(tunnelId);
|
|
1782
1959
|
if (!tcpSocket.destroyed) {
|
|
1783
1960
|
tcpSocket.destroy();
|
|
@@ -1787,6 +1964,9 @@ async function handleProxyConnect(payload) {
|
|
|
1787
1964
|
}
|
|
1788
1965
|
function cleanupAllTunnels() {
|
|
1789
1966
|
for (const [, tunnel] of activeTunnels) {
|
|
1967
|
+
if (tunnel.finalizeTimer) {
|
|
1968
|
+
clearTimeout(tunnel.finalizeTimer);
|
|
1969
|
+
}
|
|
1790
1970
|
tunnel.tcpSocket.destroy();
|
|
1791
1971
|
if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
|
|
1792
1972
|
tunnel.proxyWs.close();
|