-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhole.js
More file actions
executable file
·474 lines (417 loc) · 19.9 KB
/
hole.js
File metadata and controls
executable file
·474 lines (417 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
#!/usr/bin/env node
/**
* Hole — P2P SSH tunnel via HyperDHT. No port forwarding required.
*/
import { parseArgs, die } from './lib/utils.js'
import { addDevice, removeDevice, listDevices, holeDir, keypairPath, loadRegistry } from './lib/registry.js'
import { aclAdd, aclRemove, aclList } from './lib/acl.js'
import { existsSync } from 'fs'
const [,, cmd, ...rest] = process.argv
const { flags, positional } = parseArgs(rest)
// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------
const USAGE = `
Hole — P2P SSH tunnel over HyperDHT. No port forwarding. No VPN.
Usage:
hole agent Start the tunnel agent on this host
hole ssh <target> [user] SSH into a remote agent (tunnel + ssh in one command)
hole exec <target> <user> Run a single command on a remote host (tunnel + ssh -c)
hole copy <src> <dest> Copy files to/from a remote host via scp
hole ping <target> Check if a remote agent is reachable (DHT latency)
hole client <target> [svc] Open a local tunnel port (manual/scripting use)
hole relay Start a relay node (for CGNAT / mobile hotspot)
hole install-service Install agent as a system service (auto-start on boot)
hole uninstall-service Remove the system service
hole completion Generate shell completion script (bash)
hole list List known devices
hole add <name> <key> Add a device to the registry
hole remove <name> Remove a device from the registry
hole status Show config paths and registry summary
hole audit Show recent connection audit log
hole dashboard Open the web fleet dashboard (localhost:4321)
hole acl list List allowed client keys (empty = all allowed)
hole acl add <name> <key> Allow a specific client key to connect
hole acl remove <name> Remove a client key from the ACL
hole doctor Run network diagnostics (TCP, UDP, HyperDHT)
Options for agent:
--name <name> Register this agent with a friendly name
--relay <host:port> Use a custom relay node
--port <n> SSH port on this host (default: 22)
--forward <svc:port> Add a named forward, e.g. --forward rdp:3389
Options for ssh:
--user <name> SSH username (default: current OS user)
--relay <host:port> Use a custom relay node
-- <args> Extra args passed to ssh, e.g. -- -L 8080:localhost:3000
Options for exec:
-- <cmd> Command to run, e.g. hole exec server user -- ls /
Options for copy:
Remote paths use device:/path syntax, e.g.:
hole copy file.txt my-server:/tmp/ user
hole copy my-server:/tmp/file.txt . user
Options for ping:
--count <n> Number of probes (default: 4)
--relay <host:port> Use a custom relay node
Options for audit:
--tail <n> Show last N entries (default: 50)
Options for add:
--user <name> Default SSH username for this device
--relay <host:port> Default relay to use for this device
--identity <path> Default SSH identity file (private key) for this device
--tag <label> Tag this device (repeatable: --tag homelab --tag web)
Options for list:
--tag <label> Filter by tag
--ping Check live latency for each device
Options for client:
--port <n> Local proxy port (default: 2222)
--relay <host:port> Use a custom relay node
Options for dashboard:
--port <n> Port to listen on (default: 4321)
Options for relay:
--host <ip> Public IPv4 address to bind as relay
--port <n> UDP port to listen on (default: 49737)
Options for install-service:
--name <name> Device name to pass to agent
--relay <host:port> Relay to pass to agent
Examples:
# Register an agent, then use it:
hole agent --name my-server # on the server
hole add my-server <printed-key> # on your laptop
hole ssh my-server alice # SSH in
hole exec my-server alice -- uptime # run one command
hole copy file.txt my-server:/tmp/ alice # upload a file
hole copy my-server:/var/log/app.log . alice # download a file
hole ping my-server # check if it's up
# Multiple services on one host:
hole agent --name my-pc --forward rdp:3389 --forward web:3000
hole client my-pc rdp # opens local port for RDP client
hole client my-pc web # opens local port for browser
# Lock down who can connect:
hole acl add laptop <my-laptop-public-key>
hole agent --name my-pc # now only 'laptop' can connect
# Install as a service (auto-start on boot):
hole install-service --name my-pc
# Audit recent connections:
hole audit
hole audit --tail 20
Config: ${holeDir()}
`.trim()
// ---------------------------------------------------------------------------
// Parse --forward flags → array of { name, host, port }
// --forward rdp:3389 or --forward web:127.0.0.1:3000
// ---------------------------------------------------------------------------
function parseForwards (rawForwards) {
if (!rawForwards) return []
const list = Array.isArray(rawForwards) ? rawForwards : [rawForwards]
return list.map(raw => {
const parts = raw.split(':')
if (parts.length === 2) {
// name:port
return { name: parts[0], host: '127.0.0.1', port: parseInt(parts[1], 10) }
}
if (parts.length === 3) {
// name:host:port
return { name: parts[0], host: parts[1], port: parseInt(parts[2], 10) }
}
die(`Invalid --forward value "${raw}". Use: name:port or name:host:port`)
})
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main () {
switch (cmd) {
// ── agent ──────────────────────────────────────────────────────────────
case 'agent': {
const { run } = await import('./lib/agent.js')
await run({
name: flags.name ?? null,
relay: flags.relay ?? null,
forwards: parseForwards(flags.forward),
sshHost: process.env.SSH_HOST ?? '127.0.0.1',
sshPort: parseInt(flags.port ?? process.env.SSH_PORT ?? '22', 10)
})
break
}
// ── ssh ────────────────────────────────────────────────────────────────
case 'ssh': {
const target = positional[0]
if (!target) die('Usage: hole ssh <name|key> [user] [--relay host:port] [-- extra-ssh-args]')
const devices = loadRegistry()
const dev = /^[0-9a-f]{64}$/i.test(target) ? null : devices[target]
const user = positional[1] ?? flags.user ?? dev?.user ?? null
const relay = flags.relay ?? dev?.relay ?? null
const identity = flags.identity ?? dev?.identity ?? null
// Everything after `--` is forwarded directly to ssh
const ddash = process.argv.indexOf('--')
const sshArgs = ddash !== -1 ? process.argv.slice(ddash + 1) : []
const { ssh } = await import('./lib/client.js')
await ssh({
target,
user,
relay,
identity,
sshArgs
})
break
}
// ── ping ───────────────────────────────────────────────────────────────
case 'ping': {
const target = positional[0]
if (!target) die('Usage: hole ping <name|key> [--count 4] [--relay host:port]')
const devices = loadRegistry()
const dev = /^[0-9a-f]{64}$/i.test(target) ? null : devices[target]
const { ping } = await import('./lib/ping.js')
await ping({
target,
count: parseInt(flags.count ?? '4', 10),
relay: flags.relay ?? dev?.relay ?? null
})
break
}
// ── exec ───────────────────────────────────────────────────────────────
case 'exec': {
const target = positional[0]
if (!target) die('Usage: hole exec <name|key> <user> [--relay host:port] -- <command>')
const devices = loadRegistry()
const dev = /^[0-9a-f]{64}$/i.test(target) ? null : devices[target]
const user = positional[1] ?? flags.user ?? dev?.user ?? null
const relay = flags.relay ?? dev?.relay ?? null
const identity = flags.identity ?? dev?.identity ?? null
const ddash = process.argv.indexOf('--')
const cmd_ = ddash !== -1 ? process.argv.slice(ddash + 1) : []
const { exec } = await import('./lib/client.js')
await exec({ target, user, relay, identity, cmd: cmd_ })
break
}
// ── copy ───────────────────────────────────────────────────────────────
case 'copy': {
// hole copy <src> <dest> [user]
// remote paths: device:/path
const src = positional[0]
const dest = positional[1]
const userFlag = positional[2] ?? flags.user ?? null
if (!src || !dest) die('Usage: hole copy <src> <dest> [user]\n Remote: device:/path e.g. hole copy file.txt my-server:/tmp/ alice')
// Derive target device name from whichever arg is remote (device:/path)
const REMOTE_RE = /^([^/:\\]+):/
const srcDev = src.match(REMOTE_RE)?.[1]
const destDev = dest.match(REMOTE_RE)?.[1]
const target = srcDev ?? destDev
if (!target) die('One of src or dest must be a remote path in the form device:/path')
const devices = loadRegistry()
const dev = /^[0-9a-f]{64}$/i.test(target) ? null : devices[target]
const user = userFlag ?? dev?.user ?? null
const relay = flags.relay ?? dev?.relay ?? null
const identity = flags.identity ?? dev?.identity ?? null
const { copy } = await import('./lib/client.js')
await copy({ target, src, dest, user, relay, identity })
break
}
// ── dashboard ──────────────────────────────────────────────────────────
case 'dashboard': {
const { run } = await import('./lib/dashboard.js')
await run({ port: parseInt(flags.port ?? '4321', 10) })
break
}
// ── audit ──────────────────────────────────────────────────────────────
case 'audit': {
const { printAuditLog } = await import('./lib/audit.js')
printAuditLog({ tail: parseInt(flags.tail ?? '50', 10) })
break
}
// ── client ─────────────────────────────────────────────────────────────
case 'client': {
const target = positional[0]
const service = positional[1] ?? null // optional: ssh | rdp | web | ...
if (!target) die('Usage: hole client <name|key> [service] [--port 2222] [--relay host:port]')
const { run } = await import('./lib/client.js')
const devices = loadRegistry()
const dev = /^[0-9a-f]{64}$/i.test(target) ? null : devices[target]
await run({
target,
service,
port: parseInt(flags.port ?? '2222', 10),
relay: flags.relay ?? dev?.relay ?? null
})
break
}
// ── relay ──────────────────────────────────────────────────────────────
case 'relay': {
const { run } = await import('./lib/relay.js')
await run({
port: parseInt(flags.port ?? '49737', 10),
host: flags.host ?? null
})
break
}
// ── doctor ───────────────────────────────────────────────────────────────
case 'doctor': {
const { run } = await import('./lib/doctor.js')
await run()
break
}
// ── install-service ────────────────────────────────────────────────────
case 'install-service': {
const { install } = await import('./lib/installer.js')
const agentArgs = []
if (flags.name) agentArgs.push('--name', flags.name)
if (flags.relay) agentArgs.push('--relay', flags.relay)
install(agentArgs)
break
}
case 'uninstall-service': {
const { uninstall } = await import('./lib/installer.js')
uninstall()
break
}
// ── completion ─────────────────────────────────────────────────────────
case 'completion': {
console.log(`
# Hole bash completion
_hole_completion() {
local cur prev opts
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
opts="agent ssh exec copy ping client relay install-service uninstall-service list add remove status audit dashboard acl doctor help completion"
case "\${prev}" in
ssh|exec|copy|ping|client|remove|add)
local devices=$(hole list | awk 'NR>2 {print $1}' | grep -v '^─' | grep -v '^$')
COMPREPLY=( $(compgen -W "\${devices}" -- \${cur}) )
return 0
;;
*)
;;
esac
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
}
complete -F _hole_completion hole
`.trim())
break
}
// ── list ───────────────────────────────────────────────────────────────
case 'list': {
let devices = listDevices()
const filterTag = flags.tag ?? null
if (filterTag) {
const tag = String(filterTag)
devices = Object.fromEntries(
Object.entries(devices).filter(([, d]) => (d.tags ?? []).includes(tag))
)
}
const names = Object.keys(devices)
if (!names.length) {
console.log(filterTag
? `No devices with tag "${filterTag}".`
: 'No devices registered.\nAdd one with: hole add <name> <64-char-key>')
} else {
const showPing = flags.ping === true
const { pingOne } = showPing ? await import('./lib/ping.js') : {}
const header = `\n${'NAME'.padEnd(20)} ${'KEY (16)'.padEnd(18)} ${'HOST'.padEnd(18)} ${'TAGS'.padEnd(18)} ${'SERVICES'.padEnd(20)} ${showPing ? 'PING'.padEnd(10) : ''}LAST SEEN`
console.log(header)
console.log('─'.repeat(showPing ? 118 : 108))
for (const [name, d] of Object.entries(devices)) {
const key = (d.key ?? '').slice(0, 16) + '...'
const host = (d.host ?? '').slice(0, 16)
const tags = (d.tags ?? []).join(', ').slice(0, 16) || '—'
const svcs = Object.keys(d.services ?? {}).join(', ') || '—'
const seen = (d.lastSeen ?? '').slice(0, 16).replace('T', ' ') || 'never'
let pingCol = ''
if (showPing) {
const res = await pingOne({ target: name, relay: d.relay })
pingCol = (res.online ? `${res.ms}ms` : 'DOWN').padEnd(10)
}
console.log(`${name.padEnd(20)} ${key.padEnd(18)} ${host.padEnd(18)} ${tags.padEnd(18)} ${svcs.padEnd(20)} ${pingCol}${seen}`)
}
console.log('')
}
break
}
// ── add ────────────────────────────────────────────────────────────────
case 'add': {
const [name, key] = positional
if (!name || !key) die('Usage: hole add <name> <64-char-hex-key>')
if (!/^[0-9a-f]{64}$/i.test(key)) die('Key must be a 64-character hex string.')
const rawTags = flags.tag ?? null
const tags = rawTags ? [].concat(rawTags) : undefined
addDevice(name, key, {
user: flags.user ?? undefined,
relay: flags.relay ?? undefined,
identity: flags.identity ?? undefined,
tags
})
const extras = []
if (flags.user) extras.push(`user=${flags.user}`)
if (flags.relay) extras.push(`relay=${flags.relay}`)
if (flags.identity) extras.push(`identity=${flags.identity}`)
if (tags?.length) extras.push(`tags=${tags.join(',')}`)
console.log(`Added "${name}" → ${key.slice(0, 16)}...${extras.length ? ` (${extras.join(', ')})` : ''}`)
break
}
// ── remove ─────────────────────────────────────────────────────────────
case 'remove': {
const [name] = positional
if (!name) die('Usage: hole remove <name>')
removeDevice(name) ? console.log(`Removed "${name}".`) : die(`Device "${name}" not found.`)
break
}
// ── status ─────────────────────────────────────────────────────────────
case 'status': {
const dir = holeDir()
const kp = keypairPath()
const reg = loadRegistry()
console.log('\n=== Hole Status ===')
console.log(`Config dir : ${dir}`)
console.log(`Keypair : ${kp} (${existsSync(kp) ? 'exists' : 'not generated'})`)
console.log(`Devices : ${Object.keys(reg).length} registered`)
console.log('')
break
}
// ── acl ────────────────────────────────────────────────────────────────
case 'acl': {
const sub = positional[0]
if (sub === 'list') {
const acl = aclList()
const entries = Object.entries(acl)
if (!entries.length) {
console.log('ACL is empty — all clients are allowed.')
} else {
console.log(`\n${'NAME'.padEnd(20)} KEY`)
console.log('─'.repeat(70))
for (const [name, key] of entries) {
console.log(`${name.padEnd(20)} ${key}`)
}
console.log('')
}
break
}
if (sub === 'add') {
const [, name, key] = positional
if (!name || !key) die('Usage: hole acl add <name> <64-char-key>')
aclAdd(name, key)
console.log(`ACL: added "${name}" → ${key.slice(0, 16)}...`)
break
}
if (sub === 'remove') {
const [, name] = positional
if (!name) die('Usage: hole acl remove <name>')
aclRemove(name) ? console.log(`ACL: removed "${name}".`) : die(`"${name}" not in ACL.`)
break
}
die('Usage: hole acl <list|add|remove>')
}
// ── help ───────────────────────────────────────────────────────────────
case undefined:
case '--help':
case 'help':
console.log(USAGE)
break
default:
console.error(`Unknown command: ${cmd}\nRun 'hole help' for usage.`)
process.exit(1)
}
}
main().catch(e => {
console.error(`\nFatal: ${e.message}`)
process.exit(1)
})