feat(door): flexible transport + clearer controller/generic API docs

The app is cloud-hosted, so a connection's target is not assumed to be
LAN-only: any pinned host (public controller/proxy or VPN-reachable LAN
device) is allowed except cloud-metadata/link-local. KNOWN_DOOR_
CONTROLLERS surfaces the controller type with source links (HeatSync/23b
native -> the heatsynclabs + zyphlar repos; otherwise generic HTTP).
The generic adapter form now explains the request model and every
{slot}/{tag}/{perm}/{door}/{pw} placeholder. Copy and the migration
comment corrected away from "LAN-only".
This commit is contained in:
Moheeb Zara
2026-05-16 20:38:52 -07:00
parent ccd0c07ee7
commit 1db93167db
3 changed files with 101 additions and 16 deletions
+59 -14
View File
@@ -14,6 +14,7 @@ import {
deleteDoorConnection, deleteDoorConnection,
testDoorConnection, testDoorConnection,
} from '@/lib/actions' } from '@/lib/actions'
import { KNOWN_DOOR_CONTROLLERS, controllerForAdapter } from '@/lib/door-logic'
type Conn = { type Conn = {
id: string id: string
@@ -155,9 +156,12 @@ export function DoorManageClient({
<div className="p-4 md:p-6 space-y-4"> <div className="p-4 md:p-6 space-y-4">
<p className="font-sans text-sm text-muted-foreground max-w-2xl"> <p className="font-sans text-sm text-muted-foreground max-w-2xl">
A connection targets a door controller on your LAN. The shared password is read A connection targets a door controller. Because this app is cloud-hosted, the
from your encrypted Secrets vault, never stored here. The server only ever calls target can be a publicly reachable controller or proxy, or a VPN-reachable
the exact pinned host, never follows redirects, and records every action in the device on your LAN. The shared password is read from your encrypted Secrets
vault and never stored here. The server only ever calls the exact pinned host
(any public or private host you set, except cloud-metadata/link-local), never
follows redirects, caps time and response size, and records every action in the
access log with secrets redacted. access log with secrets redacted.
</p> </p>
@@ -167,8 +171,9 @@ export function DoorManageClient({
value={d.name} onChange={e => setD({ ...d, name: e.target.value })} /> value={d.name} onChange={e => setD({ ...d, name: e.target.value })} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select className={input} value={d.adapter} onChange={e => setD({ ...d, adapter: e.target.value })}> <select className={input} value={d.adapter} onChange={e => setD({ ...d, adapter: e.target.value })}>
<option value="native_heatsync">Native HeatSync controller</option> {KNOWN_DOOR_CONTROLLERS.map(c => (
<option value="generic_http">Generic HTTP</option> <option key={c.id} value={c.adapter}>{c.label}</option>
))}
</select> </select>
<select className={input} value={d.secret_ref} onChange={e => setD({ ...d, secret_ref: e.target.value })}> <select className={input} value={d.secret_ref} onChange={e => setD({ ...d, secret_ref: e.target.value })}>
<option value="">No password (auth: none)</option> <option value="">No password (auth: none)</option>
@@ -179,16 +184,56 @@ export function DoorManageClient({
<input className={input} placeholder="Pinned host (192.168.1.50)" <input className={input} placeholder="Pinned host (192.168.1.50)"
value={d.pinned_host} onChange={e => setD({ ...d, pinned_host: e.target.value })} /> value={d.pinned_host} onChange={e => setD({ ...d, pinned_host: e.target.value })} />
</div> </div>
{(() => {
const k = controllerForAdapter(d.adapter)
if (!k) return null
return (
<div className="rounded border border-border bg-background p-3 text-xs text-muted-foreground space-y-1">
<p className="font-sans">{k.note}</p>
{k.repos.length > 0 && (
<p className="font-mono text-[10px]">
Source:{' '}
{k.repos.map((r, i) => (
<span key={r.url}>
{i > 0 && ' · '}
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">{r.label}</a>
</span>
))}
</p>
)}
</div>
)
})()}
{d.adapter === 'generic_http' && ( {d.adapter === 'generic_http' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="rounded border border-border bg-background p-3 space-y-3">
<input className={input} placeholder="grant template (?m{slot}&t{tag}&e={pw})" <div className="text-xs text-muted-foreground space-y-1">
value={d.grant} onChange={e => setD({ ...d, grant: e.target.value })} /> <p className="font-sans">
<input className={input} placeholder="revoke template (?r{slot}&e={pw})" Per-verb request templates. The template is appended to the base URL and
value={d.revoke} onChange={e => setD({ ...d, revoke: e.target.value })} /> sent as an HTTP <code className="font-mono">GET</code> to the pinned host
<input className={input} placeholder="open template (?o1&e={pw})" (no redirects followed). Placeholders are URL-encoded and substituted:
value={d.open} onChange={e => setD({ ...d, open: e.target.value })} /> </p>
<input className={input} placeholder="status template (?9&e={pw})" <ul className="font-mono text-[10px] list-disc list-inside">
value={d.status} onChange={e => setD({ ...d, status: e.target.value })} /> <li><code>{'{slot}'}</code> — the member&rsquo;s integer card slot</li>
<li><code>{'{tag}'}</code> — the card UID / tag value</li>
<li><code>{'{perm}'}</code> — permission level (default 1)</li>
<li><code>{'{door}'}</code> — door identifier (for open/lock verbs)</li>
<li><code>{'{pw}'}</code> — the shared password from your Secrets vault (server-side only; never shown or logged)</li>
</ul>
<p className="font-sans">
Leave a verb blank if your controller does not support it. Example
(HeatSync-style): <code className="font-mono">?m{'{slot}'}&p001&t{'{tag}'}&e={'{pw}'}</code>
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className={input} placeholder="grant template — e.g. ?m{slot}&t{tag}&e={pw}"
value={d.grant} onChange={e => setD({ ...d, grant: e.target.value })} />
<input className={input} placeholder="revoke template — e.g. ?r{slot}&e={pw}"
value={d.revoke} onChange={e => setD({ ...d, revoke: e.target.value })} />
<input className={input} placeholder="open template — e.g. ?o{door}&e={pw}"
value={d.open} onChange={e => setD({ ...d, open: e.target.value })} />
<input className={input} placeholder="status template — e.g. ?9&e={pw}"
value={d.status} onChange={e => setD({ ...d, status: e.target.value })} />
</div>
</div> </div>
)} )}
<label className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground"> <label className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
+38
View File
@@ -13,6 +13,44 @@ export function maskCardUid(uid: string): string {
return '•'.repeat(uid.length - 4) + uid.slice(-4) return '•'.repeat(uid.length - 4) + uid.slice(-4)
} }
// ─── Known door controllers (shown in the UI, with source links) ────────────
// The app is cloud-hosted, so a connection's target may be a publicly
// reachable controller/proxy OR a VPN-reachable LAN device. The adapter is
// not assumed to be on a private network; the SSRF guard pins the exact host
// (public or private) and always blocks metadata/link-local + redirects.
export type KnownController = {
id: string
label: string
adapter: 'native_heatsync' | 'generic_http'
repos: { label: string; url: string }[]
note: string
}
export const KNOWN_DOOR_CONTROLLERS: KnownController[] = [
{
id: 'heatsync',
label: 'HeatSync / 23b Open Source Access Control (native)',
adapter: 'native_heatsync',
repos: [
{ label: 'heatsynclabs/Open-Source-Access-Control-Web-Interface', url: 'https://github.com/heatsynclabs/Open-Source-Access-Control-Web-Interface' },
{ label: 'zyphlar/Open_Access_Control_Ethernet (23b lineage)', url: 'https://github.com/zyphlar/Open_Access_Control_Ethernet' },
],
note: 'Arduino HTTP query-string controller. Built-in verb encoding (fixed-width, password as a query param). Point base_url at the controller or a proxy that reaches it.',
},
{
id: 'generic',
label: 'Other / generic HTTP controller',
adapter: 'generic_http',
repos: [],
note: 'Any controller you can drive over HTTP. Configure a per-verb URL/query template with {slot}, {tag}, {perm}, {door}, {pw} placeholders.',
},
]
export function controllerForAdapter(adapter: string): KnownController | undefined {
return KNOWN_DOOR_CONTROLLERS.find(c => c.adapter === adapter)
}
// ─── SSRF guard (the dangerous part) ───────────────────────────────────────── // ─── SSRF guard (the dangerous part) ─────────────────────────────────────────
// A door connection deliberately targets a LAN device, so private ranges are // A door connection deliberately targets a LAN device, so private ranges are
// allowed — but ONLY the exact host the admin pinned, and never the cloud // allowed — but ONLY the exact host the admin pinned, and never the cloud
+4 -2
View File
@@ -8,8 +8,10 @@
-- row in the existing AES-256-GCM `secrets` vault; the executor loads and -- row in the existing AES-256-GCM `secrets` vault; the executor loads and
-- decrypts it server-side only and never returns/logs it. -- decrypts it server-side only and never returns/logs it.
-- * pinned_host is the SSRF pin: the executor will only ever call that exact -- * pinned_host is the SSRF pin: the executor will only ever call that exact
-- host (no redirects, size/time caps). A door is LAN-only, so private -- host (no redirects, size/time caps). The app is cloud-hosted, so the
-- ranges are allowed -- but only the pinned host, never metadata. -- target may be a public controller/proxy OR a VPN-reachable LAN device;
-- any pinned host is allowed (public or private) EXCEPT cloud-metadata /
-- link-local, which are always blocked.
-- * adapter is 'native_heatsync' (verified query-string firmware) or -- * adapter is 'native_heatsync' (verified query-string firmware) or
-- 'generic_http' (admin-supplied per-verb templates). -- 'generic_http' (admin-supplied per-verb templates).
-- * allow_member_self_entry opts a connection into the member "buzz me in" -- * allow_member_self_entry opts a connection into the member "buzz me in"