- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) / custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh token, clear edge-refusal vs DSH-refusal errors - link + enroll go through the edge; master.edgeAuth stored at pair time, secrets as DSH credential refs only; config edgeAuth override - /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form - pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty - tests: fake Authentik-like proxy for all modes + WebSocket through the edge
136 lines
6.8 KiB
JavaScript
136 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Pair this machine with a dsh-remote master WITHOUT a running local DSH: exchange a
|
|
// one-time pairing code for a device key and write the slave link state.
|
|
//
|
|
// node deploy/pair.mjs --master https://dsh.example.com --code ABCD-... --name my-mac
|
|
// [--edge basic --edge-user USER] # password from $DSH_REMOTE_EDGE_PASSWORD or prompt
|
|
// [--edge bearer] # token from $DSH_REMOTE_EDGE_TOKEN or prompt
|
|
// [--edge oauth2 --edge-token-url URL --edge-client-id ID [--edge-user USER] [--edge-client-secret]]
|
|
// [--edge headers --edge-header "CF-Access-Client-Id: …" --edge-header "CF-Access-Client-Secret: …"]
|
|
// [--state <file>]
|
|
//
|
|
// Edge = an auth proxy (Authentik, oauth2-proxy, Cloudflare Access, Basic) in front of the
|
|
// master. Its secrets are stored in $DSH_HOME/.credentials.yaml (refs, 0600) — the same store
|
|
// DSH's credentials service reads — and only their ref names go into the link state.
|
|
// Restart DSH afterwards.
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import readline from 'node:readline'
|
|
import tty from 'node:tty'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const { createEdgeAuth } = await import(path.join(here, '..', 'lib', 'edge-auth.mjs'))
|
|
const { setRefIn, getRefIn } = await import(path.join(here, 'pair-lib.mjs'))
|
|
|
|
const args = process.argv.slice(2)
|
|
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
|
|
const opts = (k) => args.flatMap((a, i) => (a === `--${k}` ? [args[i + 1]] : []))
|
|
const master = String(opt('master', '')).replace(/\/+$/, '')
|
|
const code = opt('code', '')
|
|
const name = opt('name', os.hostname())
|
|
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
const stateFile = opt('state', path.join(home, 'profiles', opt('profile', 'web'), 'dsh-remote', 'state.json'))
|
|
if (!/^https?:\/\//.test(master) || !code) {
|
|
console.error('usage: pair.mjs --master <http(s)://…> --code <pairing code> [--name <device name>] [--edge basic|bearer|oauth2|headers …]')
|
|
process.exit(2)
|
|
}
|
|
|
|
// ---- credentials.yaml refs (the store DSH's credentials service reads) ----------------
|
|
const credFile = path.join(home, '.credentials.yaml')
|
|
function setRef(ref, value) {
|
|
let text = ''
|
|
try { text = fs.readFileSync(credFile, 'utf8') } catch { text = '' }
|
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
const tmp = `${credFile}.${process.pid}.tmp`
|
|
fs.writeFileSync(tmp, setRefIn(text, ref, value), { mode: 0o600 })
|
|
fs.renameSync(tmp, credFile)
|
|
}
|
|
function getRef(ref) {
|
|
if (process.env[ref]) return process.env[ref]
|
|
try { return getRefIn(fs.readFileSync(credFile, 'utf8'), ref) } catch { return undefined }
|
|
}
|
|
function prompt(question) {
|
|
// `curl … | bash` makes stdin the script; read from the terminal instead.
|
|
let input = process.stdin
|
|
let output = process.stdout
|
|
if (!process.stdin.isTTY) {
|
|
try {
|
|
const fd = fs.openSync('/dev/tty', 'r+')
|
|
input = new tty.ReadStream(fd)
|
|
output = new tty.WriteStream(fd)
|
|
} catch { return Promise.resolve('') }
|
|
}
|
|
return new Promise((resolve) => {
|
|
const rl = readline.createInterface({ input, output, terminal: true })
|
|
rl._writeToOutput = (s) => { if (s.includes(question)) output.write(s) } // hide typed secret
|
|
rl.question(question, (a) => { rl.close(); output.write('\n'); if (input !== process.stdin) input.destroy(); resolve(a.trim()) })
|
|
})
|
|
}
|
|
|
|
// ---- edge auth spec ----------------------------------------------------------------------
|
|
const edgeMode = opt('edge', 'none')
|
|
let edgeAuth = { mode: 'none' }
|
|
async function secretRef(ref, what) {
|
|
let v = getRef(ref)
|
|
if (!v) v = await prompt(`${what}: `)
|
|
if (!v) { console.error(`missing ${what} (set ${ref} or answer the prompt)`); process.exit(2) }
|
|
setRef(ref, v)
|
|
return ref
|
|
}
|
|
if (edgeMode === 'basic') {
|
|
edgeAuth = { mode: 'basic', username: opt('edge-user', ''), passwordRef: await secretRef('DSH_REMOTE_EDGE_PASSWORD', `Password / app password for "${opt('edge-user', '')}" at the auth proxy`) }
|
|
} else if (edgeMode === 'bearer') {
|
|
edgeAuth = { mode: 'bearer', tokenRef: await secretRef('DSH_REMOTE_EDGE_TOKEN', 'Bearer token for the auth proxy') }
|
|
} else if (edgeMode === 'oauth2') {
|
|
edgeAuth = { mode: 'oauth2', tokenUrl: opt('edge-token-url'), clientId: opt('edge-client-id'), username: opt('edge-user'), scope: opt('edge-scope') }
|
|
if (!edgeAuth.tokenUrl || !edgeAuth.clientId) { console.error('--edge oauth2 needs --edge-token-url and --edge-client-id'); process.exit(2) }
|
|
if (edgeAuth.username) edgeAuth.passwordRef = await secretRef('DSH_REMOTE_EDGE_PASSWORD', `App password for "${edgeAuth.username}"`)
|
|
if (args.includes('--edge-client-secret')) edgeAuth.clientSecretRef = await secretRef('DSH_REMOTE_EDGE_CLIENT_SECRET', 'OAuth2 client secret')
|
|
} else if (edgeMode === 'headers') {
|
|
edgeAuth = { mode: 'headers', headers: {} }
|
|
let i = 0
|
|
for (const h of opts('edge-header')) {
|
|
const at = h.indexOf(':')
|
|
if (at <= 0) { console.error(`bad --edge-header "${h}" (want "Name: value")`); process.exit(2) }
|
|
const ref = `DSH_REMOTE_EDGE_HEADER_${++i}`
|
|
setRef(ref, h.slice(at + 1).trim())
|
|
edgeAuth.headers[h.slice(0, at).trim()] = `ref:${ref}`
|
|
}
|
|
} else if (edgeMode !== 'none') {
|
|
console.error(`unknown --edge mode ${edgeMode}`)
|
|
process.exit(2)
|
|
}
|
|
|
|
// ---- enroll -----------------------------------------------------------------------------------
|
|
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'device'
|
|
let state = {}
|
|
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')) } catch { state = {} }
|
|
state.self ||= { id: slug(name), name, createdAt: Date.now() }
|
|
state.self.name = name
|
|
|
|
const edge = createEdgeAuth(edgeAuth, async (ref) => getRef(ref))
|
|
let r
|
|
try {
|
|
r = await edge.edgeFetch(`${master}/dsh-remote/enroll`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ code: code.trim().toUpperCase(), id: state.self.id, name, platform: `${process.platform}-${process.arch}` }),
|
|
})
|
|
} catch (e) {
|
|
console.error(`pairing failed: ${e.message}${e.hint ? `\n hint: ${e.hint}` : ''}`)
|
|
process.exit(1)
|
|
}
|
|
let j = {}
|
|
try { j = await r.json() } catch { j = {} }
|
|
if (!r.ok) { console.error(`pairing failed: ${j.error || `HTTP ${r.status}`}`); process.exit(1) }
|
|
|
|
state.master = { url: master, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() }
|
|
state.self.id = j.deviceId
|
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 })
|
|
const tmp = `${stateFile}.${process.pid}.tmp`
|
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 })
|
|
fs.renameSync(tmp, stateFile)
|
|
console.log(`paired as "${j.deviceId}" with master ${j.master?.name || master}${edgeAuth.mode !== 'none' ? ` (through ${edgeAuth.mode} edge auth)` : ''}`)
|