diff --git a/apps/fc-signage-pi-player/README.md b/apps/fc-signage-pi-player/README.md new file mode 100644 index 0000000..4191f4f --- /dev/null +++ b/apps/fc-signage-pi-player/README.md @@ -0,0 +1,17 @@ +# FlowerCore Signage Pi Player + +Phase 1 Raspberry Pi signage player packaging for Chromium kiosk deployments. +This bundle is intentionally air-gap friendly: systemd units, shell scripts, +udev rules, and Chromium managed policy are all checked into the repo and are +installed by `FlowerCore.Puppet`. + +## Scope + +- Bootstrap a stable node identity and mTLS client certificate. +- Launch Chromium in kiosk mode against `FC.Signage.Web` player routes. +- Restart the kiosk on HDMI hotplug. +- Renew mTLS certificates daily when fewer than 30 days remain. +- Detect display capabilities at boot, daily, and on HDMI hotplug. + +Phase 2 native Avalonia rendering is documented separately in Notes and remains +deferred. diff --git a/apps/fc-signage-pi-player/chromium-policies/flowercore-signage.json b/apps/fc-signage-pi-player/chromium-policies/flowercore-signage.json new file mode 100644 index 0000000..2f305fd --- /dev/null +++ b/apps/fc-signage-pi-player/chromium-policies/flowercore-signage.json @@ -0,0 +1,15 @@ +{ + "AutofillAddressEnabled": false, + "AutofillCreditCardEnabled": false, + "PasswordManagerEnabled": false, + "BrowserSignin": 0, + "MetricsReportingEnabled": false, + "SafeBrowsingProtectionLevel": 0, + "DefaultNotificationsSetting": 2, + "DefaultPopupsSetting": 2, + "BackgroundModeEnabled": false, + "DefaultBrowserSettingEnabled": false, + "PromotionalTabsEnabled": false, + "CommandLineFlagSecurityWarningsEnabled": false, + "ExtensionInstallBlocklist": ["*"] +} diff --git a/apps/fc-signage-pi-player/scripts/fc-signage-detect-display b/apps/fc-signage-pi-player/scripts/fc-signage-detect-display new file mode 100644 index 0000000..aaaaae2 --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/fc-signage-detect-display @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +NODE_JSON="/etc/flowercore/signage-node.json" +CERT_DIR="/etc/fc-signage-player" +SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}" +NODE_ID=$(jq -r '.nodeId' "$NODE_JSON") + +CONNECTORS=() +for dir in /sys/class/drm/card*-HDMI-A-*; do + [[ -e "$dir/status" ]] || continue + if [[ "$(cat "$dir/status")" == "connected" ]]; then + CONNECTORS+=("$(basename "$dir")") + fi +done + +if [[ ${#CONNECTORS[@]} -eq 0 ]]; then + CAPABILITIES_JSON=$(jq -n --arg id "$NODE_ID" '{ + nodeId: $id, + platform: "linux-arm64-pi", + displayConnected: false, + detectedAt: (now | todate), + note: "No HDMI display detected" + }') +else + PRIMARY="${CONNECTORS[0]}" + EDID_PATH="/sys/class/drm/${PRIMARY}/edid" + WIDTH=0 + HEIGHT=0 + REFRESH=60 + HDR=false + AUDIO_HDMI=false + MFG="" + MODEL="" + PHYSICAL_SIZE=null + + if [[ -s "$EDID_PATH" ]] && command -v edid-decode >/dev/null 2>&1; then + EDID_INFO=$(edid-decode < "$EDID_PATH" 2>/dev/null || true) + MFG=$(echo "$EDID_INFO" | grep -m1 -oP 'Manufacturer:\s*\K\S+' || true) + MODEL=$(echo "$EDID_INFO" | grep -m1 -oP 'Model:\s*\K\S+' || true) + PREF=$(echo "$EDID_INFO" | grep -m1 -oP '\d+x\d+\s*@\s*\d+(?:\.\d+)?\s*Hz' || true) + if [[ -n "$PREF" ]]; then + WIDTH=$(echo "$PREF" | grep -oP '^\d+') + HEIGHT=$(echo "$PREF" | grep -oP 'x\K\d+') + REFRESH=$(echo "$PREF" | grep -oP '@\s*\K[\d.]+' | cut -d. -f1) + fi + if echo "$EDID_INFO" | grep -qiE 'HDR (Static|Dynamic) Metadata Block'; then HDR=true; fi + if echo "$EDID_INFO" | grep -qiE 'CEA Audio Block|Audio Format Descriptor'; then AUDIO_HDMI=true; fi + PH_W=$(echo "$EDID_INFO" | grep -m1 -oP 'Maximum image size:\s*\K\d+\s*cm\s*x\s*\d+' || true) + if [[ -n "$PH_W" ]]; then + PH_CM_W=$(echo "$PH_W" | grep -oP '^\d+') + PH_CM_H=$(echo "$PH_W" | grep -oP 'x\s*\K\d+') + if (( PH_CM_W > 0 && PH_CM_H > 0 )); then + PHYSICAL_SIZE=$(awk -v w="$PH_CM_W" -v h="$PH_CM_H" 'BEGIN { printf "%.1f", sqrt(w*w + h*h)/2.54 }') + fi + fi + fi + + if [[ "$WIDTH" == "0" ]] && command -v kmsprint >/dev/null 2>&1; then + KMS=$(kmsprint 2>/dev/null | grep -A2 "$PRIMARY" | grep -oP '\d+x\d+' | head -1 || true) + if [[ -n "$KMS" ]]; then + WIDTH=$(echo "$KMS" | grep -oP '^\d+') + HEIGHT=$(echo "$KMS" | grep -oP 'x\K\d+') + fi + fi + + AUDIO_ALSA=false + if aplay -l 2>/dev/null | grep -qi 'card.*HDMI'; then AUDIO_ALSA=true; fi + HAS_AUDIO=false + if [[ "$AUDIO_HDMI" == "true" && "$AUDIO_ALSA" == "true" ]]; then HAS_AUDIO=true; fi + + CAPABILITIES_JSON=$(jq -n \ + --arg id "$NODE_ID" \ + --argjson w "$WIDTH" \ + --argjson h "$HEIGHT" \ + --argjson r "$REFRESH" \ + --argjson hdr "$HDR" \ + --argjson audio "$HAS_AUDIO" \ + --arg connector "$PRIMARY" \ + --arg mfg "$MFG" \ + --arg model "$MODEL" \ + --argjson size "$PHYSICAL_SIZE" \ + '{ + nodeId: $id, + platform: "linux-arm64-pi", + displayConnected: true, + detectedAt: (now | todate), + hardware: { + maxResolution: { width: $w, height: $h }, + nativeResolution: { width: $w, height: $h }, + refreshRateHz: $r, + colorDepth: ($hdr | if . then "Color30Hdr" else "Color24" end), + hasAudioOutput: $audio, + audioChannelCount: ($audio | if . then 2 else 0 end), + physicalSizeInches: $size, + connector: $connector, + manufacturer: $mfg, + modelName: $model + }, + render: { codecs: ["h264", "vp9", "mp4"] } + }') +fi + +ENDPOINT_CANDIDATES=( + "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/capabilities" + "${SIGNAGE_URL}/api/v1/displays/${NODE_ID}/capability-profile" +) + +SUCCESS=false +for url in "${ENDPOINT_CANDIDATES[@]}"; do + HTTP_STATUS=$(curl -sk -o /tmp/cap-response.json -w "%{http_code}" \ + --max-time 10 \ + --cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \ + -X POST "$url" \ + -H "Content-Type: application/json" \ + -d "$CAPABILITIES_JSON" || echo "000") + if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" || "$HTTP_STATUS" == "204" ]]; then + SUCCESS=true + break + fi +done + +mkdir -p /var/log/fc-signage-player +if [[ "$SUCCESS" != "true" ]]; then + echo "[$(date -Is)] capability declare: no endpoint accepted the profile; logging locally" \ + | tee -a /var/log/fc-signage-player/capabilities.log + echo "$CAPABILITIES_JSON" | tee -a /var/log/fc-signage-player/capabilities.log +else + echo "[$(date -Is)] capability declare: ok ($url)" | tee -a /var/log/fc-signage-player/capabilities.log +fi + +echo "$CAPABILITIES_JSON" diff --git a/apps/fc-signage-pi-player/scripts/flowercore-signage-bootstrap.sh b/apps/fc-signage-pi-player/scripts/flowercore-signage-bootstrap.sh new file mode 100644 index 0000000..1995773 --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/flowercore-signage-bootstrap.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +NODE_JSON="/etc/flowercore/signage-node.json" +CERT_DIR="/etc/fc-signage-player" +SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}" +SETUP_CODE_FILE="/etc/flowercore/signage-setup-code" + +mkdir -p /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player +chown fc-signage:fc-signage /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player +chmod 0750 "$CERT_DIR" + +if [[ -s "$NODE_JSON" && -s "$CERT_DIR/client.p12" ]]; then + ENROLLED=$(jq -r '.enrolledAt // empty' "$NODE_JSON") + if [[ -n "$ENROLLED" ]]; then + echo "[$(date -Is)] bootstrap: already enrolled at $ENROLLED; skipping" + exit 0 + fi +fi + +if [[ -s "$NODE_JSON" ]]; then + NODE_UUID=$(jq -r '.nodeUuid // empty' "$NODE_JSON") + MACHINE_ID=$(jq -r '.machineId // empty' "$NODE_JSON") +else + NODE_UUID=$(uuidgen) + MACHINE_ID=$(echo "$NODE_UUID" | tr -d '-' | cut -c1-16) + jq -n --arg uuid "$NODE_UUID" --arg machine "$MACHINE_ID" --arg host "$(hostname -f)" --arg ts "$(date -Is)" \ + '{nodeUuid: $uuid, machineId: $machine, hostname: $host, platform: "linux-arm64-pi", createdAt: $ts}' \ + > "$NODE_JSON" + chmod 0640 "$NODE_JSON" + chown fc-signage:fc-signage "$NODE_JSON" +fi + +SETUP_CODE="" +if [[ -s "$SETUP_CODE_FILE" ]]; then + SETUP_CODE=$(tr -d '\r\n\t ' < "$SETUP_CODE_FILE") +fi + +MODEL=$(tr -d '\0' < /sys/firmware/devicetree/base/model 2>/dev/null || echo Unknown) +REG_PAYLOAD=$(jq -n \ + --arg machine "$MACHINE_ID" \ + --arg name "$(hostname -f)" \ + --arg setup "$SETUP_CODE" \ + --arg resolution "1920x1080" \ + --arg model "$MODEL" \ + '{ + machineId: $machine, + name: $name, + setupCode: ($setup | if . == "" then null else . end), + resolution: $resolution, + hardwareModel: $model, + platform: "linux-arm64-pi" + }') + +for attempt in 1 2; do + HTTP_STATUS=$(curl -sk -o /tmp/register-response.json -w "%{http_code}" \ + --max-time 15 \ + -X POST "${SIGNAGE_URL}/api/v1/nodes/register" \ + -H "Content-Type: application/json" \ + -d "$REG_PAYLOAD" || echo "000") + if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" ]]; then + break + fi + echo "[$(date -Is)] bootstrap: register attempt $attempt returned $HTTP_STATUS" >&2 + sleep 5 +done + +if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then + echo "[$(date -Is)] bootstrap: register failed after 2 attempts" >&2 + exit 2 +fi + +NODE_ID=$(jq -r '.nodeId // empty' /tmp/register-response.json) +if [[ -z "$NODE_ID" ]]; then + echo "[$(date -Is)] bootstrap: register response did not include nodeId" >&2 + exit 2 +fi +jq --arg id "$NODE_ID" '.nodeId = $id' "$NODE_JSON" > "${NODE_JSON}.tmp" && mv "${NODE_JSON}.tmp" "$NODE_JSON" + +if [[ -s "$SETUP_CODE_FILE" ]]; then + curl -sk -X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/approve-via-setup-code" \ + -H "Content-Type: application/json" \ + -d "{\"setupCode\":\"${SETUP_CODE}\"}" \ + -o /dev/null || true +fi + +STATUS="" +DEADLINE=$(( $(date +%s) + 1800 )) +while (( $(date +%s) < DEADLINE )); do + STATUS=$(curl -sk --max-time 5 "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/status" | jq -r '.status // empty') + if [[ "$STATUS" == "Approved" || "$STATUS" == "Enrolled" || "$STATUS" == "Online" ]]; then + break + fi + sleep 15 +done + +if [[ "$STATUS" != "Approved" && "$STATUS" != "Enrolled" && "$STATUS" != "Online" ]]; then + echo "[$(date -Is)] bootstrap: approval not granted within 30min budget" >&2 + exit 3 +fi + +KEY_PATH="${CERT_DIR}/client.key" +CSR_PATH="${CERT_DIR}/client.csr" +openssl ecparam -genkey -name prime256v1 -out "$KEY_PATH" +openssl req -new -key "$KEY_PATH" -out "$CSR_PATH" \ + -subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi" + +ENROLL_PAYLOAD=$(jq -n --arg csr "$(cat "$CSR_PATH")" '{certificateSigningRequest: $csr}') +HTTP_STATUS=$(curl -sk -o /tmp/enroll-response.json -w "%{http_code}" \ + --max-time 15 \ + -X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/enroll" \ + -H "Content-Type: application/json" \ + -d "$ENROLL_PAYLOAD") + +if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then + echo "[$(date -Is)] bootstrap: enroll failed with HTTP $HTTP_STATUS" >&2 + exit 4 +fi + +jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/client.crt" +jq -r '.caCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/ca-chain.pem" +P12_PASS=$(openssl rand -hex 24) +echo -n "$P12_PASS" > "${CERT_DIR}/client.p12.pass" +chmod 0600 "${CERT_DIR}/client.p12.pass" + +openssl pkcs12 -export \ + -inkey "$KEY_PATH" \ + -in "${CERT_DIR}/client.crt" \ + -certfile "${CERT_DIR}/ca-chain.pem" \ + -out "${CERT_DIR}/client.p12" \ + -password "pass:${P12_PASS}" + +chown fc-signage:fc-signage "${CERT_DIR}"/* "$NODE_JSON" +chmod 0640 "${CERT_DIR}/client.p12" "${CERT_DIR}/client.crt" "${CERT_DIR}/ca-chain.pem" "$KEY_PATH" +chmod 0600 "${CERT_DIR}/client.p12.pass" + +EXPIRY=$(openssl x509 -in "${CERT_DIR}/client.crt" -enddate -noout | sed 's/notAfter=//') +jq --arg ts "$(date -Is)" --arg exp "$EXPIRY" \ + '.enrolledAt = $ts | .certExpiry = $exp' "$NODE_JSON" > "${NODE_JSON}.tmp" \ + && mv "${NODE_JSON}.tmp" "$NODE_JSON" + +systemctl start flowercore-signage-detect-display.service || true +systemctl start flowercore-signage-player-pi.service || true +echo "[$(date -Is)] bootstrap: enrolled and kiosk started (NodeId=${NODE_ID})" diff --git a/apps/fc-signage-pi-player/scripts/flowercore-signage-hdmi-respond.sh b/apps/fc-signage-pi-player/scripts/flowercore-signage-hdmi-respond.sh new file mode 100644 index 0000000..ddd9c60 --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/flowercore-signage-hdmi-respond.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +sleep 2 +systemctl start flowercore-signage-detect-display.service || true +systemctl restart flowercore-signage-player-pi.service diff --git a/apps/fc-signage-pi-player/scripts/flowercore-signage-launch.sh b/apps/fc-signage-pi-player/scripts/flowercore-signage-launch.sh new file mode 100644 index 0000000..f5892c9 --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/flowercore-signage-launch.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +NODE_JSON="/etc/flowercore/signage-node.json" +NODE_ID=$(jq -r '.nodeId' "$NODE_JSON") +SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}" +CERT_DIR="/etc/fc-signage-player" + +CERT_THUMB=$(openssl pkcs12 -in "$CERT_DIR/client.p12" -passin file:"$CERT_DIR/client.p12.pass" -nodes -nokeys 2>/dev/null \ + | openssl x509 -fingerprint -sha256 -noout \ + | sed 's/.*=//' \ + | tr -d ':') + +PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}/embed?token=${CERT_THUMB}" +HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 \ + --cert-type P12 --cert "$CERT_DIR/client.p12:$(cat "$CERT_DIR/client.p12.pass")" \ + "$PLAYER_URL" || echo "000") + +mkdir -p /var/log/fc-signage-player +if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "301" && "$HTTP_STATUS" != "302" ]]; then + echo "[$(date -Is)] /embed returned $HTTP_STATUS; falling back to /player/${NODE_ID}" \ + >> /var/log/fc-signage-player/url-divergence.log + PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}?token=${CERT_THUMB}" +fi + +exec chromium-browser \ + --kiosk \ + --noerrdialogs \ + --disable-infobars \ + --disable-translate \ + --disable-features=TranslateUI,InfiniteSessionRestore \ + --autoplay-policy=no-user-gesture-required \ + --password-store=basic \ + --user-data-dir=/var/lib/fc-signage-player/profile \ + --disk-cache-dir=/var/lib/fc-signage-player/cache \ + --disk-cache-size=104857600 \ + --no-first-run \ + --no-default-browser-check \ + --check-for-update-interval=2592000 \ + --enable-features=OverlayScrollbar \ + --start-fullscreen \ + --window-position=0,0 \ + --window-size=1920,1080 \ + "$PLAYER_URL" diff --git a/apps/fc-signage-pi-player/scripts/flowercore-signage-prelaunch.sh b/apps/fc-signage-pi-player/scripts/flowercore-signage-prelaunch.sh new file mode 100644 index 0000000..46c6c6a --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/flowercore-signage-prelaunch.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /var/log/fc-signage-player + +for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass; do + if [[ ! -r "$f" ]]; then + echo "[$(date -Is)] prelaunch: missing or unreadable $f" >&2 + exit 1 + fi +done + +if openssl pkcs12 -in /etc/fc-signage-player/client.p12 -passin file:/etc/fc-signage-player/client.p12.pass -nokeys -clcerts 2>/dev/null \ + | openssl x509 -checkend $((7*24*3600)) -noout; then + : +else + echo "[$(date -Is)] prelaunch: client cert expires within 7 days" >&2 +fi + +echo "[$(date -Is)] prelaunch: ok" | tee -a /var/log/fc-signage-player/prelaunch.log diff --git a/apps/fc-signage-pi-player/scripts/flowercore-signage-renew-cert.sh b/apps/fc-signage-pi-player/scripts/flowercore-signage-renew-cert.sh new file mode 100644 index 0000000..1673c9d --- /dev/null +++ b/apps/fc-signage-pi-player/scripts/flowercore-signage-renew-cert.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="/etc/fc-signage-player" +NODE_JSON="/etc/flowercore/signage-node.json" +SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}" + +[[ -s "$CERT_DIR/client.crt" ]] || { echo "no cert to renew"; exit 0; } + +if openssl x509 -in "$CERT_DIR/client.crt" -checkend $((30*24*3600)) -noout; then + exit 0 +fi + +NODE_ID=$(jq -r '.nodeId' "$NODE_JSON") +NEW_KEY="$CERT_DIR/client.key.new" +NEW_CSR="$CERT_DIR/client.csr.new" + +openssl ecparam -genkey -name prime256v1 -out "$NEW_KEY" +openssl req -new -key "$NEW_KEY" -out "$NEW_CSR" \ + -subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi" + +HTTP_STATUS=$(curl -sk -o /tmp/renew-response.json -w "%{http_code}" \ + --cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \ + -X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/renew" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg csr "$(cat "$NEW_CSR")" '{certificateSigningRequest: $csr}')") + +if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then + echo "[$(date -Is)] renew: failed HTTP $HTTP_STATUS; leaving old cert in place" >&2 + exit 5 +fi + +jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/renew-response.json > "$CERT_DIR/client.crt.new" +jq -r '.caCertificatePem' /tmp/renew-response.json > "$CERT_DIR/ca-chain.pem.new" +P12_PASS=$(cat "$CERT_DIR/client.p12.pass") +openssl pkcs12 -export -inkey "$NEW_KEY" -in "$CERT_DIR/client.crt.new" \ + -certfile "$CERT_DIR/ca-chain.pem.new" \ + -out "$CERT_DIR/client.p12.new" -password "pass:${P12_PASS}" + +mv "$CERT_DIR/client.key.new" "$CERT_DIR/client.key" +mv "$CERT_DIR/client.crt.new" "$CERT_DIR/client.crt" +mv "$CERT_DIR/ca-chain.pem.new" "$CERT_DIR/ca-chain.pem" +mv "$CERT_DIR/client.p12.new" "$CERT_DIR/client.p12" + +chown fc-signage:fc-signage "$CERT_DIR"/client.* +systemctl restart flowercore-signage-player-pi.service diff --git a/apps/fc-signage-pi-player/systemd/99-flowercore-signage-hdmi.rules b/apps/fc-signage-pi-player/systemd/99-flowercore-signage-hdmi.rules new file mode 100644 index 0000000..e227d62 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/99-flowercore-signage-hdmi.rules @@ -0,0 +1,3 @@ +# Restart kiosk and redeclare capabilities when HDMI connect/disconnect changes DRM state. +SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl restart flowercore-signage-player-pi.service" +SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-signage-detect-display.service" diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-bootstrap.service b/apps/fc-signage-pi-player/systemd/flowercore-signage-bootstrap.service new file mode 100644 index 0000000..001680a --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-bootstrap.service @@ -0,0 +1,16 @@ +[Unit] +Description=FlowerCore Signage Pi: first-boot identity + mTLS enrollment +Wants=network-online.target +After=network-online.target +Before=flowercore-signage-player-pi.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/flowercore-signage-bootstrap.sh +RemainAfterExit=yes +StandardOutput=journal +StandardError=journal +TimeoutStartSec=2100 + +[Install] +WantedBy=multi-user.target diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.service b/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.service new file mode 100644 index 0000000..fc84893 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.service @@ -0,0 +1,8 @@ +[Unit] +Description=FlowerCore Signage Pi: detect connected display + declare capabilities +After=flowercore-signage-bootstrap.service + +[Service] +Type=oneshot +User=fc-signage +ExecStart=/usr/local/bin/fc-signage-detect-display diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.timer b/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.timer new file mode 100644 index 0000000..9b0ee43 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-detect-display.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily FlowerCore Signage Pi display capability redeclaration + +[Timer] +OnCalendar=daily +RandomizedDelaySec=1h +Persistent=true +OnBootSec=30s + +[Install] +WantedBy=timers.target diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi-hdmi.service b/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi-hdmi.service new file mode 100644 index 0000000..7915525 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi-hdmi.service @@ -0,0 +1,7 @@ +[Unit] +Description=FlowerCore Signage Pi Player HDMI hotplug responder +DefaultDependencies=no + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/flowercore-signage-hdmi-respond.sh diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi.service b/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi.service new file mode 100644 index 0000000..eaca788 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-player-pi.service @@ -0,0 +1,30 @@ +[Unit] +Description=FlowerCore Digital Signage Pi Player (Chromium kiosk) +Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/appletv-pi-signage-agents-design.md +Wants=network-online.target +After=network-online.target graphical.target +ConditionPathExists=/etc/flowercore/signage-node.json +ConditionPathExists=/etc/fc-signage-player/client.p12 + +[Service] +Type=simple +User=fc-signage +Group=fc-signage +WorkingDirectory=/var/lib/fc-signage-player +EnvironmentFile=-/etc/flowercore/signage-player.env +ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh +ExecStart=/usr/local/bin/flowercore-signage-launch.sh +Restart=always +RestartSec=10s +StartLimitBurst=5 +StartLimitIntervalSec=300s +MemoryMax=2G +MemoryHigh=1500M +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/fc-signage-player /var/log/fc-signage-player +PrivateTmp=true +NoNewPrivileges=true + +[Install] +WantedBy=graphical.target diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.service b/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.service new file mode 100644 index 0000000..d8f0f08 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.service @@ -0,0 +1,6 @@ +[Unit] +Description=FlowerCore Signage Pi: cert renewal worker + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/flowercore-signage-renew-cert.sh diff --git a/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.timer b/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.timer new file mode 100644 index 0000000..2365944 --- /dev/null +++ b/apps/fc-signage-pi-player/systemd/flowercore-signage-renew.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily check for FlowerCore Signage Pi cert renewal + +[Timer] +OnCalendar=daily +RandomizedDelaySec=2h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tests/bluejay-infra-lint/PiSignagePlayerArtifactTests.cs b/tests/bluejay-infra-lint/PiSignagePlayerArtifactTests.cs new file mode 100644 index 0000000..403f11a --- /dev/null +++ b/tests/bluejay-infra-lint/PiSignagePlayerArtifactTests.cs @@ -0,0 +1,266 @@ +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "Unit")] +public sealed class PiSignagePlayerArtifactTests +{ + private static readonly string Root = FindRepoRoot(); + private static readonly string AppRoot = Path.Combine(Root, "apps", "fc-signage-pi-player"); + + public static TheoryData RequiredArtifacts => new() + { + "README.md", + "systemd/flowercore-signage-player-pi.service", + "systemd/flowercore-signage-player-pi-hdmi.service", + "systemd/flowercore-signage-bootstrap.service", + "systemd/flowercore-signage-renew.service", + "systemd/flowercore-signage-renew.timer", + "systemd/flowercore-signage-detect-display.service", + "systemd/flowercore-signage-detect-display.timer", + "systemd/99-flowercore-signage-hdmi.rules", + "chromium-policies/flowercore-signage.json", + "scripts/flowercore-signage-launch.sh", + "scripts/flowercore-signage-prelaunch.sh", + "scripts/flowercore-signage-bootstrap.sh", + "scripts/flowercore-signage-renew-cert.sh", + "scripts/flowercore-signage-hdmi-respond.sh", + "scripts/fc-signage-detect-display", + }; + + [Theory] + [MemberData(nameof(RequiredArtifacts))] + public void RequiredArtifacts_ArePresent(string relativePath) + { + File.Exists(Path.Combine(AppRoot, relativePath)).Should().BeTrue(relativePath); + } + + [Fact] + public void PlayerService_UsesExpectedRestartAndMemoryGuards() + { + var unit = Read("systemd/flowercore-signage-player-pi.service"); + + unit.Should().Contain("Restart=always"); + unit.Should().Contain("RestartSec=10s"); + unit.Should().Contain("StartLimitBurst=5"); + unit.Should().Contain("StartLimitIntervalSec=300s"); + unit.Should().Contain("MemoryMax=2G"); + } + + [Fact] + public void PlayerService_IsGatedByNodeIdentityAndMtlsCertificate() + { + var unit = Read("systemd/flowercore-signage-player-pi.service"); + + unit.Should().Contain("ConditionPathExists=/etc/flowercore/signage-node.json"); + unit.Should().Contain("ConditionPathExists=/etc/fc-signage-player/client.p12"); + unit.Should().Contain("ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh"); + } + + [Fact] + public void LaunchScript_TriesEmbedThenFallsBackToBarePlayerRoute() + { + var script = Read("scripts/flowercore-signage-launch.sh"); + + script.Should().Contain("/player/${NODE_ID}/embed?token=${CERT_THUMB}"); + script.Should().Contain("url-divergence.log"); + script.Should().Contain("/player/${NODE_ID}?token=${CERT_THUMB}"); + } + + [Fact] + public void LaunchScript_DisablesChromiumPromptsAndRuntimeUpdates() + { + var script = Read("scripts/flowercore-signage-launch.sh"); + + script.Should().Contain("--noerrdialogs"); + script.Should().Contain("--disable-infobars"); + script.Should().Contain("--password-store=basic"); + script.Should().Contain("--check-for-update-interval=2592000"); + } + + [Fact] + public void PrelaunchScript_AbortsWhenRequiredFilesAreMissing() + { + var script = Read("scripts/flowercore-signage-prelaunch.sh"); + + script.Should().Contain("for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass"); + script.Should().Contain("exit 1"); + script.Should().Contain("-checkend $((7*24*3600))"); + } + + [Fact] + public void BootstrapScript_IsIdempotentWhenAlreadyEnrolled() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("already enrolled"); + script.Should().Contain("exit 0"); + script.Should().Contain(".enrolledAt"); + } + + [Fact] + public void BootstrapScript_GeneratesStableMachineIdFromUuid() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("uuidgen"); + script.Should().Contain("cut -c1-16"); + script.Should().Contain("machineId"); + } + + [Fact] + public void BootstrapScript_RetriesRegisterOnceForFirstCallRace() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("for attempt in 1 2"); + script.Should().Contain("register attempt $attempt returned"); + script.Should().Contain("sleep 5"); + } + + [Fact] + public void BootstrapScript_SupportsSetupCodeAndApprovalPollingBudget() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("signage-setup-code"); + script.Should().Contain("approve-via-setup-code"); + script.Should().Contain("+ 1800"); + script.Should().Contain("sleep 15"); + } + + [Fact] + public void BootstrapScript_CsrSubjectIdentifiesPiPlayer() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi"); + } + + [Fact] + public void BootstrapScript_PersistsCertificateAsP12WithRestrictivePermissions() + { + var script = Read("scripts/flowercore-signage-bootstrap.sh"); + + script.Should().Contain("openssl pkcs12 -export"); + script.Should().Contain("client.p12.pass"); + script.Should().Contain("chmod 0600"); + script.Should().Contain("chmod 0640"); + } + + [Fact] + public void RenewScript_OnlyRunsWhenCertHasLessThanThirtyDays() + { + var script = Read("scripts/flowercore-signage-renew-cert.sh"); + + script.Should().Contain("-checkend $((30*24*3600))"); + script.Should().Contain("exit 0"); + script.Should().Contain("/renew"); + } + + [Fact] + public void RenewScript_AtomicallySwapsNewCertificateFiles() + { + var script = Read("scripts/flowercore-signage-renew-cert.sh"); + + script.Should().Contain("client.key.new"); + script.Should().Contain("mv \"$CERT_DIR/client.key.new\" \"$CERT_DIR/client.key\""); + script.Should().Contain("mv \"$CERT_DIR/client.p12.new\" \"$CERT_DIR/client.p12\""); + } + + [Fact] + public void HdmiRule_RestartsPlayerAndRunsCapabilityDetection() + { + var rule = Read("systemd/99-flowercore-signage-hdmi.rules"); + + rule.Should().Contain("KERNEL==\"card?-HDMI-A-?\""); + rule.Should().Contain("restart flowercore-signage-player-pi.service"); + rule.Should().Contain("start flowercore-signage-detect-display.service"); + } + + [Fact] + public void DetectDisplayServiceAndTimer_RunAtBootAndDaily() + { + var service = Read("systemd/flowercore-signage-detect-display.service"); + var timer = Read("systemd/flowercore-signage-detect-display.timer"); + + service.Should().Contain("ExecStart=/usr/local/bin/fc-signage-detect-display"); + timer.Should().Contain("OnBootSec=30s"); + timer.Should().Contain("OnCalendar=daily"); + timer.Should().Contain("RandomizedDelaySec=1h"); + } + + [Fact] + public void DetectDisplayScript_EmitsDisconnectedProfileWhenNoHdmiIsPresent() + { + var script = Read("scripts/fc-signage-detect-display"); + + script.Should().Contain("displayConnected: false"); + script.Should().Contain("No HDMI display detected"); + } + + [Fact] + public void DetectDisplayScript_ParsesEdidForHdrResolutionAndAudio() + { + var script = Read("scripts/fc-signage-detect-display"); + + script.Should().Contain("edid-decode"); + script.Should().Contain("HDR (Static|Dynamic) Metadata Block"); + script.Should().Contain("maxResolution"); + script.Should().Contain("hasAudioOutput"); + } + + [Fact] + public void DetectDisplayScript_TriesBothForwardCompatibleCapabilityEndpoints() + { + var script = Read("scripts/fc-signage-detect-display"); + + script.Should().Contain("/api/v1/nodes/${NODE_ID}/capabilities"); + script.Should().Contain("/api/v1/displays/${NODE_ID}/capability-profile"); + script.Should().Contain("no endpoint accepted the profile"); + } + + [Fact] + public void ChromiumPolicy_IsValidJsonAndDisablesCredentialPrompts() + { + using var doc = JsonDocument.Parse(Read("chromium-policies/flowercore-signage.json")); + var root = doc.RootElement; + + root.GetProperty("AutofillAddressEnabled").GetBoolean().Should().BeFalse(); + root.GetProperty("AutofillCreditCardEnabled").GetBoolean().Should().BeFalse(); + root.GetProperty("PasswordManagerEnabled").GetBoolean().Should().BeFalse(); + root.GetProperty("ExtensionInstallBlocklist")[0].GetString().Should().Be("*"); + } + + [Fact] + public void RenewalTimer_UsesDailyCadenceWithTwoHourJitter() + { + var timer = Read("systemd/flowercore-signage-renew.timer"); + + timer.Should().Contain("OnCalendar=daily"); + timer.Should().Contain("RandomizedDelaySec=2h"); + timer.Should().Contain("Persistent=true"); + } + + private static string Read(string relativePath) + => File.ReadAllText(Path.Combine(AppRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, "apps")) + && File.Exists(Path.Combine(current.FullName, "README.md"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not find bluejay-infra root."); + } +}