Files
KNELBMS/packages/doorman.yaml
T
mrcharles 66a5c45b7e feat(doorman): unlock dispatch — arm switch + rest_command to doorctl [#345]
Founder-approved: valid badge + armed input_boolean -> rest_command
fires the pfvsvrpi doorctl endpoint (Tailscale-bound, token+source
allowlisted, self-securing relay). Disarm = boolean off = log-only.
URL secret on-box only (doorman_unlock_url).

https://projects.knownelement.com/issues/345
2026-09-03 07:08:06 -05:00

228 lines
10 KiB
YAML

# ============================================================================
# Doorman — server-room badge access (#345/#355/#356)
# ============================================================================
# Ingests badge scans from the KNEL/doorman listeners (pfvsvrpi prod,
# ultix-field dev) and decides known/disabled/unknown against the
# badge-ID control file: KNEL/access-roster (private repo, two-human
# PR gate). The roster is fetched live from the gitea API — NO git
# mechanics, NO submodule (git_pull add-on lacks submodule support),
# NO local copy: access-roster remains the single source of truth.
#
# UNLOCK PATH (founder-approved 2026-09-03): valid badge + the
# doorman_unlock_enabled input_boolean ON -> rest_command fires the
# doorctl endpoint on pfvsvrpi (Tailscale-bound, token+source
# allowlisted, self-securing relay — KNEL/doorman README). Disarm = the
# boolean OFF; unknown/disabled badges never reach this path.
#
# Change control (founder ruling 2026-09-02/03): this file deploys via
# the pfv-bms master pipeline (webhook fast path + sha-watch safety
# net); run `ha core check` before pushing — packages/** triggers a
# core restart. Badge ID changes NEVER happen here — only in
# KNEL/access-roster via approved PRs.
# ============================================================================
rest:
- resource: https://git.knownelement.com/api/v1/repos/KNEL/access-roster/raw/roster/badges.yaml?ref=main
headers:
Authorization: !secret gitea_auth_header
User-Agent: pfv-bms-doorman-roster
scan_interval: 300
verify_ssl: true
sensor:
# Enabled badges only: CSV rows "badge_id|holder" (state limit
# 255 chars — revisit to attribute-based if roster grows past
# ~15 entries).
- name: Doorman roster enabled badges
unique_id: doorman_roster_enabled_badges
value_template: >-
{% set ns = namespace(id='', holder='', out=[]) %}
{% for raw in value.split('\n') %}
{% set line = raw | trim %}
{% if line.startswith('- badge_id:') %}
{% set ns.id = line.split(':', 1)[1].replace('"', '') | trim %}
{% elif line.startswith('holder:') %}
{% set ns.holder = line.split(':', 1)[1].replace('"', '') | trim %}
{% elif line.startswith('enabled: true') %}
{% set ns.out = ns.out + [(ns.id ~ '|' ~ ns.holder)] %}
{% endif %}
{% endfor %}
{{ ns.out | join(',') }}
# All roster badges (enabled or not) — lets the automation tell
# "disabled badge presented" apart from "unknown badge".
- name: Doorman roster all badges
unique_id: doorman_roster_all_badges
value_template: >-
{% set ns = namespace(id='', out=[]) %}
{% for raw in value.split('\n') %}
{% set line = raw | trim %}
{% if line.startswith('- badge_id:') %}
{% set ns.id = line.split(':', 1)[1].replace('"', '') | trim %}
{% set ns.out = ns.out + [ns.id] %}
{% endif %}
{% endfor %}
{{ ns.out | join(',') }}
input_text:
doorman_last_badge:
name: Doorman last badge scanned
max: 255
input_datetime:
doorman_last_scan:
name: Doorman last scan time
has_date: true
has_time: true
input_boolean:
# Arming switch (founder-level): ON = a valid badge DISPATCHES the
# unlock to the pfvsvrpi relay. OFF = valid badges log + notify but
# the door stays shut.
doorman_unlock_enabled:
name: Doorman unlock armed
icon: mdi:lock-open-variant
rest_command:
# Fires the doorctl endpoint on pfvsvrpi (systemd socket-activated,
# Tailscale-bound, token+source allowlisted; self-securing relay).
# URL contains the token -> secret. KNEL/doorman README documents it.
doorman_unlock:
url: !secret doorman_unlock_url
method: get
timeout: 20
automation:
- id: doorman_badge_scan
alias: Doorman - badge scan ingest + decision
description: >-
Server-room badge scans from the KNEL/doorman listeners. Whitelist
= KNEL/access-roster via the doorman roster sensors. Logging +
alerts only — UNLOCK IS DELIBERATELY NOT WIRED (pending the
HA->Pi control path decision, #345/#741). This automation cannot
open the door.
mode: queued
max_exceeded: silent
triggers:
- trigger: webhook
webhook_id: !secret doorman_scan_webhook_id
allowed_methods:
- POST
local_only: false
actions:
- variables:
badge_id: "{{ trigger.json.badge_id | default('') | string }}"
reader: "{{ trigger.json.reader | default('unknown') }}"
scanned: "{{ trigger.json.ts | default(now().isoformat()) }}"
enabled_csv: "{{ states('sensor.doorman_roster_enabled_badges') }}"
all_csv: "{{ states('sensor.doorman_roster_all_badges') }}"
roster_ok: "{{ '|' in enabled_csv and ',' in (enabled_csv ~ ',') }}"
known_ids: >-
{{ all_csv.split(',') | map('trim') | select('match', '^\d+$') | list }}
enabled_ids: >-
{% set ns = namespace(ids=[]) %}
{% for row in enabled_csv.split(',') %}
{% set parts = row.split('|') %}
{% if parts | length == 2 %}
{% set ns.ids = ns.ids + [parts[0] | trim] %}
{% endif %}
{% endfor %}
{{ ns.ids }}
holder: >-
{% set hits = enabled_csv.split(',') | map('trim')
| select('search', '^' ~ badge_id ~ '\|') | list %}
{{ hits[0].split('|')[1] | trim if hits else 'UNKNOWN' }}
is_known: "{{ badge_id in known_ids }}"
is_enabled: "{{ badge_id in enabled_ids }}"
- action: input_text.set_value
target:
entity_id: input_text.doorman_last_badge
data:
value: "{{ badge_id }} at {{ reader }} ({{ 'enabled' if is_enabled else ('disabled' if is_known else 'UNKNOWN') }})"
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.doorman_last_scan
data:
datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"
- choose:
# Roster unavailable (gitea fetch failed): log, notify once,
# decide NOTHING. Fail-safe: no unlock path exists anyway.
- conditions: "{{ not roster_ok }}"
sequence:
- action: persistent_notification.create
data:
title: Doorman — roster unavailable
message: >-
Scan {{ badge_id }} at {{ reader }} could not be
checked: roster sensors are not populated. Logged
only; whitelist NOT evaluated. Check gitea
connectivity / KNEL/access-roster.
# Known + enabled badge: log, then dispatch unlock IF armed
# (founder-level input_boolean). Disarmed = log + notice only.
- conditions: "{{ is_known and is_enabled }}"
sequence:
- action: logbook.log
data:
name: Doorman
message: >-
Badge {{ badge_id }} ({{ holder }}) accepted at
{{ reader }}.
- if:
- condition: state
entity_id: input_boolean.doorman_unlock_enabled
state: 'on'
then:
- action: rest_command.doorman_unlock
continue_on_error: true
- action: logbook.log
data:
name: Doorman
message: "UNLOCK dispatched to the {{ reader }} relay (armed)."
- action: notify.persistent_notification
data:
title: Doorman — badge accepted, UNLOCK dispatched
message: "{{ holder }} scanned at {{ reader }} ({{ scanned }}). Door released for the hold window."
else:
- action: persistent_notification.create
data:
title: Doorman — badge accepted, unlock NOT armed
message: "{{ holder }} scanned at {{ reader }} ({{ scanned }}). Unlock is disarmed — door stayed shut."
# In roster but disabled: flag it loudly (stolen/suspended
# badge use is exactly what we want to know about).
- conditions: "{{ is_known and not is_enabled }}"
sequence:
- action: logbook.log
data:
name: Doorman
message: "DISABLED badge {{ badge_id }} presented at {{ reader }} — refused."
- action: notify.send_message
target:
entity_id:
- notify.ultix_sidecar_new
- notify.allthes_ipad
data:
title: Doorman — DISABLED badge at server room
message: "Badge {{ badge_id }} is in the roster but disabled. Presented at {{ reader }} ({{ scanned }})."
# Unknown badge: instant alert (alert brain pattern).
- conditions: "{{ not is_known }}"
sequence:
- action: logbook.log
data:
name: Doorman
message: "UNKNOWN badge {{ badge_id }} at {{ reader }}."
- action: notify.send_message
target:
entity_id:
- notify.ultix_sidecar_new
- notify.allthes_ipad
data:
title: Doorman — UNKNOWN badge at server room
message: >-
Badge {{ badge_id }} is NOT in KNEL/access-roster.
Presented at {{ reader }} ({{ scanned }}). If this
was you, add it via an access-roster PR (two-human
gate); otherwise treat as recon.
default:
- action: logbook.log
data:
name: Doorman
message: "Scan {{ badge_id }} at {{ reader }} hit no decision branch."