Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and match each card to a member, via two transports sharing one ingest core. Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in native-HeatSync connection's ?z log through the existing hardened executor and parses it (firmware-characterized, best-effort ring buffer). Webhook: /api/door/inbound/[connection] accepts pushed event JSON authenticated by a per-connection bearer secret from the vault. Both dedupe on (connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an inbound config panel to /door/manage. Phase 5 (migration 054): admins define api_buttons that fire a configured HTTP call (full verbs + headers + body) when an authorized member presses them, through the same hardened egress (now shared between callDoor and the new callApi). Each button carries a required_permission (default the new apicall.invoke code); members see only buttons they may press. Builder at /door/buttons with a door-template preset; members press from /doors. Every press is recorded in api_call_log with secrets redacted. Both subsystems are inert until an admin enables inbound on a connection or creates a button. 624 unit + 50 integration tests; migrations applied top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
+115
-30
@@ -1,12 +1,12 @@
|
||||
// Server-only. The single hardened egress point for door-controller HTTP.
|
||||
// Every door call funnels through here so the SSRF guard, no-redirect rule,
|
||||
// timeout, response cap and secret redaction cannot be bypassed. The secret
|
||||
// is already embedded in `url` by the caller (per the verified firmware,
|
||||
// auth is a query param); it is never logged -- only a redacted snippet is
|
||||
// ever returned.
|
||||
// Server-only. The single hardened egress point for both the door controller
|
||||
// HTTP (callDoor) and the universal API-call buttons (callApi). Every outbound
|
||||
// call funnels through `egress` so the SSRF guard, no-redirect rule, timeout,
|
||||
// response cap and secret redaction cannot be bypassed. Secrets are never
|
||||
// logged -- only a redacted snippet is ever returned.
|
||||
|
||||
import dns from 'node:dns/promises'
|
||||
import { validateDoorTarget, redactDoorSecrets, isBlockedDoorIp } from '@/lib/door-logic'
|
||||
import { buildApiRequest, type ApiAuthMode } from '@/lib/api-call-logic'
|
||||
|
||||
export type DoorCallResult =
|
||||
| { ok: true; status: number; snippet: string }
|
||||
@@ -14,39 +14,57 @@ export type DoorCallResult =
|
||||
|
||||
const MAX_BODY = 4096
|
||||
const SNIPPET = 500
|
||||
// Log-ingest polls (HeatSync ?z) need the whole ring-buffer dump, which is
|
||||
// larger than a status snippet. Capped so a hostile/garbage response still
|
||||
// cannot exhaust memory.
|
||||
const MAX_BODY_INGEST = 32768
|
||||
// API-button responses: enough for a useful audit detail, not unbounded.
|
||||
const MAX_BODY_API = 8192
|
||||
const SNIPPET_API = 1000
|
||||
|
||||
export async function callDoor(opts: {
|
||||
// The shared hardened egress. Validates the target against the connection's
|
||||
// pinned host, resolves once and connects to the validated IP literal (so
|
||||
// fetch performs no second resolution -> closes the DNS-rebind TOCTOU),
|
||||
// refuses redirects, caps time and body, and redacts secrets from anything
|
||||
// returned. `headers.host` is always forced to the original pinned host, so a
|
||||
// caller-supplied header map can never repoint routing or override it.
|
||||
async function egress(opts: {
|
||||
method: string
|
||||
url: string
|
||||
pinnedHost: string
|
||||
password?: string | null
|
||||
authParam?: string | null
|
||||
headers?: Record<string, string>
|
||||
body?: string | null
|
||||
redactSecret?: string | null
|
||||
redactAuthParam?: string | null
|
||||
timeoutMs?: number
|
||||
maxBody: number
|
||||
snippetLen: number
|
||||
}): Promise<DoorCallResult> {
|
||||
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
|
||||
if (!guard.ok) return { ok: false, reason: guard.reason }
|
||||
|
||||
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host,
|
||||
// and a bare fetch would do its OWN resolution (a hostname could resolve
|
||||
// to 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once
|
||||
// here, reject if ANY resolved address is blocked, then connect to the
|
||||
// validated IP literal so fetch performs no second resolution. Plaintext
|
||||
// HTTP to LAN devices that don't vhost, so connecting by IP (with a
|
||||
// best-effort original Host header) is correct; no TLS/SNI concern.
|
||||
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host, and
|
||||
// a bare fetch would do its OWN resolution (a hostname could resolve to
|
||||
// 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once here,
|
||||
// reject if ANY resolved address is blocked, then connect to the validated
|
||||
// IP literal so fetch performs no second resolution. Plaintext HTTP to LAN
|
||||
// devices that don't vhost, so connecting by IP (with a best-effort original
|
||||
// Host header) is correct; no TLS/SNI concern.
|
||||
const target = new URL(guard.url)
|
||||
let resolved: { address: string; family: number }[]
|
||||
try {
|
||||
resolved = await dns.lookup(target.hostname, { all: true, verbatim: true })
|
||||
} catch {
|
||||
return { ok: false, reason: 'Could not resolve the controller host.' }
|
||||
return { ok: false, reason: 'Could not resolve the target host.' }
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
return { ok: false, reason: 'The controller host did not resolve.' }
|
||||
return { ok: false, reason: 'The target host did not resolve.' }
|
||||
}
|
||||
for (const a of resolved) {
|
||||
if (isBlockedDoorIp(a.address)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The controller host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
reason: 'The target host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,22 +75,22 @@ export async function callDoor(opts: {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
|
||||
try {
|
||||
// Host forced last so a custom header map cannot override the pinned host.
|
||||
const headers: Record<string, string> = { ...(opts.headers ?? {}), host: target.host }
|
||||
const res = await fetch(pinned.toString(), {
|
||||
method: 'GET',
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host
|
||||
// (and would re-resolve, escaping the IP pin).
|
||||
method: opts.method,
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host (and
|
||||
// would re-resolve, escaping the IP pin).
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
// Host preserved best-effort so a vhosting controller still routes;
|
||||
// single-purpose door firmware ignores it. The UA may drop a forbidden
|
||||
// Host header — harmless for these devices.
|
||||
headers: { accept: 'text/plain, text/html', host: target.host },
|
||||
headers,
|
||||
body: opts.body ?? undefined,
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The controller returned a redirect, which is refused (anti-SSRF).',
|
||||
reason: 'The target returned a redirect, which is refused (anti-SSRF).',
|
||||
status: res.status || undefined,
|
||||
}
|
||||
}
|
||||
@@ -81,7 +99,7 @@ export async function callDoor(opts: {
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
const dec = new TextDecoder()
|
||||
while (received.length < MAX_BODY) {
|
||||
while (received.length < opts.maxBody) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
received += dec.decode(value, { stream: true })
|
||||
@@ -92,12 +110,79 @@ export async function callDoor(opts: {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password, opts.authParam)
|
||||
const snippet = redactDoorSecrets(received.slice(0, opts.snippetLen), opts.redactSecret, opts.redactAuthParam)
|
||||
return { ok: res.ok, status: res.status, snippet }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'request failed'
|
||||
return { ok: false, reason: redactDoorSecrets(msg, opts.password, opts.authParam) }
|
||||
return { ok: false, reason: redactDoorSecrets(msg, opts.redactSecret, opts.redactAuthParam) }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// Door controller call. GET only; the secret (if any) is already embedded in
|
||||
// `url` by the caller (per the verified firmware, auth is a query param).
|
||||
// `fullBody` returns the whole capped body (the inbound-log poll); otherwise a
|
||||
// 500-char snippet. Behavior is unchanged from the original single-function
|
||||
// executor.
|
||||
export async function callDoor(opts: {
|
||||
url: string
|
||||
pinnedHost: string
|
||||
password?: string | null
|
||||
authParam?: string | null
|
||||
timeoutMs?: number
|
||||
fullBody?: boolean
|
||||
}): Promise<DoorCallResult> {
|
||||
return egress({
|
||||
method: 'GET',
|
||||
url: opts.url,
|
||||
pinnedHost: opts.pinnedHost,
|
||||
headers: { accept: 'text/plain, text/html' },
|
||||
redactSecret: opts.password,
|
||||
redactAuthParam: opts.authParam,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxBody: opts.fullBody ? MAX_BODY_INGEST : MAX_BODY,
|
||||
snippetLen: opts.fullBody ? MAX_BODY_INGEST : SNIPPET,
|
||||
})
|
||||
}
|
||||
|
||||
// Universal API-call button. Full verbs + custom headers + optional body, with
|
||||
// the secret injected server-side per auth_mode (query param / custom header /
|
||||
// bearer) and never returned. Same SSRF guard, host pin, no-redirect, caps and
|
||||
// redaction as the door path.
|
||||
export async function callApi(opts: {
|
||||
method: string
|
||||
baseUrl: string
|
||||
urlTemplate?: string | null
|
||||
pinnedHost: string
|
||||
headers?: Record<string, string> | null
|
||||
body?: string | null
|
||||
authMode: ApiAuthMode
|
||||
authParam?: string | null
|
||||
secret?: string | null
|
||||
timeoutMs?: number
|
||||
}): Promise<DoorCallResult> {
|
||||
const req = buildApiRequest({
|
||||
baseUrl: opts.baseUrl,
|
||||
urlTemplate: opts.urlTemplate,
|
||||
method: opts.method,
|
||||
headers: { accept: '*/*', ...(opts.headers ?? {}) },
|
||||
body: opts.body,
|
||||
authMode: opts.authMode,
|
||||
authParam: opts.authParam,
|
||||
secret: opts.secret,
|
||||
})
|
||||
|
||||
return egress({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
pinnedHost: opts.pinnedHost,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
redactSecret: opts.secret,
|
||||
redactAuthParam: opts.authParam,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
maxBody: MAX_BODY_API,
|
||||
snippetLen: SNIPPET_API,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Server-only. The shared ingest core for Door epic Phase 4. Both transports
|
||||
// (the poll cron and the inbound webhook) parse/normalize events into
|
||||
// DoorIngestEvent[] and hand them here to resolve each card to a member and
|
||||
// idempotently insert audit rows. door_access_log has no client write policy,
|
||||
// so the service (admin) client is the only writer, exactly as for the
|
||||
// outbound actions.
|
||||
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { callDoor } from './executor'
|
||||
import { resolveDoorSecret } from './secret'
|
||||
import { encodeHeatSyncControl, applyTemplate, redactDoorSecrets } from '@/lib/door-logic'
|
||||
import {
|
||||
parseHeatSyncLog,
|
||||
cardUidsEquivalent,
|
||||
eventCardValue,
|
||||
type DoorIngestEvent,
|
||||
} from '@/lib/door-log-logic'
|
||||
|
||||
type Admin = ReturnType<typeof createAdminClient>
|
||||
|
||||
// The subset of door_connections the ingest paths read (service client).
|
||||
export type IngestConn = {
|
||||
id: string
|
||||
space_id: string
|
||||
adapter: string
|
||||
base_url: string
|
||||
pinned_host: string
|
||||
auth_param: string | null
|
||||
secret_ref: string | null
|
||||
verbs: Record<string, string> | null
|
||||
is_enabled: boolean
|
||||
}
|
||||
|
||||
function isUniqueViolation(message: string): boolean {
|
||||
return /duplicate key value|already exists|unique constraint/i.test(message)
|
||||
}
|
||||
|
||||
function actionFor(result: DoorIngestEvent['result']): string {
|
||||
if (result === 'granted') return 'entry'
|
||||
if (result === 'denied') return 'entry_denied'
|
||||
return 'inbound'
|
||||
}
|
||||
|
||||
// Resolve each event's card to a member and insert one redacted, deduped
|
||||
// door_access_log row per new event. Returns counts for the caller's response.
|
||||
// Dedup is two-layered: a pre-filter against existing (connection_id,
|
||||
// dedupe_key) cuts the common re-poll case to a single SELECT and zero writes,
|
||||
// and the partial-unique index makes a concurrent insert race a no-op (the
|
||||
// unique violation is swallowed per row).
|
||||
export async function ingestEvents(
|
||||
admin: Admin,
|
||||
spaceId: string,
|
||||
connectionId: string,
|
||||
events: DoorIngestEvent[],
|
||||
): Promise<{ inserted: number; resolved: number; skipped: number }> {
|
||||
if (events.length === 0) return { inserted: 0, resolved: 0, skipped: 0 }
|
||||
|
||||
const { data: cards } = await admin
|
||||
.from('member_cards')
|
||||
.select('id, card_uid, member_id')
|
||||
.eq('space_id', spaceId)
|
||||
.eq('is_active', true)
|
||||
const cardList = (cards ?? []) as { id: string; card_uid: string; member_id: string | null }[]
|
||||
|
||||
const keys = events.map(e => e.dedupeKey)
|
||||
const { data: existing } = await admin
|
||||
.from('door_access_log')
|
||||
.select('dedupe_key')
|
||||
.eq('connection_id', connectionId)
|
||||
.in('dedupe_key', keys)
|
||||
const have = new Set((existing ?? []).map(r => r.dedupe_key as string))
|
||||
|
||||
let inserted = 0
|
||||
let resolved = 0
|
||||
let skipped = 0
|
||||
for (const ev of events) {
|
||||
if (have.has(ev.dedupeKey)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const value = eventCardValue(ev)
|
||||
const card = value ? cardList.find(c => cardUidsEquivalent(c.card_uid, value)) : undefined
|
||||
const targetMemberId = card?.member_id ?? null
|
||||
if (card) resolved++
|
||||
|
||||
const detail = redactDoorSecrets(
|
||||
`${ev.detail} (${card ? 'member matched' : 'no card match'})`.slice(0, 1000),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
space_id: spaceId,
|
||||
connection_id: connectionId,
|
||||
actor_member_id: null,
|
||||
target_member_id: targetMemberId,
|
||||
action: actionFor(ev.result),
|
||||
success: ev.result === 'granted',
|
||||
detail,
|
||||
dedupe_key: ev.dedupeKey,
|
||||
}
|
||||
// A webhook caller supplies occurred_at; accept only a parseable,
|
||||
// not-in-the-future time (60s skew) so a relay cannot pin spoofed rows to
|
||||
// the top of the operator's occurred_at-ordered log. Otherwise the row
|
||||
// defaults to ingest time.
|
||||
if (ev.occurredAt) {
|
||||
const t = Date.parse(ev.occurredAt)
|
||||
if (!Number.isNaN(t) && t <= Date.now() + 60_000) {
|
||||
row.occurred_at = new Date(t).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await admin.from('door_access_log').insert(row)
|
||||
if (error) {
|
||||
// A concurrent ingest already inserted this exact event: not an error.
|
||||
if (!isUniqueViolation(error.message)) {
|
||||
// Anything else (e.g. a transient DB error) is surfaced via console so
|
||||
// the row is not silently lost; the next poll re-attempts it.
|
||||
console.error('[door-ingest] insert failed:', error.message)
|
||||
}
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return { inserted, resolved, skipped }
|
||||
}
|
||||
|
||||
// Poll a single native-HeatSync connection's `?z` log and return parsed events.
|
||||
// Only native_heatsync is parsed (its wire format is characterized); generic
|
||||
// controllers ingest via the webhook. The full ring-buffer body is requested
|
||||
// (fullBody) so the parser sees every slot.
|
||||
export async function pollConnectionLog(
|
||||
admin: Admin,
|
||||
conn: IngestConn,
|
||||
): Promise<{ ok: boolean; events: DoorIngestEvent[]; detail: string }> {
|
||||
const password = await resolveDoorSecret(admin, conn.space_id, conn.secret_ref)
|
||||
|
||||
let query: string
|
||||
if (conn.adapter === 'native_heatsync') {
|
||||
const enc = encodeHeatSyncControl('log', password ?? '')
|
||||
if (!enc.ok) return { ok: false, events: [], detail: `encode failed: ${enc.reason}` }
|
||||
query = enc.query
|
||||
} else {
|
||||
return { ok: false, events: [], detail: 'poll supports native_heatsync only; use the webhook' }
|
||||
}
|
||||
|
||||
const result = await callDoor({
|
||||
url: conn.base_url + query,
|
||||
pinnedHost: conn.pinned_host,
|
||||
password,
|
||||
authParam: conn.auth_param,
|
||||
fullBody: true,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return { ok: false, events: [], detail: redactDoorSecrets(`poll failed: ${result.reason}`, password, conn.auth_param) }
|
||||
}
|
||||
const events = parseHeatSyncLog(result.snippet)
|
||||
return { ok: true, events, detail: `polled ${events.length} event(s)` }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Server-only. Load + decrypt a door connection's secret from the AES-256-GCM
|
||||
// secrets vault. Used for both the OUTBOUND door password (secret_ref) and the
|
||||
// INBOUND webhook secret (inbound_secret_ref). The plaintext is decrypted
|
||||
// server-side only and never returned to the browser or logged. Returns null
|
||||
// when no secret is referenced.
|
||||
|
||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { decryptSecret } from '@/lib/secrets/crypto'
|
||||
|
||||
export async function resolveDoorSecret(
|
||||
admin: ReturnType<typeof createAdminClient>,
|
||||
spaceId: string,
|
||||
secretRef: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!secretRef) return null
|
||||
const { data } = await admin
|
||||
.from('secrets')
|
||||
.select('encryption_version, encrypted_value, value')
|
||||
.eq('id', secretRef)
|
||||
.eq('space_id', spaceId)
|
||||
.maybeSingle()
|
||||
if (!data) return null
|
||||
if (data.encryption_version === 1 && data.encrypted_value) {
|
||||
const raw = data.encrypted_value as unknown
|
||||
const buf =
|
||||
typeof raw === 'string'
|
||||
? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex')
|
||||
: Buffer.from(raw as Uint8Array)
|
||||
try {
|
||||
return decryptSecret(buf, 1)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return (data.value as string | null) ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user