dsh-remote-tunnel-easy 1.4.0
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/LICENSE +21 -0
- package/README.md +166 -0
- package/assets/logo.svg +81 -0
- package/cordis.patch.yml +12 -0
- package/index.js +792 -0
- package/lib/client.js +617 -0
- package/lib/cloudflared.js +196 -0
- package/lib/qrcode.js +591 -0
- package/lib/update.js +221 -0
- package/package.json +51 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cloudflared binary resolution for the dsh-remote-tunnel-easy bundle.
|
|
3
|
+
*
|
|
4
|
+
* The bundle needs a `cloudflared` binary to open a quick tunnel, but the
|
|
5
|
+
* README's "install cloudflared yourself" prerequisite is exactly the hassle
|
|
6
|
+
* this package exists to remove. This module makes it self-contained: on
|
|
7
|
+
* first tunnel start it probes PATH for an existing binary, uses it when
|
|
8
|
+
* present, and otherwise downloads the pinned official Cloudflare binary,
|
|
9
|
+
* verifies its SHA-256, and caches it under the user's cache directory.
|
|
10
|
+
*
|
|
11
|
+
* No npm dependency is added. Download uses `globalThis.fetch` (Node 22's
|
|
12
|
+
* built-in undici), hashing and file I/O use `node:*` builtins, and the macOS
|
|
13
|
+
* `.tgz` is extracted with the system `tar`. The download happens lazily at
|
|
14
|
+
* tunnel start — never at `dsh plugin add` — so the package keeps its
|
|
15
|
+
* "plain ESM, no postinstall" promise and stays installable without build
|
|
16
|
+
* approval.
|
|
17
|
+
*
|
|
18
|
+
* OS/arch support covers the six binary families Cloudflare ships; any other
|
|
19
|
+
* combination throws with an actionable message so the failure is loud, not a
|
|
20
|
+
* hung "starting" state.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Readable } from 'node:stream'
|
|
24
|
+
import { createWriteStream, createReadStream, mkdirSync, existsSync, chmodSync, rmSync, renameSync } from 'node:fs'
|
|
25
|
+
import { homedir, tmpdir } from 'node:os'
|
|
26
|
+
import { join, dirname } from 'node:path'
|
|
27
|
+
import { execFile } from 'node:child_process'
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
|
|
30
|
+
/** Pinned cloudflared release this bundle downloads when no system binary exists. */
|
|
31
|
+
export const CLOUDFLARED_VERSION = '2026.8.2'
|
|
32
|
+
|
|
33
|
+
const DOWNLOAD_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Canonical platform+arch → release asset. Keys use process.platform plus
|
|
37
|
+
* process.arch (Standard mapping: 'x64'/'ia32' on win32, 'x64'/'arm64'
|
|
38
|
+
* elsewhere). `archive: true` marks the macOS `.tgz` that must be extracted.
|
|
39
|
+
* Per-asset digests are the GitHub assets-API `digest` values, not the release
|
|
40
|
+
* body checksums (those were observed to disagree for the darwin tarball).
|
|
41
|
+
*/
|
|
42
|
+
const ASSETS = {
|
|
43
|
+
'win32-x64': { file: 'cloudflared-windows-amd64.exe', archive: false, sha256: 'c29eee2b121f5436a642eed69fd9767da7e7b8c510fa50aaa130337f931357b5' },
|
|
44
|
+
'win32-ia32': { file: 'cloudflared-windows-386.exe', archive: false, sha256: '6acb072357618fa16c53c43e05438ed728aacd47119f1c6c3aa1a668c3299b43' },
|
|
45
|
+
'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', archive: true, sha256: 'f1727723c586500e2092368ae21871b3df7ddfd2cb097f22d81bee4a9c458bb4' },
|
|
46
|
+
'darwin-arm64': { file: 'cloudflared-darwin-arm64.tgz', archive: true, sha256: '9042c2c5d8b2de78e60f313d5fb31b6c5c1cebde787a3caf1f2c9588084ac442' },
|
|
47
|
+
'linux-x64': { file: 'cloudflared-linux-amd64', archive: false, sha256: 'fcfb02b575a52ca1af2e3267af4e1517bcdeb30ac48c834c69abaed3c0576ad2' },
|
|
48
|
+
'linux-arm64': { file: 'cloudflared-linux-arm64', archive: false, sha256: '7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalize the running platform/arch into one of the ASSETS keys, or null. */
|
|
52
|
+
export function platformKey(platform = process.platform, arch = process.arch) {
|
|
53
|
+
if (platform === 'win32' && (arch === 'x64' || arch === 'ia32')) return `win32-${arch}`
|
|
54
|
+
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return `darwin-${arch}`
|
|
55
|
+
if (platform === 'linux' && (arch === 'x64' || arch === 'arm64')) return `linux-${arch}`
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Cache directory for downloaded binaries (per-user, outlives the session). */
|
|
60
|
+
export function cacheDir() {
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
const base = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local')
|
|
63
|
+
return join(base, 'dsh-remote-tunnel-easy', 'cloudflared')
|
|
64
|
+
}
|
|
65
|
+
const base = process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache')
|
|
66
|
+
return join(base, 'dsh-remote-tunnel-easy', 'cloudflared')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Absolute path of the cached binary for `key`, encoding version so an upgrade re-downloads. */
|
|
70
|
+
function cachedBinaryPath(key) {
|
|
71
|
+
const ext = process.platform === 'win32' ? '.exe' : ''
|
|
72
|
+
return join(cacheDir(), `cloudflared-${CLOUDFLARED_VERSION}-${key}${ext}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Probe PATH for an executable named `cloudflared`; resolve undefined when absent. */
|
|
76
|
+
function probePath() {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
execFile('cloudflared', ['--version'], { windowsHide: true, timeout: 8000 }, (error) => {
|
|
79
|
+
// Any error (ENOENT, non-zero exit, timeout) means "not usable on PATH".
|
|
80
|
+
resolve(error === null ? 'cloudflared' : undefined)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** SHA-256 of a file, hex-encoded, streamed to avoid buffering ~30–55 MB. */
|
|
86
|
+
function sha256File(path) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const hash = createHash('sha256')
|
|
89
|
+
const stream = createReadStream(path)
|
|
90
|
+
stream.on('data', (chunk) => hash.update(chunk))
|
|
91
|
+
stream.on('end', () => resolve(hash.digest('hex')))
|
|
92
|
+
stream.on('error', reject)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Stream `url` to `dest`; resolves once fully written. */
|
|
97
|
+
function download(url, dest) {
|
|
98
|
+
return fetch(url).then(async (response) => {
|
|
99
|
+
if (!response.ok) throw new Error(`cloudflared download failed: HTTP ${response.status}`)
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
const sink = createWriteStream(dest)
|
|
102
|
+
Readable.fromWeb(response.body).pipe(sink)
|
|
103
|
+
sink.on('finish', resolve)
|
|
104
|
+
sink.on('error', reject)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Extract a macOS `.tgz` with the system tar; `expectName` is the file it looks for. */
|
|
110
|
+
function extractTgz(tgzPath, destDir) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
execFile('/usr/bin/tar', ['-xzf', tgzPath, '-C', destDir], (error) => {
|
|
113
|
+
if (error) reject(new Error(`cloudflared archive extraction failed: ${error.message}`))
|
|
114
|
+
else resolve()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a runnable cloudflared path honoring the bundle's config.
|
|
121
|
+
*
|
|
122
|
+
* Priority:
|
|
123
|
+
* 1. `config.cloudflaredPath` non-empty → use it verbatim (manual override,
|
|
124
|
+
* never probe or download).
|
|
125
|
+
* 2. A `cloudflared` binary reachable on PATH → use it (no download).
|
|
126
|
+
* 3. A cached download for the pinned version+platform/arch → reuse it.
|
|
127
|
+
* 4. Download the pinned asset, verify its SHA-256, extract if needed, cache it.
|
|
128
|
+
*
|
|
129
|
+
* `onProgress(message)` is invoked during resolution so the Settings card can
|
|
130
|
+
* show "downloading… / verifying… / extracting…" instead of a bare spinner.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<{ path: string, kind: 'override'|'system'|'downloaded' }>}
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveCloudflared(config, onProgress = () => {}) {
|
|
135
|
+
if (typeof config.cloudflaredPath === 'string' && config.cloudflaredPath !== '') {
|
|
136
|
+
return { path: config.cloudflaredPath, kind: 'override' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const system = await probePath()
|
|
140
|
+
if (system !== undefined) return { path: system, kind: 'system' }
|
|
141
|
+
|
|
142
|
+
if (config.autoInstallCloudflared === false) {
|
|
143
|
+
throw new Error('cloudflared not found on PATH and auto-install is disabled — install it and set cloudflaredPath')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const key = platformKey()
|
|
147
|
+
const asset = ASSETS[key]
|
|
148
|
+
if (asset === undefined) {
|
|
149
|
+
const plat = `${process.platform}-${process.arch}`
|
|
150
|
+
throw new Error(`cloudflared has no official binary for ${plat} — install it yourself and set cloudflaredPath`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const finalPath = cachedBinaryPath(key)
|
|
154
|
+
if (existsSync(finalPath)) return { path: finalPath, kind: 'downloaded' }
|
|
155
|
+
|
|
156
|
+
const dir = dirname(finalPath)
|
|
157
|
+
mkdirSync(dir, { recursive: true })
|
|
158
|
+
// Download into a temp file and only rename on success, so an interrupted or
|
|
159
|
+
// failed fetch never leaves a half-written cached binary that looks valid.
|
|
160
|
+
const work = tmpdir()
|
|
161
|
+
const tmpAsset = join(work, `dsh-remote-tunnel-easy-${asset.file}-${process.pid}`)
|
|
162
|
+
const tmpBin = join(work, `dsh-remote-tunnel-easy-cloudflared-${process.pid}`)
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
onProgress('downloading cloudflared…')
|
|
166
|
+
await download(`${DOWNLOAD_BASE}/${asset.file}`, tmpAsset)
|
|
167
|
+
|
|
168
|
+
onProgress('verifying cloudflared…')
|
|
169
|
+
const actual = await sha256File(tmpAsset)
|
|
170
|
+
if (actual !== asset.sha256) {
|
|
171
|
+
throw new Error(`cloudflared checksum mismatch for ${asset.file} (got ${actual.slice(0, 12)}…)`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (asset.archive) {
|
|
175
|
+
onProgress('extracting cloudflared…')
|
|
176
|
+
const extractDir = join(work, `dsh-remote-tunnel-easy-extract-${process.pid}`)
|
|
177
|
+
mkdirSync(extractDir, { recursive: true })
|
|
178
|
+
await extractTgz(tmpAsset, extractDir)
|
|
179
|
+
const extracted = join(extractDir, 'cloudflared')
|
|
180
|
+
if (!existsSync(extracted)) throw new Error('cloudflared archive contained no cloudflared binary')
|
|
181
|
+
chmodSync(extracted, 0o700)
|
|
182
|
+
renameSync(extracted, tmpBin)
|
|
183
|
+
rmSync(extractDir, { recursive: true, force: true })
|
|
184
|
+
} else {
|
|
185
|
+
// Raw binary: lock it down and move it to its final cached name.
|
|
186
|
+
chmodSync(tmpAsset, 0o700)
|
|
187
|
+
renameSync(tmpAsset, tmpBin)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
renameSync(tmpBin, finalPath)
|
|
191
|
+
return { path: finalPath, kind: 'downloaded' }
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(tmpAsset, { force: true })
|
|
194
|
+
rmSync(tmpBin, { force: true })
|
|
195
|
+
}
|
|
196
|
+
}
|