Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be changed on one chosen server through the hub, with that server's admin token. Operators manage cache and blocking; admins also zones, settings and DHCP. Under "All servers", or on a server without an admin token, the controls are disabled with a hint. The hub refuses settings/set changes to a server's web service fields and anything about users, SSO or LDAP; backup, restore, log and stats deletion and admin/* stay refused. Non-admins get settings with secrets blanked. Write params travel in the POST body, downloads pass through with a single-use token, and the audit log names no values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
+79
-12
@@ -21,6 +21,15 @@ export class UpstreamError extends Error {
|
||||
|
||||
export type Params = Record<string, string>
|
||||
|
||||
/* What `raw` hands back: the headers worth passing on and the body as a stream. */
|
||||
export interface RawAnswer {
|
||||
contentType: string
|
||||
disposition?: string
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
/* Stops the download (the caller went away). */
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
response: unknown
|
||||
@@ -61,18 +70,73 @@ export class Upstream {
|
||||
/*
|
||||
A write: POST with the server's ADMIN token and never the read one, never cached. Whatever
|
||||
the outcome, what the hub remembers of this server is dropped afterwards, so the next read
|
||||
shows the state the write left. `body` is a multipart upload (the app zip).
|
||||
shows the state the write left.
|
||||
|
||||
The parameters travel in a form body (`application/x-www-form-urlencoded`) and only `token`
|
||||
stays in the query, so values (a record's data, a TSIG secret) do not end up in a proxy's
|
||||
access log. The server reads query or form alike. A `body` that is already a form gets the
|
||||
parameters added to it. Two bodies cannot be a form, so there the parameters stay in the
|
||||
query: a `FormData` (a multipart upload, the app zip or a zone file) and a string (a zone
|
||||
file as `text/plain`).
|
||||
*/
|
||||
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams): Promise<unknown> {
|
||||
async write(server: HubServer, path: string, params: Params = {}, body?: FormData | URLSearchParams | string): Promise<unknown> {
|
||||
if (server.adminToken === undefined) throw new UpstreamError('rejected', 'no admin token configured')
|
||||
let query = new URLSearchParams()
|
||||
const init: RequestInit = { method: 'POST' }
|
||||
if (body === undefined || body instanceof URLSearchParams) {
|
||||
const form = new URLSearchParams(body)
|
||||
for (const [k, v] of Object.entries(params)) form.set(k, v)
|
||||
init.body = form
|
||||
} else {
|
||||
query = new URLSearchParams(params)
|
||||
init.body = body
|
||||
if (typeof body === 'string') init.headers = { 'content-type': 'text/plain; charset=utf-8' }
|
||||
}
|
||||
try {
|
||||
const init: RequestInit = body === undefined ? { method: 'POST' } : { method: 'POST', body }
|
||||
return await this.#exec(server, path, new URLSearchParams(params), server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
|
||||
return await this.#exec(server, path, query, server.adminToken, init, Math.max(this.#config.timeoutMs, WRITE_TIMEOUT_MS))
|
||||
} finally {
|
||||
this.invalidate(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A file the server answers with (a zone export, a log, a block list): read with the READ token,
|
||||
never cached. A JSON answer is the usual envelope, so an error is raised as for any call; any
|
||||
other answer is handed back as it is, to be streamed to the caller. The timeout covers the wait
|
||||
for the headers only: a big log may take longer to arrive.
|
||||
*/
|
||||
async raw(server: HubServer, path: string, params: Params = {}): Promise<RawAnswer> {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('token', server.token)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, { method: 'GET', signal: controller.signal })
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const type = res.headers.get('content-type') ?? 'application/octet-stream'
|
||||
if (/json/i.test(type)) {
|
||||
let envelope: { status?: string; response?: unknown; errorMessage?: string }
|
||||
try {
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
throw new UpstreamError('unreachable', this.#describe(e, [server.token], this.#config.timeoutMs))
|
||||
}
|
||||
throw envelope?.status === 'ok' ? new UpstreamError('rejected', 'it sent JSON where a file was expected') : this.#rejection(envelope)
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError('rejected', `status ${res.status}`)
|
||||
return {
|
||||
contentType: type,
|
||||
disposition: res.headers.get('content-disposition') ?? undefined,
|
||||
body: res.body,
|
||||
abort: () => controller.abort(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget every cached answer of one server. */
|
||||
invalidate(serverId: string): void {
|
||||
const prefix = `${serverId}\n`
|
||||
@@ -89,7 +153,7 @@ export class Upstream {
|
||||
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
|
||||
})
|
||||
envelope = (await res.json()) as typeof envelope
|
||||
} catch (e) {
|
||||
@@ -98,16 +162,19 @@ export class Upstream {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (envelope?.status !== 'ok') {
|
||||
const why =
|
||||
envelope?.status === 'invalid-token'
|
||||
? 'its API token was refused'
|
||||
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
||||
throw new UpstreamError('rejected', why)
|
||||
}
|
||||
if (envelope?.status !== 'ok') throw this.#rejection(envelope)
|
||||
return envelope.response
|
||||
}
|
||||
|
||||
/* The error for an envelope that is not `ok`. */
|
||||
#rejection(envelope: { status?: string; errorMessage?: string } | undefined): UpstreamError {
|
||||
const why =
|
||||
envelope?.status === 'invalid-token'
|
||||
? 'its API token was refused'
|
||||
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
||||
return new UpstreamError('rejected', why)
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
|
||||
Reference in new issue
Block a user