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:
@@ -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’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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user