<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Daan van Gorkum</title>
  <subtitle>Senior Site Reliability Engineer specializing in Linux, Infrastructure as Code, Python, and Go.</subtitle>
  <link href="https://dj.vg/feed.xml" rel="self" type="application/atom+xml"/>
  <link href="https://dj.vg/" rel="alternate" type="text/html"/>
  <id>https://dj.vg/</id>
  <updated>2026-10-23T00:00:00Z</updated>
  <author>
    <name>Daan van Gorkum</name>
    <email>me@dj.vg</email>
    <uri>https://dj.vg/</uri>
  </author>
  <entry>
    <title>Bridging Singapore Power Smart Meters to Home Assistant with Rust and MQTT</title>
    <link href="https://dj.vg/posts/bridging-singapore-power-smart-meters-to-home-assistant-rust-mqtt/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/bridging-singapore-power-smart-meters-to-home-assistant-rust-mqtt/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/bridging-singapore-power-smart-meters-to-home-assistant-rust-mqtt/</id>
    <published>2026-10-23T00:00:00Z</published>
    <updated>2026-10-23T00:00:00Z</updated>
    <summary>How I built a lightweight Rust daemon that interoperates with SP Digital's OAuth2 + SMS MFA flow and brings half-hourly AMI electricity, water, and gas telemetry into Home Assistant's Energy Dashboard.</summary>
    <content type="html">&lt;p&gt;One of the best built-in features of &lt;a href="https://www.home-assistant.io/"&gt;Home Assistant&lt;/a&gt; is its &lt;strong&gt;Energy Dashboard&lt;/strong&gt;. Feed it cumulative electricity, water, and gas counters, and it gives you long-term hourly and daily consumption curves, cost breakdowns, and baselines against individual smart plugs and air-conditioning circuits.&lt;/p&gt;
&lt;p&gt;In Singapore, &lt;strong&gt;SP Group (SP Services / SP Digital)&lt;/strong&gt; has rolled out &lt;strong&gt;Advanced Metering Infrastructure (AMI)&lt;/strong&gt; smart electricity meters across residential households. Instead of waiting for a manual meter reader once every two months, AMI meters record half-hourly electricity consumption (&lt;code&gt;48&lt;/code&gt; intervals per day) alongside monthly water (&lt;code&gt;m³&lt;/code&gt;) and piped town gas (&lt;code&gt;kWh&lt;/code&gt;) readings.&lt;/p&gt;
&lt;p&gt;In a typical Singapore condominium or HDB flat, however, your utility meters sit inside a common riser closet in the lift lobby—meaning clamping a local CT sensor or ESPHome optical pulse reader onto the meter isn't practical. While the official &lt;strong&gt;SP app&lt;/strong&gt; displays this data nicely on your phone, there is no built-in way to feed your household's own utility readings into Home Assistant for local energy automation and historical dashboards.&lt;/p&gt;
&lt;p&gt;Fresh off building &lt;a href="/posts/bridging-condo-intercom-to-home-assistant-rust-sip-mqtt/"&gt;&lt;code&gt;iplusliving2mqtt&lt;/code&gt;&lt;/a&gt; for my condo intercom, I wanted the exact same setup for my household utilities: a tiny, polite, self-contained &lt;strong&gt;Rust&lt;/strong&gt; daemon running in Kubernetes that authenticates with my SP account and exposes my home's meter readings to Home Assistant via &lt;strong&gt;MQTT Auto-Discovery&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That project is &lt;strong&gt;&lt;code&gt;sp2mqtt&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="understanding-the-client-protocol-three-design-challenges"&gt;Understanding the Client Protocol: Three Design Challenges&lt;/h2&gt;
&lt;p&gt;Inspecting how the mobile client communicates with SP Digital's backend (&lt;code&gt;identity.spdigital.sg&lt;/code&gt; and &lt;code&gt;b2c.api.spdigital.sg&lt;/code&gt;) highlighted three interesting engineering problems to solve for a clean, unattended Kubernetes integration.&lt;/p&gt;
&lt;h3 id="1-standard-auth0-android-sdk-telemetry-request-metadata"&gt;1. Standard Auth0 Android SDK Telemetry &amp;amp; Request Metadata&lt;/h3&gt;
&lt;p&gt;SP Digital authenticates users against &lt;code&gt;https://identity.spdigital.sg&lt;/code&gt; using Auth0's Resource Owner Password Realm flow (&lt;code&gt;http://auth0.com/oauth/grant-type/password-realm&lt;/code&gt;) with realm &lt;code&gt;Username-Password-Authentication&lt;/code&gt; and audience &lt;code&gt;https://profile.up.spdigital.sg/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Like most modern Auth0 deployments, the identity provider expects requests to include standard Auth0 mobile SDK telemetry and client headers rather than bare HTTP requests with a generic library User-Agent. When a request omits the expected SDK context, Auth0 flags it for additional verification:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"requires_verification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;"error_description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Suspicious request requires verification"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Looking at how the client's HTTP layer is structured, outgoing requests include standard metadata headers across three areas:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application User-Agent&lt;/strong&gt;: Identifies the client version (&lt;code&gt;Infinity/&amp;lt;versionName&amp;gt; (Android)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auth0 SDK Telemetry&lt;/strong&gt;: Includes the standard &lt;code&gt;Auth0-Client&lt;/code&gt; header (Base64URL-encoded JSON describing the &lt;code&gt;Auth0.Android&lt;/code&gt; SDK version and target API level) along with &lt;code&gt;Accept-Language: en-US&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Installation &amp;amp; Distributed Tracing Headers&lt;/strong&gt;: Attaches a persistent per-install UUIDv4 (&lt;code&gt;X-Installation-ID&lt;/code&gt;) and standard Zipkin B3 tracing headers (&lt;code&gt;X-B3-TraceId&lt;/code&gt; and &lt;code&gt;X-B3-SpanId&lt;/code&gt;) for request correlation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Including the standard client headers and OAuth2 scopes (&lt;code&gt;openid email profile offline_access enroll read:authenticators user_metadata me me:uportal me:eva me:rbac&lt;/code&gt;) allows the OAuth2 handshake to proceed smoothly into the standard Multi-Factor Authentication flow.&lt;/p&gt;
&lt;h3 id="2-handling-sms-otp-multi-factor-authentication-in-kubernetes"&gt;2. Handling SMS OTP Multi-Factor Authentication in Kubernetes&lt;/h3&gt;
&lt;p&gt;Security on utility accounts matters, and SP Digital enforces Multi-Factor Authentication (MFA). Once password authentication succeeds, Auth0 responds with &lt;code&gt;HTTP 403&lt;/code&gt; and &lt;code&gt;{"error": "mfa_required", "mfa_token": "..."}&lt;/code&gt;. Completing the login follows the standard Auth0 Out-of-Band (OOB) sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Querying &lt;code&gt;GET /mfa/authenticators&lt;/code&gt; with &lt;code&gt;Bearer &amp;lt;mfa_token&amp;gt;&lt;/code&gt; to list the account's active authenticators (such as &lt;code&gt;sms|dev_...&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Calling &lt;code&gt;POST /mfa/challenge&lt;/code&gt; (&lt;code&gt;challenge_type: "oob"&lt;/code&gt;) to request a 6-digit SMS OTP and receive an &lt;code&gt;oob_code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Submitting the 6-digit &lt;code&gt;binding_code&lt;/code&gt; to &lt;code&gt;POST /oauth/token&lt;/code&gt; using grant type &lt;code&gt;http://auth0.com/oauth/grant-type/mfa-oob&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because the initial scope includes &lt;code&gt;offline_access&lt;/code&gt;, completing MFA issues a &lt;strong&gt;long-lived &lt;code&gt;refresh_token&lt;/code&gt;&lt;/strong&gt;. Subsequent token refreshes (&lt;code&gt;grant_type: "refresh_token"&lt;/code&gt;) mint fresh 1-hour &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;id_token&lt;/code&gt; JWTs automatically without sending repeat SMS messages.&lt;/p&gt;
&lt;p&gt;Still, I wanted the initial onboarding—and any future re-authentication if a session ever expires—to feel native in a headless Kubernetes deployment &lt;strong&gt;without&lt;/strong&gt; needing to &lt;code&gt;kubectl exec&lt;/code&gt; into a running pod.&lt;/p&gt;
&lt;p&gt;To achieve that, &lt;code&gt;sp2mqtt&lt;/code&gt; exposes the MFA workflow directly inside Home Assistant over MQTT:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When &lt;code&gt;sp2mqtt&lt;/code&gt; starts with &lt;code&gt;SP_EMAIL&lt;/code&gt; and &lt;code&gt;SP_PASSWORD&lt;/code&gt; (and no cached &lt;code&gt;refresh_token&lt;/code&gt; on its persistent volume), it publishes an &lt;strong&gt;SP Services Account&lt;/strong&gt; device via MQTT Auto-Discovery with &lt;code&gt;sensor.sp_services_auth_status&lt;/code&gt; set to &lt;code&gt;MFA Required&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pressing the &lt;strong&gt;&lt;code&gt;1. Send SP SMS OTP&lt;/code&gt;&lt;/strong&gt; button in Home Assistant triggers the Auth0 OOB challenge and updates the status sensor to &lt;code&gt;OTP Sent (XXXXXXX8632)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Typing the 6-digit code into the &lt;strong&gt;&lt;code&gt;2. Submit SP 6-Digit SMS OTP&lt;/code&gt;&lt;/strong&gt; text box in Home Assistant completes the &lt;code&gt;mfa-oob&lt;/code&gt; token exchange, atomically saves the session to &lt;code&gt;/data/state.json&lt;/code&gt;, and starts the telemetry poller.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For declarative GitOps setups, &lt;code&gt;sp2mqtt&lt;/code&gt; also supports two non-interactive authentication paths:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pre-provisioned &lt;code&gt;SP_REFRESH_TOKEN&lt;/code&gt;&lt;/strong&gt;: Seeding an initial &lt;code&gt;refresh_token&lt;/code&gt; and &lt;code&gt;installation_id&lt;/code&gt; via a Kubernetes &lt;code&gt;Secret&lt;/code&gt; so the daemon can mint access tokens immediately on first boot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RFC 6238 TOTP (&lt;code&gt;SP_TOTP_SECRET&lt;/code&gt;)&lt;/strong&gt;: If an OTP authenticator is enrolled on the account, &lt;code&gt;sp2mqtt&lt;/code&gt; can compute the 6-digit TOTP code in memory during login without waiting for an SMS challenge.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-reconciling-monthly-meter-reads-with-half-hourly-ami-intervals"&gt;3. Reconciling Monthly Meter Reads with Half-Hourly AMI Intervals&lt;/h3&gt;
&lt;p&gt;Authenticated requests to SP Digital's B2C API (&lt;code&gt;https://b2c.api.spdigital.sg&lt;/code&gt;) include both the OAuth2 &lt;code&gt;Authorization: Bearer &amp;lt;access_token&amp;gt;&lt;/code&gt; header and the &lt;code&gt;X-id-token: &amp;lt;id_token&amp;gt;&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;One helpful detail when working with the API is how accounts and premises are indexed: &lt;code&gt;GET /jarvis/v3/me&lt;/code&gt; returns both a 12-digit billing &lt;code&gt;account_number&lt;/code&gt; (&lt;code&gt;008938562736&lt;/code&gt;) and a 10-digit &lt;code&gt;premise_id&lt;/code&gt; (&lt;code&gt;2100102993&lt;/code&gt;). All &lt;code&gt;/jarvis/*&lt;/code&gt; utility endpoints are keyed by the 10-digit &lt;strong&gt;&lt;code&gt;premise_id&lt;/code&gt;&lt;/strong&gt; rather than the billing account number.&lt;/p&gt;
&lt;p&gt;Telemetry for each premise is served across three endpoints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;GET /jarvis/v3/smrd-uportal/{premise_id}&lt;/code&gt;&lt;/strong&gt;: Returns the official cumulative meter readings (&lt;code&gt;last_actual_value&lt;/code&gt; and &lt;code&gt;last_actual_at&lt;/code&gt;) for &lt;code&gt;electric&lt;/code&gt; (&lt;code&gt;kWh&lt;/code&gt;), &lt;code&gt;water&lt;/code&gt; (&lt;code&gt;m³&lt;/code&gt;), and &lt;code&gt;gas&lt;/code&gt; (&lt;code&gt;kWh&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;GET /jarvis/v4/charts/{premise_id}&lt;/code&gt;&lt;/strong&gt;: Returns the monthly billed consumption history, reading status (&lt;code&gt;Actual&lt;/code&gt; vs. &lt;code&gt;Estimated&lt;/code&gt;), and neighborhood averages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;POST /jarvis/v3/ami/charts&lt;/code&gt;&lt;/strong&gt;: Returns Smart Meter (AMI) interval data—either &lt;code&gt;48&lt;/code&gt; half-hourly slots for a single day (&lt;code&gt;grouped_by: "day"&lt;/code&gt;) or daily totals across a calendar month (&lt;code&gt;grouped_by: "month"&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is a subtle nuance when feeding &lt;code&gt;/jarvis/v3/smrd-uportal&lt;/code&gt; directly into Home Assistant's Energy Dashboard: &lt;code&gt;last_actual_value&lt;/code&gt; only updates on the monthly billing read date (for example, &lt;code&gt;12,186.0 kWh&lt;/code&gt; on the 25th of the month).&lt;/p&gt;
&lt;p&gt;To give Home Assistant a cumulative &lt;code&gt;total_increasing&lt;/code&gt; electricity counter that updates every 30 minutes as smart meter data arrives, &lt;code&gt;sp2mqtt&lt;/code&gt; computes a &lt;strong&gt;live cumulative kWh reading&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;live_cumulative_kwh = meter_reading_at_t0 + sum(ami_daily_kwh for date &amp;gt; t0)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;By taking the official cumulative reading anchored at &lt;code&gt;last_actual_at&lt;/code&gt; and adding every subsequent day's AMI smart meter intervals (fetching both the previous month's and current month's daily AMI series whenever &lt;code&gt;last_actual_at&lt;/code&gt; falls in the prior month), &lt;code&gt;sensor.&amp;lt;prefix&amp;gt;_&amp;lt;premise&amp;gt;_electricity_cumulative_kwh&lt;/code&gt; advances smoothly every half hour while staying anchored to the official billing meter read.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="system-architecture"&gt;System Architecture&lt;/h2&gt;
&lt;p&gt;Like &lt;code&gt;iplusliving2mqtt&lt;/code&gt;, &lt;code&gt;sp2mqtt&lt;/code&gt; is written in &lt;strong&gt;Rust&lt;/strong&gt; as a single static binary using &lt;code&gt;tokio&lt;/code&gt;, &lt;code&gt;reqwest&lt;/code&gt; (&lt;code&gt;rustls&lt;/code&gt;), and &lt;code&gt;rumqttc&lt;/code&gt;:&lt;/p&gt;
&lt;figure class="arch-diagram" role="group" aria-label="sp2mqtt System Architecture"&gt;
  &lt;svg viewBox="0 0 680 488" width="100%" height="auto" role="img" aria-labelledby="sp2mqtt-arch-title sp2mqtt-arch-desc" xmlns="http://www.w3.org/2000/svg"&gt;
    &lt;title id="sp2mqtt-arch-title"&gt;sp2mqtt System Architecture&lt;/title&gt;
    &lt;desc id="sp2mqtt-arch-desc"&gt;Architecture diagram showing SP Digital Auth0 IDP and SP Digital B2C APIs connecting into the sp2mqtt Rust daemon (Auth and Token Manager, B2C Telemetry Poller, and Home Assistant MQTT Auto-Discovery Bridge) and publishing to Home Assistant Energy Dashboard over MQTT.&lt;/desc&gt;
    &lt;defs&gt;
      &lt;marker id="arch-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"&gt;
        &lt;path d="M 1 1.5 L 8.5 5 L 1 8.5 z" class="arch-arrow-head" /&gt;
      &lt;/marker&gt;
    &lt;/defs&gt;

    &lt;!-- Upstream External Services --&gt;
    &lt;rect x="44" y="16" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="164" y="42" text-anchor="middle" class="arch-node-title"&gt;SP Digital Auth0 IDP&lt;/text&gt;
    &lt;text x="164" y="61" text-anchor="middle" class="arch-node-sub"&gt;(identity.spdigital.sg)&lt;/text&gt;

    &lt;rect x="396" y="16" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="516" y="42" text-anchor="middle" class="arch-node-title"&gt;SP Digital B2C APIs&lt;/text&gt;
    &lt;text x="516" y="61" text-anchor="middle" class="arch-node-sub"&gt;(b2c.api.spdigital.sg)&lt;/text&gt;

    &lt;!-- Upstream Connectors --&gt;
    &lt;line x1="164" y1="78" x2="164" y2="132" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;
    &lt;text x="176" y="101" class="arch-edge-label"&gt;1. OAuth2 + MFA-OOB&lt;/text&gt;
    &lt;text x="176" y="118" class="arch-edge-label"&gt;&amp;amp; Token Refresh&lt;/text&gt;

    &lt;line x1="516" y1="78" x2="516" y2="132" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;
    &lt;text x="528" y="101" class="arch-edge-label"&gt;2. /jarvis/*&lt;/text&gt;
    &lt;text x="528" y="118" class="arch-edge-label"&gt;B2C REST&lt;/text&gt;

    &lt;!-- sp2mqtt Daemon Boundary --&gt;
    &lt;rect x="20" y="138" width="640" height="232" rx="6" class="arch-group" /&gt;
    &lt;text x="340" y="163" text-anchor="middle" class="arch-group-title"&gt;sp2mqtt&lt;/text&gt;

    &lt;!-- Internal Sub-components --&gt;
    &lt;rect x="44" y="178" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="164" y="204" text-anchor="middle" class="arch-node-title"&gt;Auth &amp;amp; Token Manager&lt;/text&gt;
    &lt;text x="164" y="223" text-anchor="middle" class="arch-node-sub"&gt;(JWT exp / state.json)&lt;/text&gt;

    &lt;line x1="284" y1="209" x2="390" y2="209" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;

    &lt;rect x="396" y="178" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="516" y="204" text-anchor="middle" class="arch-node-title"&gt;B2C Telemetry Poller&lt;/text&gt;
    &lt;text x="516" y="223" text-anchor="middle" class="arch-node-sub"&gt;(5xx Retry / Live kWh)&lt;/text&gt;

    &lt;!-- Internal Flow Connectors --&gt;
    &lt;line x1="164" y1="290" x2="164" y2="246" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;
    &lt;text x="176" y="263" class="arch-edge-label"&gt;Interactive&lt;/text&gt;
    &lt;text x="176" y="279" class="arch-edge-label"&gt;SMS OTP Flow&lt;/text&gt;

    &lt;line x1="516" y1="240" x2="516" y2="284" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;
    &lt;text x="528" y="263" class="arch-edge-label"&gt;Premise&lt;/text&gt;
    &lt;text x="528" y="279" class="arch-edge-label"&gt;Telemetry&lt;/text&gt;

    &lt;!-- MQTT Auto-Discovery Bridge --&gt;
    &lt;rect x="44" y="290" width="592" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="340" y="316" text-anchor="middle" class="arch-node-title"&gt;Home Assistant MQTT Auto-Discovery Bridge&lt;/text&gt;
    &lt;text x="340" y="335" text-anchor="middle" class="arch-node-sub"&gt;(Account Auth Controls + 12 Premise Sensors)&lt;/text&gt;

    &lt;!-- Downstream MQTT Connector --&gt;
    &lt;line x1="340" y1="352" x2="340" y2="404" class="arch-edge" marker-end="url(#arch-arrow)" /&gt;
    &lt;text x="352" y="392" class="arch-edge-label"&gt;MQTT (homeassistant/#)&lt;/text&gt;

    &lt;!-- Home Assistant Target --&gt;
    &lt;rect x="220" y="410" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="340" y="436" text-anchor="middle" class="arch-node-title"&gt;Home Assistant&lt;/text&gt;
    &lt;text x="340" y="455" text-anchor="middle" class="arch-node-sub"&gt;(Energy Dashboard)&lt;/text&gt;
  &lt;/svg&gt;
&lt;/figure&gt;

&lt;h3 id="event-driven-concurrency-state-coordination"&gt;Event-Driven Concurrency &amp;amp; State Coordination&lt;/h3&gt;
&lt;p&gt;Internally, &lt;code&gt;sp2mqtt&lt;/code&gt; runs three decoupled asynchronous tasks on the &lt;code&gt;tokio&lt;/code&gt; runtime:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Coordinator &amp;amp; Poller Loop&lt;/strong&gt;: A &lt;code&gt;tokio::select!&lt;/code&gt; state machine that owns the &lt;code&gt;SpClient&lt;/code&gt; and &lt;code&gt;PersistedState&lt;/code&gt;. It wakes either on the 30-minute polling timer, on incoming &lt;code&gt;DaemonCommand&lt;/code&gt; messages (&lt;code&gt;SendSmsOtp&lt;/code&gt;, &lt;code&gt;SubmitOtp&lt;/code&gt;, &lt;code&gt;PollNow&lt;/code&gt;) over a bounded &lt;code&gt;tokio::sync::mpsc&lt;/code&gt; channel, or on &lt;code&gt;SIGTERM&lt;/code&gt;/&lt;code&gt;SIGINT&lt;/code&gt; for graceful shutdown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reactive MQTT Bridge&lt;/strong&gt;: Manages the persistent &lt;code&gt;rumqttc&lt;/code&gt; connection, publishes retained discovery configurations, and subscribes to command topics (&lt;code&gt;sp2mqtt/cmd/#&lt;/code&gt;) and Home Assistant's birth topic (&lt;code&gt;homeassistant/status&lt;/code&gt;). Whenever the coordinator updates authentication state or premise telemetry in shared &lt;code&gt;ReadinessState&lt;/code&gt;, it signals a &lt;code&gt;tokio::sync::Notify&lt;/code&gt; waitlist so the MQTT task immediately flushes updated entity payloads without polling locks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability &amp;amp; Control Server&lt;/strong&gt;: A lightweight HTTP server on &lt;code&gt;:8080&lt;/code&gt; exposing &lt;code&gt;/healthz&lt;/code&gt;, &lt;code&gt;/readyz&lt;/code&gt;, &lt;code&gt;/metrics.json&lt;/code&gt;, and an on-demand &lt;code&gt;POST /poll&lt;/code&gt; trigger.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="polite-polling-proactive-token-refresh"&gt;Polite Polling &amp;amp; Proactive Token Refresh&lt;/h3&gt;
&lt;p&gt;Since AMI smart meter data settles in 30-minute intervals, &lt;code&gt;sp2mqtt&lt;/code&gt; defaults to a polite 30-minute polling cadence (&lt;code&gt;1800&lt;/code&gt; seconds)—making only a handful of lightweight HTTPS requests per half hour. Before each cycle, the token manager inspects the &lt;code&gt;exp&lt;/code&gt; claim inside both the &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;id_token&lt;/code&gt; JWTs (refreshing proactively with a 5-minute safety margin) and persists updated credentials atomically via a temporary file (&lt;code&gt;state.json.tmp&lt;/code&gt; → &lt;code&gt;rename&lt;/code&gt;) so an unexpected pod eviction never corrupts the cached refresh token. Transient upstream &lt;code&gt;5xx&lt;/code&gt; gateway responses are handled with bounded exponential backoff.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="home-assistant-entities"&gt;Home Assistant Entities&lt;/h2&gt;
&lt;p&gt;Once connected to the MQTT broker, &lt;code&gt;sp2mqtt&lt;/code&gt; registers two devices in Home Assistant with zero manual YAML configuration:&lt;/p&gt;
&lt;h3 id="1-account-authentication-device-sp-services-account"&gt;1. Account &amp;amp; Authentication Device (&lt;code&gt;SP Services Account&lt;/code&gt;)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Entity&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Type&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Auth Status&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;sensor&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;Authenticated&lt;/code&gt;, &lt;code&gt;MFA Required&lt;/code&gt;, &lt;code&gt;OTP Sent (...)&lt;/code&gt;, or &lt;code&gt;Error&lt;/code&gt; with diagnostic attributes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Authenticated&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;binary_sensor&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;ON&lt;/code&gt; when tokens are valid; &lt;code&gt;OFF&lt;/code&gt; if interactive MFA is needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;1. Send SP SMS OTP&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;button&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Initiates Auth0 login and triggers an SMS OTP challenge to your registered phone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;2. Submit SP 6-Digit SMS OTP&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;text&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Masked input box to submit the 6-digit SMS code directly from the Home Assistant UI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Refresh Utility Usage Now&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;button&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Triggers an immediate on-demand telemetry poll across all premises.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="2-per-premise-utility-device-sp-premise-premise_id"&gt;2. Per-Premise Utility Device (&lt;code&gt;SP Premise &amp;lt;premise_id&amp;gt;&lt;/code&gt;)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Entity&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Class / State Class&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Unit&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Cumulative (Live)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;total_increasing&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Energy Dashboard primary sensor&lt;/strong&gt;: Official meter reading + subsequent AMI intervals.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Meter Reading&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;total_increasing&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Official cumulative meter reading from &lt;code&gt;smrd-uportal&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Today (AMI)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;total&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Today's smart meter consumption across 48 half-hour slots.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Yesterday (AMI)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Yesterday's smart meter total (with full 48-slot breakdown in attributes).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Month-to-Date (AMI)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;total&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Current month's daily AMI total (with daily bars in attributes).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Latest 30m Slot&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Consumption during the most recently completed 30-minute AMI interval.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Latest 30m Power&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;power&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;W&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Average load (&lt;code&gt;kWh * 2000 W/kWh&lt;/code&gt;) during the latest 30-minute slot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Electricity Billed Month&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Latest monthly billed electricity usage (&lt;code&gt;Actual&lt;/code&gt; / &lt;code&gt;Estimated&lt;/code&gt; + neighbor average).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Water Meter Reading&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;water&lt;/code&gt; / &lt;code&gt;total_increasing&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;m³&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Energy Dashboard water sensor&lt;/strong&gt;: Cumulative water meter reading.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Water Billed Month&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;water&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;m³&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Latest monthly billed water consumption.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Gas Meter Reading&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;total_increasing&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Energy Dashboard gas sensor&lt;/strong&gt;: Cumulative piped town gas meter reading.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Gas Billed Month&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;energy&lt;/code&gt; / &lt;code&gt;measurement&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;kWh&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Latest monthly billed gas consumption.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2 id="kubernetes-runtime-architecture"&gt;Kubernetes Runtime Architecture&lt;/h2&gt;
&lt;p&gt;Running a stateful IoT bridge in Kubernetes that also supports interactive MFA requires careful alignment between container security, volume lifecycle, and probe semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hardened Distroless Runtime&lt;/strong&gt;: &lt;code&gt;sp2mqtt&lt;/code&gt; is compiled in a multi-stage build and packaged onto &lt;strong&gt;&lt;code&gt;gcr.io/distroless/cc-debian13:nonroot&lt;/code&gt;&lt;/strong&gt;. The pod runs as UID/GID &lt;code&gt;65532&lt;/code&gt; with &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt;, &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt;, &lt;code&gt;automountServiceAccountToken: false&lt;/code&gt;, and all Linux capabilities dropped—mounting only a read-only ConfigMap at &lt;code&gt;/etc/sp2mqtt&lt;/code&gt; and a tiny PersistentVolumeClaim at &lt;code&gt;/data&lt;/code&gt; for &lt;code&gt;state.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-Writer Rollout Strategy (&lt;code&gt;Recreate&lt;/code&gt;)&lt;/strong&gt;: Because Auth0 refresh tokens and installation metadata are persisted on a &lt;code&gt;ReadWriteOnce&lt;/code&gt; volume, the Deployment uses &lt;code&gt;strategy.type: Recreate&lt;/code&gt; so two replicas never race to refresh or overwrite the same OAuth2 session concurrently during rollouts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decoupled Readiness vs. Auth State&lt;/strong&gt;: If &lt;code&gt;/readyz&lt;/code&gt; required an active &lt;code&gt;access_token&lt;/code&gt;, Kubernetes would mark an unauthenticated pod unready—and if a liveness probe were tied to authentication, the kubelet would enter a &lt;code&gt;CrashLoopBackOff&lt;/code&gt; right while the user is waiting for an SMS OTP. Instead, &lt;code&gt;/healthz&lt;/code&gt; verifies that the Tokio runtime is responsive, while &lt;code&gt;/readyz&lt;/code&gt; checks that the MQTT broker connection is active (&lt;code&gt;mqtt_connected: true&lt;/code&gt;). That way, the pod stays healthy and ready so it can expose the &lt;strong&gt;SP Services Account&lt;/strong&gt; MFA controls in Home Assistant while awaiting initial OTP verification.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MQTT Last Will &amp;amp; Birth Synchronization&lt;/strong&gt;: The MQTT client registers a Last Will and Testament (&lt;code&gt;sp2mqtt/status&lt;/code&gt; → &lt;code&gt;offline&lt;/code&gt;) and subscribes to &lt;code&gt;homeassistant/status&lt;/code&gt;. If Home Assistant restarts while &lt;code&gt;sp2mqtt&lt;/code&gt; is already running, receiving the &lt;code&gt;online&lt;/code&gt; birth message triggers an immediate re-broadcast of all discovery schemas and cached premise states so sensors never show up as &lt;code&gt;unavailable&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you're in Singapore and interested in how household smart meter, water, and gas telemetry can be bridged into Home Assistant's Energy Dashboard, feel free to reach out at &lt;a href="mailto:me@dj.vg"&gt;me@dj.vg&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>CVE-2026-109618 &amp; CVE-2026-109619: Mitigating PHP's Critical CVSS 9.2 RCE Vulnerabilities When You Cannot Immediately Upgrade</title>
    <link href="https://dj.vg/posts/cve-2026-109618-cve-2026-109619-php-upload-progress-uaf-mitigation/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/cve-2026-109618-cve-2026-109619-php-upload-progress-uaf-mitigation/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/cve-2026-109618-cve-2026-109619-php-upload-progress-uaf-mitigation/</id>
    <published>2026-10-21T00:00:00Z</published>
    <updated>2026-10-21T00:00:00Z</updated>
    <summary>Complete technical breakdown and urgent web-server, FastCGI, HAProxy, NGINX, Apache, and PHP configuration mitigations for CVE-2026-109618 (OOM corruption) and CVE-2026-109619 (stale global state &amp; session upload progress UAF).</summary>
    <content type="html">&lt;p&gt;On &lt;a href="https://www.php.net/archive/2026.php#2026-10-20-1"&gt;20 October 2026&lt;/a&gt;, the PHP Security Team issued an rare advance warning of an upcoming emergency security release addressing two critical vulnerabilities—both rated &lt;strong&gt;CVSS v4.0 9.2&lt;/strong&gt;—while reserving the right to release early if vulnerability details leaked or active exploitation appeared in the wild.&lt;/p&gt;
&lt;p&gt;Hours later, ahead of the originally scheduled 14:00 UTC window, the PHP team published emergency releases (&lt;strong&gt;PHP 8.5.12&lt;/strong&gt;, &lt;strong&gt;PHP 8.4.27&lt;/strong&gt;, &lt;strong&gt;PHP 8.3.36&lt;/strong&gt;, and &lt;strong&gt;PHP 8.2.35&lt;/strong&gt;) and &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5"&gt;official advisories&lt;/a&gt; for &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; (&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w"&gt;GHSA-w8hw-w9fx-j79w&lt;/a&gt;) and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt; (&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp"&gt;GHSA-6wmf-4rv5-p3rp&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;If you operate PHP in production—including &lt;strong&gt;Apache &lt;code&gt;mod_php&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;PHP-FPM&lt;/strong&gt;, &lt;strong&gt;FrankenPHP&lt;/strong&gt;, &lt;strong&gt;LiteSpeed (&lt;code&gt;lsphp&lt;/code&gt;)&lt;/strong&gt;, or &lt;strong&gt;&lt;code&gt;php-cgi&lt;/code&gt;&lt;/strong&gt;—you need to take action &lt;strong&gt;immediately&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id="why-you-must-act-right-now-even-if-you-cannot-upgrade-today"&gt;Why You Must Act Right Now (Even If You Cannot Upgrade Today)&lt;/h3&gt;
&lt;p&gt;The PHP Security Team is explicit: &lt;strong&gt;updating to a patched PHP release is the only complete fix.&lt;/strong&gt; However, millions of production systems cannot be upgraded on zero notice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Volunteer RPM repositories lag behind&lt;/strong&gt;: The official PHP advisory explicitly warns that &lt;strong&gt;&lt;code&gt;rpms.remirepo.net&lt;/code&gt; will not have updated packages available today&lt;/strong&gt;, urging operators on RHEL/Rocky/AlmaLinux/Fedora to switch to vendor packages, build from source, or apply immediate risk-reduction controls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End-of-Life PHP branches (PHP 7.4, 8.0, 8.1)&lt;/strong&gt;: Roughly a large fraction of legacy enterprise workloads still run PHP 7.4–8.1. Because the vulnerabilities are so severe, the PHP team published emergency source-only backport branches (&lt;a href="https://github.com/php/php-src/tree/PHP-7.4-CVE-2026-109618-109619"&gt;PHP 7.4&lt;/a&gt;, &lt;a href="https://github.com/php/php-src/tree/PHP-8.0-CVE-2026-109618-109619"&gt;PHP 8.0&lt;/a&gt;, and &lt;a href="https://github.com/php/php-src/tree/PHP-8.1-CVE-2026-109618-109619"&gt;PHP 8.1&lt;/a&gt;), while PHP 8.2 itself reaches End-of-Life on 31 December 2026.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-script execution&lt;/strong&gt;: The most dangerous attack paths trigger during multipart request startup and early INI/session initialization—&lt;strong&gt;before a single line of your application's &lt;code&gt;.php&lt;/code&gt; code runs&lt;/strong&gt;. Application-level authentication, framework middleware, and &lt;code&gt;ini_set()&lt;/code&gt; calls will &lt;strong&gt;not&lt;/strong&gt; protect you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you cannot roll out patched binaries across every server this hour, you must immediately apply the configuration, web-server (NGINX, Apache, HAProxy), and WAF mitigations detailed below to break the exploit chains while you prepare your upgrade.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="executive-summary-official-facts"&gt;Executive Summary &amp;amp; Official Facts&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Attribute&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;CVE Identifiers&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;CVE-2026-109618&lt;/strong&gt; (&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w"&gt;GHSA-w8hw-w9fx-j79w&lt;/a&gt;) &amp;amp; &lt;strong&gt;CVE-2026-109619&lt;/strong&gt; (&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp"&gt;GHSA-6wmf-4rv5-p3rp&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Severity&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Critical — CVSS v4.0 9.2&lt;/strong&gt; (Unauthenticated Remote Code Execution)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;At-Risk Runtimes&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;All web-serving PHP SAPIs: &lt;strong&gt;Apache &lt;code&gt;mod_php&lt;/code&gt;&lt;/strong&gt; (highest risk), &lt;strong&gt;PHP-FPM&lt;/strong&gt;, &lt;strong&gt;FrankenPHP&lt;/strong&gt;, &lt;strong&gt;LiteSpeed (&lt;code&gt;lsphp&lt;/code&gt;)&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;php-cgi&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Patched Releases&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.5.12&lt;/strong&gt;, &lt;strong&gt;PHP 8.4.27&lt;/strong&gt;, &lt;strong&gt;PHP 8.3.36&lt;/strong&gt;, &lt;strong&gt;PHP 8.2.35&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;EOL Source Backports&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Official source branches published for &lt;strong&gt;PHP 7.4&lt;/strong&gt;, &lt;strong&gt;PHP 8.0&lt;/strong&gt;, and &lt;strong&gt;PHP 8.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Remi Repo Warning&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;rpms.remirepo.net&lt;/code&gt; &lt;strong&gt;does not have updates on release day&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Primary Emergency Controls&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;session.upload_progress.enabled = 0&lt;/code&gt;, &lt;code&gt;memory_limit = -1&lt;/code&gt; (paired with cgroup limits &amp;amp; low &lt;code&gt;post_max_size&lt;/code&gt;), &lt;code&gt;fatal_error_backtraces = 0&lt;/code&gt; (PHP 8.5), plus strict proxy/WAF multipart filtering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2 id="technical-breakdown-of-the-two-vulnerabilities"&gt;Technical Breakdown of the Two Vulnerabilities&lt;/h2&gt;
&lt;h3 id="1-cve-2026-109618-rce-via-out-of-memory-oom-corruption-ghsa-w8hw-w9fx-j79w"&gt;1. CVE-2026-109618: RCE via Out-of-Memory (OOM) Corruption (&lt;code&gt;GHSA-w8hw-w9fx-j79w&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Historically, PHP used &lt;code&gt;longjmp()&lt;/code&gt; when encountering an Out-of-Memory (OOM) error to bail out of the current execution context and abort the request.&lt;/p&gt;
&lt;p&gt;In several internal contexts, request memory becomes reachable before it is fully initialized. If &lt;em&gt;any&lt;/em&gt; memory allocation fails at that moment, &lt;code&gt;longjmp()&lt;/code&gt; exits early without cleaning up or resetting the partially initialized structures. During request shutdown, PHP treats that uninitialized/corrupted memory as valid state and reads or branches on it—leading directly to Remote Code Execution.&lt;/p&gt;
&lt;h4 id="how-attackers-trigger-controllable-oom-during-request-startup"&gt;How Attackers Trigger Controllable OOM During Request Startup&lt;/h4&gt;
&lt;p&gt;An attacker does not need an application memory leak to hit &lt;code&gt;memory_limit&lt;/code&gt;. Several quirks in PHP's &lt;code&gt;multipart/form-data&lt;/code&gt; parser allow a request to exhaust &lt;code&gt;memory_limit&lt;/code&gt; during request startup while appearing to stay within &lt;code&gt;post_max_size&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Missing or Unusable &lt;code&gt;Content-Length&lt;/code&gt;&lt;/strong&gt;: A multipart request without a usable declared &lt;code&gt;Content-Length&lt;/code&gt; can bypass the initial &lt;code&gt;post_max_size&lt;/code&gt; gate and continue feeding decoded body chunks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unbounded Multipart Part Headers&lt;/strong&gt;: A single multipart part can contain an arbitrary number of headers. Because PHP's internal hash tables and &lt;code&gt;zend_string&lt;/code&gt; structures consume far more memory than the raw wire bytes, header amplification can exhaust &lt;code&gt;memory_limit&lt;/code&gt; well below &lt;code&gt;post_max_size&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Empty File Controls &amp;amp; Nested Array Amplification&lt;/strong&gt;: Empty file upload inputs do not count against &lt;code&gt;max_file_uploads&lt;/code&gt;, yet still allocate &lt;code&gt;$_FILES&lt;/code&gt; metadata structures. Deeply nested field names (&lt;code&gt;a[b][c]...&lt;/code&gt;) multiply the internal &lt;code&gt;HashTable&lt;/code&gt; allocations.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="sapi-risk-profile-for-cve-2026-109618"&gt;SAPI Risk Profile for CVE-2026-109618&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Apache &lt;code&gt;mod_php&lt;/code&gt; &amp;amp; FrankenPHP (Highest Risk)&lt;/strong&gt;: Both SAPIs unconditionally invoke the request shutdown procedure even when request startup fails (&lt;code&gt;php_request_startup()&lt;/code&gt;). An unauthenticated attacker can trigger a multipart OOM during startup and achieve RCE during shutdown &lt;strong&gt;before any application code executes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PHP-FPM &amp;amp; LiteSpeed (&lt;code&gt;lsphp&lt;/code&gt;)&lt;/strong&gt;: FPM terminates the worker process if &lt;code&gt;php_request_startup()&lt;/code&gt; fails, preventing immediate startup shutdown exploitation. However, OOM corruption can still be triggered &lt;em&gt;after&lt;/em&gt; request startup completes, and FPM is directly exposed to CVE-2026-109619.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;How PHP Patched It:&lt;/em&gt; The security release removes &lt;code&gt;longjmp()&lt;/code&gt; on OOM, replacing it with a delayed soft OOM checked at the next Zend VM safe point, backed by an immediate hard &lt;code&gt;_exit()&lt;/code&gt; if memory consumption crosses a secondary threshold.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="2-cve-2026-109619-rce-via-stale-global-state-session-upload-progress-uaf-ghsa-6wmf-4rv5-p3rp"&gt;2. CVE-2026-109619: RCE via Stale Global State &amp;amp; Session Upload Progress UAF (&lt;code&gt;GHSA-6wmf-4rv5-p3rp&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Persistent PHP workers (&lt;code&gt;mod_php&lt;/code&gt;, &lt;code&gt;php-fpm&lt;/code&gt;, &lt;code&gt;lsphp&lt;/code&gt;, FrankenPHP) retain global state structures—spanning error handling, session tracking, object storage, and extensions—that temporarily point to memory owned by the current request's heap.&lt;/p&gt;
&lt;p&gt;Exploitation of &lt;strong&gt;CVE-2026-109619&lt;/strong&gt; follows a two-step &lt;strong&gt;Producer → Consumer&lt;/strong&gt; model across requests handled by the same worker process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The Producer (Request 1)&lt;/strong&gt;: A PHP operation stores a pointer to request-owned heap memory in a persistent worker global. Before the operation can reset that pointer, the request is interrupted by a fatal error (such as an OOM, uncaught error, failed &lt;code&gt;require&lt;/code&gt;, or &lt;code&gt;Maximum execution time exceeded&lt;/code&gt;)—or a late shutdown/cleanup warning repopulates the error state after normal shutdown cleared it. PHP frees the request heap, leaving a &lt;strong&gt;dangling pointer&lt;/strong&gt; in the worker's global state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Consumer (Request 2 on the same worker)&lt;/strong&gt;: Before normal request initialization (&lt;code&gt;php_request_startup()&lt;/code&gt;) resets the worker's globals, an early pre-initialization operation accesses, frees, or branches on the stale pointer, triggering a &lt;strong&gt;Use-After-Free (UAF)&lt;/strong&gt; on a controlled heap layout and yielding unauthenticated &lt;strong&gt;Remote Code Execution&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="confirmed-exploitation-paths-for-cve-2026-109619"&gt;Confirmed Exploitation Paths for CVE-2026-109619&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;RFC 1867 Session Upload Progress Lifecycle (Confirmed RCE on both &lt;code&gt;PHP-FPM&lt;/code&gt; and &lt;code&gt;mod_php&lt;/code&gt;)&lt;/strong&gt;:&lt;/li&gt;
&lt;li&gt;Because &lt;code&gt;session.upload_progress.enabled&lt;/code&gt; is &lt;strong&gt;enabled by default (&lt;code&gt;1&lt;/code&gt;)&lt;/strong&gt;, sending a &lt;code&gt;multipart/form-data&lt;/code&gt; request containing &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; initializes session and decoder state during SAPI input parsing—&lt;strong&gt;before normal module initialization and without any application call to &lt;code&gt;session_start()&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In addition to intra-request stale &lt;code&gt;zval*&lt;/code&gt; pointer hazards (&lt;code&gt;post_bytes_processed&lt;/code&gt; / &lt;code&gt;current_file_bytes_processed&lt;/code&gt;) when multipart streams stall across &lt;code&gt;session.upload_progress.min_freq&lt;/code&gt;, interrupting an upload or request after session/decoder state is published leaves persistent session globals pointing into freed request memory. A subsequent upload progress request routed to the same worker consumes those stale globals prior to initialization, achieving full RCE on both &lt;strong&gt;PHP-FPM&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;mod_php&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PHP 8.5 &lt;code&gt;fatal_error_backtraces&lt;/code&gt; (Confirmed RCE on &lt;code&gt;mod_php&lt;/code&gt;)&lt;/strong&gt;:&lt;/li&gt;
&lt;li&gt;In PHP 8.5, when &lt;code&gt;fatal_error_backtraces&lt;/code&gt; is enabled, fatal error reporting stores request-owned backtrace state in global memory and fails to reset it during shutdown. Any early diagnostic on the next request consumes the freed backtrace structure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Early INI Diagnostics Before Request Initialization&lt;/strong&gt;:&lt;/li&gt;
&lt;li&gt;Any warning, notice, or deprecation emitted while applying per-request INI overrides (via &lt;code&gt;.htaccess&lt;/code&gt;, Apache &lt;code&gt;php_value&lt;/code&gt;/&lt;code&gt;php_flag&lt;/code&gt;/&lt;code&gt;php_admin_*&lt;/code&gt;, or FastCGI &lt;code&gt;PHP_VALUE&lt;/code&gt;/&lt;code&gt;PHP_ADMIN_VALUE&lt;/code&gt;) accesses global error state &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;php_request_startup()&lt;/code&gt; resets it.&lt;/li&gt;
&lt;li&gt;Examples include deprecated settings (&lt;code&gt;auto_detect_line_endings=1&lt;/code&gt;, non-default &lt;code&gt;filter.default&lt;/code&gt;, PHP 8.3+ &lt;code&gt;assert.*&lt;/code&gt;, PHP 8.4+ legacy session directives, PHP 8.5 &lt;code&gt;report_memleaks=0&lt;/code&gt;, explicit &lt;code&gt;mbstring.*&lt;/code&gt; or &lt;code&gt;iconv.*&lt;/code&gt; encodings) or paths rejected by &lt;code&gt;open_basedir&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crucial detail&lt;/strong&gt;: PHP accesses the stale global state &lt;em&gt;before&lt;/em&gt; checking &lt;code&gt;error_reporting&lt;/code&gt;, &lt;code&gt;display_errors&lt;/code&gt;, &lt;code&gt;log_errors&lt;/code&gt;, or the &lt;code&gt;@&lt;/code&gt; suppression operator. Hiding warnings does &lt;strong&gt;not&lt;/strong&gt; prevent exploitation.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2 id="immediate-mitigations-if-you-cannot-upgrade-php-today"&gt;Immediate Mitigations If You Cannot Upgrade PHP Today&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning from the PHP Security Team:&lt;/strong&gt; Only upgrading to &lt;strong&gt;PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35&lt;/strong&gt; (or applying the official source backports for 7.4/8.0/8.1) completely resolves these vulnerabilities. If you cannot upgrade immediately, you must apply the following layered controls across both PHP and your web servers/proxies &lt;strong&gt;and restart all PHP workers&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="step-1-lock-down-php-configuration-phpini-fpm-pool"&gt;Step 1: Lock Down PHP Configuration (&lt;code&gt;php.ini&lt;/code&gt; / FPM Pool)&lt;/h3&gt;
&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; rely on &lt;code&gt;ini_set()&lt;/code&gt; inside PHP scripts or &lt;code&gt;.user.ini&lt;/code&gt; files—both run &lt;em&gt;after&lt;/em&gt; the vulnerable pre-initialization paths have already executed. Apply these settings in global &lt;code&gt;php.ini&lt;/code&gt; or FPM pool definitions and &lt;strong&gt;restart every worker&lt;/strong&gt;:&lt;/p&gt;
&lt;p&gt;Create &lt;code&gt;/etc/php/*/fpm/conf.d/99-cve-2026-109618-109619-mitigation.ini&lt;/code&gt; (and the equivalent for Apache/LSAPI):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;; 1. Block CVE-2026-109619 session upload progress producer &amp;amp; consumer paths&lt;/span&gt;
&lt;span class="c1"&gt;;    Recommended by the PHP Security Team even AFTER patching.&lt;/span&gt;
&lt;span class="na"&gt;session.upload_progress.enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="c1"&gt;; 2. Block CVE-2026-109619 PHP 8.5 fatal error backtrace producer path&lt;/span&gt;
&lt;span class="na"&gt;fatal_error_backtraces&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;

&lt;span class="c1"&gt;; 3. Mitigate CVE-2026-109618 controllable per-request PHP OOM longjmp()&lt;/span&gt;
&lt;span class="c1"&gt;;    IMPORTANT: Pair this with strict post_max_size and cgroup memory.max limits!&lt;/span&gt;
&lt;span class="na"&gt;memory_limit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;-1&lt;/span&gt;

&lt;span class="c1"&gt;; 4. Bound multipart and input amplification vectors (CVE-2026-109618)&lt;/span&gt;
&lt;span class="na"&gt;post_max_size&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;8M&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;; Use 8M if uploads aren't needed, or 32M if required&lt;/span&gt;
&lt;span class="na"&gt;upload_max_filesize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;4M&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;; Keep strictly below post_max_size&lt;/span&gt;
&lt;span class="na"&gt;max_file_uploads&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="na"&gt;max_input_vars&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;500&lt;/span&gt;
&lt;span class="na"&gt;max_input_nesting_level&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;In your PHP-FPM pool configuration (&lt;code&gt;/etc/php/*/fpm/pool.d/www.conf&lt;/code&gt;), enforce these with &lt;code&gt;php_admin_value&lt;/code&gt; / &lt;code&gt;php_admin_flag&lt;/code&gt; so no application or directory override can re-enable finite &lt;code&gt;memory_limit&lt;/code&gt; or &lt;code&gt;upload_progress&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="na"&gt;php_admin_flag[session.upload_progress.enabled]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="na"&gt;php_admin_flag[fatal_error_backtraces]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;
&lt;span class="na"&gt;php_admin_value[memory_limit]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;-1&lt;/span&gt;
&lt;span class="na"&gt;php_admin_value[post_max_size]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;8M&lt;/span&gt;
&lt;span class="na"&gt;php_admin_value[max_input_nesting_level]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Critical Cgroup vs. &lt;code&gt;RLIMIT&lt;/code&gt; Warning:&lt;/strong&gt; When setting &lt;code&gt;memory_limit = -1&lt;/code&gt; to prevent attackers from triggering a predictable PHP &lt;code&gt;longjmp()&lt;/code&gt;, enforce your process memory boundary using a &lt;strong&gt;container or systemd/cgroup v2 limit (&lt;code&gt;MemoryMax=&lt;/code&gt; / &lt;code&gt;memory.max&lt;/code&gt;)&lt;/strong&gt; that OOM-kills the worker cleanly. &lt;strong&gt;NEVER use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt;&lt;/strong&gt;, because when &lt;code&gt;RLIMIT&lt;/code&gt; is reached, &lt;code&gt;mmap()&lt;/code&gt; / &lt;code&gt;brk()&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt; to PHP, which immediately triggers the exact vulnerable &lt;code&gt;longjmp()&lt;/code&gt; OOM path!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Restart PHP-FPM or Apache immediately after applying:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;restart&lt;span class="w"&gt; &lt;/span&gt;php*-fpm
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;hr /&gt;
&lt;h3 id="step-2-web-server-fastcgi-hardening-nginx-apache-haproxy"&gt;Step 2: Web Server &amp;amp; FastCGI Hardening (NGINX, Apache, HAProxy)&lt;/h3&gt;
&lt;p&gt;audit any &lt;code&gt;PHP_VALUE&lt;/code&gt; or &lt;code&gt;PHP_ADMIN_VALUE&lt;/code&gt; directives passed from NGINX, Apache, or HAProxy to ensure they &lt;strong&gt;never&lt;/strong&gt; pass deprecated or invalid INI directives (which act as early diagnostic consumers for CVE-2026-109619), while enforcing &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; and strict body limits at the proxy boundary.&lt;/p&gt;
&lt;h4 id="option-a-nginx-php-fpm"&gt;Option A: NGINX + PHP-FPM&lt;/h4&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;http&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Enforce decoded body size at the reverse proxy (including chunked requests)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;client_max_body_size&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;client_body_timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Buffer the full client request before forwarding to PHP-FPM&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;fastcgi_request_buffering&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;proxy_request_buffering&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="kn"&gt;location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sr"&gt;\.php$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="kn"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;unix:/run/php/php-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="c1"&gt;# Enforce administrative kill switches (ensure no deprecated INI keys are sent)&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="kn"&gt;fastcgi_param&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;PHP_ADMIN_VALUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"session.upload_progress.enabled=0\nmemory_limit=-1"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Reload NGINX:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;nginx&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;reload&lt;span class="w"&gt; &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h4 id="option-b-apache-mod_php-and-mod_proxy_fcgi"&gt;Option B: Apache (&lt;code&gt;mod_php&lt;/code&gt; and &lt;code&gt;mod_proxy_fcgi&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Because &lt;strong&gt;Apache &lt;code&gt;mod_php&lt;/code&gt; faces the highest risk&lt;/strong&gt; (unconditional shutdown execution on startup OOM and pre-startup &lt;code&gt;.htaccess&lt;/code&gt; INI evaluation):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Disable &lt;code&gt;.htaccess&lt;/code&gt; overrides (&lt;code&gt;AllowOverride None&lt;/code&gt;)&lt;/strong&gt; wherever possible so attackers or legacy directories cannot trigger pre-initialization INI warnings via &lt;code&gt;php_value&lt;/code&gt; / &lt;code&gt;php_flag&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Enforce request size and rate limits via &lt;code&gt;LimitRequestBody&lt;/code&gt; and &lt;code&gt;mod_reqtimeout&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# Reject oversized or un-bounded request bodies at the Apache layer (8 MB)&lt;/span&gt;
&lt;span class="nb"&gt;LimitRequestBody&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;8388608&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;IfModule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mod_reqtimeout.c&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;RequestReadTimeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;header=10-20,MinRate=500&lt;span class="w"&gt; &lt;/span&gt;body=10,MinRate=1000
&lt;span class="nt"&gt;&amp;lt;/IfModule&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;IfModule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mod_php.c&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;php_admin_flag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;session.upload_progress.enabled&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;off&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;php_admin_flag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;fatal_error_backtraces&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;off&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;php_admin_value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;memory_limit&lt;span class="w"&gt; &lt;/span&gt;-1
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;php_admin_value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;post_max_size&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;M
&lt;span class="nt"&gt;&amp;lt;/IfModule&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;IfModule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mod_proxy_fcgi.c&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;SetEnv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;PHP_ADMIN_VALUE&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"session.upload_progress.enabled=0"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/IfModule&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Restart Apache so all worker processes start with a clean global heap:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;apachectl&lt;span class="w"&gt; &lt;/span&gt;configtest&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;restart&lt;span class="w"&gt; &lt;/span&gt;apache2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h4 id="option-c-haproxy-edge-body-filtering-buffering-fastcgi"&gt;Option C: HAProxy (Edge Body Filtering, Buffering &amp;amp; FastCGI)&lt;/h4&gt;
&lt;p&gt;In HAProxy, you can simultaneously:
1. Block requests missing a valid &lt;code&gt;Content-Length&lt;/code&gt; on &lt;code&gt;multipart/form-data&lt;/code&gt; POSTs (preventing the CVE-2026-109618 &lt;code&gt;post_max_size&lt;/code&gt; bypass),
2. Block &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; in buffered multipart bodies (preventing the CVE-2026-109619 session lifecycle consumer/producer), and
3. Lock &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; if using HAProxy's native &lt;code&gt;fcgi-app&lt;/code&gt;.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;frontend https_edge
    bind :443 ssl crt /etc/haproxy/certs/
    mode http

    # Buffer client request bodies and enforce strict timeouts
    option http-buffer-request
    timeout http-request 5s
    timeout client-fin 5s

    acl is_post method POST
    acl is_multipart req.hdr(Content-Type) -i -m sub multipart/form-data

    # 1. CVE-2026-109618: Require a valid Content-Length on multipart POSTs and cap size
    acl has_content_length req.hdr_cnt(Content-Length) eq 1
    acl body_too_large req.body_size gt 8388608
    http-request deny deny_status 411 if is_post is_multipart !has_content_length
    http-request deny deny_status 413 if is_post body_too_large

    # 2. CVE-2026-109619: Block multipart payloads containing PHP_SESSION_UPLOAD_PROGRESS
    acl has_upload_progress req.body -m sub PHP_SESSION_UPLOAD_PROGRESS
    http-request deny deny_status 403 if is_post is_multipart has_upload_progress

    default_backend php_fpm_nodes

fcgi-app php-fpm-app
    docroot /var/www/html
    index index.php
    path-info ^(/.+\.php)(/.*)?$
    set-param PHP_ADMIN_VALUE "session.upload_progress.enabled=0"

backend php_fpm_nodes
    mode http
    use-fcgi-app php-fpm-app
    server fpm1 127.0.0.1:9000 proto fcgi check
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Validate and reload HAProxy:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;haproxy&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;/etc/haproxy/haproxy.cfg&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;reload&lt;span class="w"&gt; &lt;/span&gt;haproxy
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;hr /&gt;
&lt;h3 id="step-3-waf-edge-rules-modsecurity-openresty"&gt;Step 3: WAF &amp;amp; Edge Rules (ModSecurity &amp;amp; OpenResty)&lt;/h3&gt;
&lt;p&gt;If your application does not use &lt;code&gt;multipart/form-data&lt;/code&gt; file uploads at all, the PHP Security Team recommends blocking &lt;code&gt;multipart/form-data&lt;/code&gt; requests at the frontend entirely. Otherwise, enforce strict multipart part/header bounds and block &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt;:&lt;/p&gt;
&lt;h4 id="modsecurity-apache-nginx-owasp-crs"&gt;ModSecurity (Apache, NGINX, OWASP CRS)&lt;/h4&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# Block PHP_SESSION_UPLOAD_PROGRESS in parsed POST fields or raw multipart streams&lt;/span&gt;
&lt;span class="nb"&gt;SecRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;REQUEST_METHOD&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@streq POST"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;\
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"id:1000101,\&lt;/span&gt;
&lt;span class="s2"&gt;    phase:2,\&lt;/span&gt;
&lt;span class="s2"&gt;    block,\&lt;/span&gt;
&lt;span class="s2"&gt;    status:403,\&lt;/span&gt;
&lt;span class="s2"&gt;    log,\&lt;/span&gt;
&lt;span class="s2"&gt;    msg:'CVE-2026-109619: Blocked PHP_SESSION_UPLOAD_PROGRESS field',\&lt;/span&gt;
&lt;span class="s2"&gt;    chain"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;SecRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ARGS_POST_NAMES&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@contains PHP_SESSION_UPLOAD_PROGRESS"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;

&lt;span class="nb"&gt;SecRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;REQUEST_BODY&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@rx name=[\"']?PHP_SESSION_UPLOAD_PROGRESS[\"']?"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;\
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"id:1000102,\&lt;/span&gt;
&lt;span class="s2"&gt;    phase:2,\&lt;/span&gt;
&lt;span class="s2"&gt;    block,\&lt;/span&gt;
&lt;span class="s2"&gt;    status:403,\&lt;/span&gt;
&lt;span class="s2"&gt;    log,\&lt;/span&gt;
&lt;span class="s2"&gt;    msg:'CVE-2026-109619: Blocked raw multipart PHP_SESSION_UPLOAD_PROGRESS'"&lt;/span&gt;

&lt;span class="c"&gt;# Require Content-Length on multipart/form-data requests (CVE-2026-109618)&lt;/span&gt;
&lt;span class="nb"&gt;SecRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;REQUEST_HEADERS:Content-Type&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@rx (?i)multipart/form-data"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;\
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"id:1000103,\&lt;/span&gt;
&lt;span class="s2"&gt;    phase:1,\&lt;/span&gt;
&lt;span class="s2"&gt;    block,\&lt;/span&gt;
&lt;span class="s2"&gt;    status:411,\&lt;/span&gt;
&lt;span class="s2"&gt;    log,\&lt;/span&gt;
&lt;span class="s2"&gt;    msg:'CVE-2026-109618: Multipart request without Content-Length',\&lt;/span&gt;
&lt;span class="s2"&gt;    chain"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;SecRule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;amp;REQUEST_HEADERS:Content-Length&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@eq 0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h4 id="openresty-nginx-with-lua-nginx-module"&gt;OpenResty / NGINX with &lt;code&gt;lua-nginx-module&lt;/code&gt;&lt;/h4&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kn"&gt;access_by_lua_block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="kn"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ngx.req.get_headers()&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;content_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;headers["content-type"]&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;content_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;string.find(content_type:lower(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"multipart/form-data",&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;true)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;CVE-2026-109618:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Reject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;multipart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;requests&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;without&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;declared&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;headers["content-length"]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;ngx.log(ngx.ERR,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Blocked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;chunked/unbounded&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;multipart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;(CVE-2026-109618)")&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ngx.exit(ngx.HTTP_LENGTH_REQUIRED)&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;

&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;ngx.req.read_body()&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ngx.req.get_body_data()&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;body_file&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ngx.req.get_body_file()&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;body_file&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;io.open(body_file,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"rb")&lt;/span&gt;
&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                        &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;f:read(65536)&lt;/span&gt;
&lt;span class="w"&gt;                        &lt;/span&gt;&lt;span class="s"&gt;f:close()&lt;/span&gt;
&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;CVE-2026-109619:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Reject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;string.find(body,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"PHP_SESSION_UPLOAD_PROGRESS",&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;true)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;ngx.log(ngx.ERR,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Blocked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;(CVE-2026-109619)")&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="s"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ngx.exit(ngx.HTTP_FORBIDDEN)&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Standard proxy_pass or fastcgi_pass configuration below&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;hr /&gt;
&lt;h2 id="auditing-your-fleet-logs"&gt;Auditing Your Fleet &amp;amp; Logs&lt;/h2&gt;
&lt;p&gt;The PHP Security Team recommends running a targeted configuration and log audit across all PHP hosts:&lt;/p&gt;
&lt;h3 id="1-check-active-runtime-versions-vulnerable-ini-directives"&gt;1. Check Active Runtime Versions &amp;amp; Vulnerable INI Directives&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bin&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;which&lt;span class="w"&gt; &lt;/span&gt;php&lt;span class="w"&gt; &lt;/span&gt;php-fpm&lt;span class="w"&gt; &lt;/span&gt;php-fpm8.1&lt;span class="w"&gt; &lt;/span&gt;php-fpm8.2&lt;span class="w"&gt; &lt;/span&gt;php-fpm8.3&lt;span class="w"&gt; &lt;/span&gt;php-fpm8.4&lt;span class="w"&gt; &lt;/span&gt;php-fpm8.5&lt;span class="w"&gt; &lt;/span&gt;lsphp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sort&lt;span class="w"&gt; &lt;/span&gt;-u&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"=== Auditing: &lt;/span&gt;&lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-v&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;head&lt;span class="w"&gt; &lt;/span&gt;-n1&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;) ==="&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^(memory_limit|post_max_size|upload_max_filesize|max_file_uploads|max_input_nesting_level|fatal_error_backtraces|session\.upload_progress\.(enabled|name))"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="2-audit-startup-per-request-ini-overrides-for-early-diagnostic-consumers"&gt;2. Audit Startup &amp;amp; Per-Request INI Overrides for Early Diagnostic Consumers&lt;/h3&gt;
&lt;p&gt;Search your Apache, NGINX, &lt;code&gt;.htaccess&lt;/code&gt;, and FPM configs for deprecated or invalid INI settings that emit warnings before &lt;code&gt;php_request_startup()&lt;/code&gt; (which act as consumers for CVE-2026-109619):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-rnE&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"php_(admin_)?(value|flag)|PHP_(ADMIN_)?VALUE|auto_detect_line_endings|filter\.default|mbstring\.(http_|internal_)|iconv\."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;/etc/nginx&lt;span class="w"&gt; &lt;/span&gt;/etc/apache2&lt;span class="w"&gt; &lt;/span&gt;/etc/httpd&lt;span class="w"&gt; &lt;/span&gt;/etc/php*&lt;span class="w"&gt; &lt;/span&gt;/var/www&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="3-inspect-logs-for-producer-triggers"&gt;3. Inspect Logs for Producer Triggers&lt;/h3&gt;
&lt;p&gt;Enable &lt;code&gt;log_errors=On&lt;/code&gt; and &lt;code&gt;error_reporting=E_ALL&lt;/code&gt; in your startup &lt;code&gt;php.ini&lt;/code&gt; and check your PHP-FPM and Apache error logs for evidence of producer interruptions or early diagnostics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Allowed memory size of ... bytes exhausted&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Maximum execution time of ... seconds exceeded&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Warnings or deprecations logged &lt;em&gt;without&lt;/em&gt; a script filename or at &lt;code&gt;Unknown on line 0&lt;/code&gt; (indicating pre-initialization or post-deactivation diagnostics)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2 id="official-patched-versions-upgrade-matrix"&gt;Official Patched Versions &amp;amp; Upgrade Matrix&lt;/h2&gt;
&lt;p&gt;As soon as your change window or build pipeline allows, upgrade to the patched releases published on &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5"&gt;21 October 2026&lt;/a&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;PHP Branch&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Affected Versions&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Patched Release&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Status / Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.5&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;&amp;lt; 8.5.12&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;8.5.12&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Active support — &lt;a href="https://www.php.net/downloads.php"&gt;Download&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.4&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;&amp;lt; 8.4.27&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;8.4.27&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Active support — &lt;a href="https://www.php.net/downloads.php"&gt;Download&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.3&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;&amp;lt; 8.3.36&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;8.3.36&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Security support — &lt;a href="https://www.php.net/downloads.php"&gt;Download&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.2&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;&amp;lt; 8.2.35&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;8.2.35&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Security support (EOL on &lt;strong&gt;31 Dec 2026&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.1 (EOL)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;All &lt;code&gt;8.1.x&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;a href="https://github.com/php/php-src/tree/PHP-8.1-CVE-2026-109618-109619"&gt;&lt;code&gt;PHP-8.1-CVE-2026-109618-109619&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Official minimal source backport branch or LTS distro vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 8.0 (EOL)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;All &lt;code&gt;8.0.x&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;a href="https://github.com/php/php-src/tree/PHP-8.0-CVE-2026-109618-109619"&gt;&lt;code&gt;PHP-8.0-CVE-2026-109618-109619&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Official minimal source backport branch or LTS distro vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;PHP 7.4 (EOL)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;All &lt;code&gt;7.4.x&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;a href="https://github.com/php/php-src/tree/PHP-7.4-CVE-2026-109618-109619"&gt;&lt;code&gt;PHP-7.4-CVE-2026-109618-109619&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Official minimal source backport branch or LTS distro vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</content>
  </entry>
  <entry>
    <title>Bridging a Cloud Condo Intercom to Home Assistant with Rust, SIP, and MQTT</title>
    <link href="https://dj.vg/posts/bridging-condo-intercom-to-home-assistant-rust-sip-mqtt/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/bridging-condo-intercom-to-home-assistant-rust-sip-mqtt/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/bridging-condo-intercom-to-home-assistant-rust-sip-mqtt/</id>
    <published>2026-10-17T00:00:00Z</published>
    <updated>2026-10-17T00:00:00Z</updated>
    <summary>How I replaced a sluggish property-management app with a self-contained Rust daemon that speaks Firebase Cloud Messaging, SIP/TLS, H.264 RTP, and Home Assistant MQTT Auto-Discovery.</summary>
    <content type="html">&lt;p&gt;In a well-tuned smart home, almost every physical interaction is programmable. Lighting scenes adapt to the time of day, climate control reacts to occupancy, and alerts route cleanly through a single dashboard in &lt;a href="https://www.home-assistant.io/"&gt;Home Assistant&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Yet in many modern condominiums in Singapore, one piece of hardware stubbornly resists integration: &lt;strong&gt;the building intercom&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of exposing a standard local LAN doorphone inside the apartment, many residential developments route their lobby call panels through cloud-managed property management apps—such as &lt;strong&gt;iPlus Living&lt;/strong&gt; backed by a &lt;strong&gt;Fermax MeetMe&lt;/strong&gt; SIP infrastructure. When a visitor or delivery driver dials your unit from the lobby or side gate, your phone receives a push notification from a proprietary app, you wait for the app to cold-start and negotiate a video stream, and you tap an icon to buzz the gate open.&lt;/p&gt;
&lt;p&gt;From a home automation perspective, this is a dead end:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Siloed notifications&lt;/strong&gt;: You cannot route doorbell events or camera snapshots into Home Assistant notifications, wall tablets, or TVs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No programmable access&lt;/strong&gt;: You cannot trigger an unlock from a Home Assistant dashboard, an NFC tag, or a temporary "delivery window" automation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-platform lock-in&lt;/strong&gt;: Everything depends on a mobile app staying logged in and surviving aggressive mobile OS background battery killers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I wanted the condo intercom to behave like any other native device in my cluster. So I built &lt;strong&gt;&lt;code&gt;iplusliving2mqtt&lt;/code&gt;&lt;/strong&gt;—a self-contained Rust daemon running in Kubernetes that bridges the entire intercom lifecycle into Home Assistant via MQTT Auto-Discovery.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="why-standard-sip-clients-dont-work"&gt;Why Standard SIP Clients Don't Work&lt;/h2&gt;
&lt;p&gt;At first glance, a SIP-based intercom sounds trivial: grab the SIP credentials, point &lt;code&gt;baresip&lt;/code&gt;, Asterisk, or Home Assistant's VoIP integration at the server, and wait for an incoming &lt;code&gt;INVITE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In practice, inspecting the protocol flow revealed three layers that prevent a standard SIP client from ever receiving a call:&lt;/p&gt;
&lt;h3 id="1-split-key-encrypted-provisioning-api"&gt;1. Split-Key Encrypted Provisioning API&lt;/h3&gt;
&lt;p&gt;Rather than giving residents static SIP credentials, the iPlus Living mobile app dynamically provisions them after login. Every HTTP request and response body is encrypted with &lt;strong&gt;AES-256-CBC&lt;/strong&gt; using a fixed initialization vector, while the 32-byte AES key is hidden inside a 42-byte random alphanumeric prefix prepended to the base64 ciphertext (&lt;code&gt;payload[:16] + payload[22:38]&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Authenticating through the four-step login sequence (&lt;code&gt;user/login&lt;/code&gt; → &lt;code&gt;user/logindetail&lt;/code&gt; → &lt;code&gt;intercom/meetmelicencedetails&lt;/code&gt; → &lt;code&gt;intercom/intercomconfiguration&lt;/code&gt;) returns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloud MeetMe SIP credentials&lt;/strong&gt; (&lt;code&gt;sip.fermax.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Building PBX SIP credentials&lt;/strong&gt; (the development's Grandstream server)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The call panel directory&lt;/strong&gt; (mapping numeric SIP caller IDs like &lt;code&gt;14&lt;/code&gt; or &lt;code&gt;22&lt;/code&gt; to human-readable gate and lobby names)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-the-push-notification-catch-22-rfc-8599"&gt;2. The Push Notification Catch-22 (RFC 8599)&lt;/h3&gt;
&lt;p&gt;Even with valid SIP credentials and an active TLS connection to Fermax's &lt;code&gt;Flexisip&lt;/code&gt; proxy (&lt;code&gt;_sips._tcp.sip.fermax.com&lt;/code&gt;), a standard SIP client will sit idle forever when someone presses the doorbell.&lt;/p&gt;
&lt;p&gt;Why? Because the &lt;code&gt;Flexisip&lt;/code&gt; server is configured for mobile push notifications under &lt;strong&gt;RFC 8599&lt;/strong&gt; with &lt;code&gt;pn-timeout=0&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Contact: &amp;lt;sip:USER@IP:PORT;pn-prid=FCM_TOKEN;pn-provider=fcm;pn-param=SENDER_ID;pn-silent=1;pn-timeout=0;transport=tls&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;With &lt;code&gt;pn-timeout=0&lt;/code&gt;, the SIP proxy assumes mobile clients go to sleep immediately after registering. When the lobby panel places a call:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Flexisip&lt;/code&gt; &lt;strong&gt;pauses&lt;/strong&gt; the incoming &lt;code&gt;INVITE&lt;/code&gt; transaction.&lt;/li&gt;
&lt;li&gt;It dispatches a high-priority data push (&lt;code&gt;title: "IC_CALL"&lt;/code&gt;) through &lt;strong&gt;Google Firebase Cloud Messaging (FCM)&lt;/strong&gt; to the &lt;code&gt;pn-prid&lt;/code&gt; token registered in the &lt;code&gt;Contact&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;It waits for the client to wake up and send a fresh SIP &lt;code&gt;REGISTER&lt;/code&gt; over TLS.&lt;/li&gt;
&lt;li&gt;Only after receiving that &lt;code&gt;REGISTER&lt;/code&gt; does &lt;code&gt;Flexisip&lt;/code&gt; release the held &lt;code&gt;INVITE&lt;/code&gt; to the client.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Without a real FCM token and a persistent listener on Google's Mobile Connection Server (&lt;code&gt;mtalk.google.com:5228&lt;/code&gt;), the SIP server never forwards the call.&lt;/p&gt;
&lt;h3 id="3-early-media-h264-video-dual-mode-dtmf"&gt;3. Early-Media H.264 Video &amp;amp; Dual-Mode DTMF&lt;/h3&gt;
&lt;p&gt;Once the &lt;code&gt;INVITE&lt;/code&gt; arrives, the lobby panel streams H.264 video during &lt;strong&gt;early media&lt;/strong&gt; (&lt;code&gt;183 Session Progress&lt;/code&gt;) before the call is answered. However, the door relay controller ignores unlock commands during early media—to actually buzz the gate open, the client must transition the call to answered (&lt;code&gt;200 OK&lt;/code&gt; with SDP), wait for the &lt;code&gt;ACK&lt;/code&gt;, and transmit DTMF unlock tones before hanging up (&lt;code&gt;BYE&lt;/code&gt;).&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="system-architecture"&gt;System Architecture&lt;/h2&gt;
&lt;p&gt;To make this rock-solid in Kubernetes without relying on external headless Android emulators, &lt;code&gt;ffmpeg&lt;/code&gt; subprocesses, or Python scripts, I built &lt;code&gt;iplusliving2mqtt&lt;/code&gt; as a single static &lt;strong&gt;Rust&lt;/strong&gt; binary:&lt;/p&gt;
&lt;figure class="arch-diagram" role="group" aria-label="iplusliving2mqtt System Architecture"&gt;
  &lt;svg viewBox="0 0 680 584" width="100%" height="auto" role="img" aria-labelledby="iplus-arch-title iplus-arch-desc" xmlns="http://www.w3.org/2000/svg"&gt;
    &lt;title id="iplus-arch-title"&gt;iplusliving2mqtt System Architecture&lt;/title&gt;
    &lt;desc id="iplus-arch-desc"&gt;Architecture diagram showing iPlus Living API and Google FCM/MCS connecting into the iplusliving2mqtt Rust daemon (Provisioning State, Native FCM Listener, Dual-Account SIP/TLS Engine, OpenH264 Decoder and JPEG Encoder, and MQTT Auto-Discovery and Command Bridge) and publishing to Home Assistant.&lt;/desc&gt;
    &lt;defs&gt;
      &lt;marker id="arch-arrow-iplus" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"&gt;
        &lt;path d="M 1 1.5 L 8.5 5 L 1 8.5 z" class="arch-arrow-head" /&gt;
      &lt;/marker&gt;
    &lt;/defs&gt;

    &lt;!-- Upstream External Services --&gt;
    &lt;rect x="44" y="16" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="164" y="42" text-anchor="middle" class="arch-node-title"&gt;iPlus Living API&lt;/text&gt;
    &lt;text x="164" y="61" text-anchor="middle" class="arch-node-sub"&gt;(AES-256-CBC Provision)&lt;/text&gt;

    &lt;rect x="396" y="16" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="516" y="42" text-anchor="middle" class="arch-node-title"&gt;Google FCM / MCS&lt;/text&gt;
    &lt;text x="516" y="61" text-anchor="middle" class="arch-node-sub"&gt;(mtalk.google.com:5228)&lt;/text&gt;

    &lt;!-- Upstream Connectors --&gt;
    &lt;line x1="164" y1="78" x2="164" y2="126" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="176" y="107" class="arch-edge-label"&gt;1. Fetch SIP + Panels&lt;/text&gt;

    &lt;line x1="516" y1="78" x2="516" y2="126" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="528" y="107" class="arch-edge-label"&gt;2. Push "IC_CALL"&lt;/text&gt;

    &lt;!-- iplusliving2mqtt Daemon Boundary --&gt;
    &lt;rect x="20" y="132" width="640" height="336" rx="6" class="arch-group" /&gt;
    &lt;text x="340" y="157" text-anchor="middle" class="arch-group-title"&gt;iplusliving2mqtt&lt;/text&gt;

    &lt;!-- Internal Row 1 --&gt;
    &lt;rect x="44" y="172" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="164" y="198" text-anchor="middle" class="arch-node-title"&gt;Provisioning State&lt;/text&gt;
    &lt;text x="164" y="217" text-anchor="middle" class="arch-node-sub"&gt;(state.json)&lt;/text&gt;

    &lt;rect x="396" y="172" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="516" y="198" text-anchor="middle" class="arch-node-title"&gt;Native FCM Listener&lt;/text&gt;
    &lt;text x="516" y="217" text-anchor="middle" class="arch-node-sub"&gt;(Protobuf over TLS)&lt;/text&gt;

    &lt;!-- Row 1 to Row 2 Connectors --&gt;
    &lt;line x1="164" y1="234" x2="164" y2="272" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;line x1="516" y1="234" x2="516" y2="272" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="528" y="258" class="arch-edge-label"&gt;Wake Signal&lt;/text&gt;

    &lt;!-- Internal Row 2: SIP/TLS Engine --&gt;
    &lt;rect x="44" y="278" width="592" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="340" y="304" text-anchor="middle" class="arch-node-title"&gt;Dual-Account SIP/TLS Engine&lt;/text&gt;
    &lt;text x="340" y="323" text-anchor="middle" class="arch-node-sub"&gt;(MeetMe Cloud + Building Grandstream)&lt;/text&gt;

    &lt;!-- Row 2 to Row 3 Connectors --&gt;
    &lt;line x1="164" y1="340" x2="164" y2="378" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="176" y="364" class="arch-edge-label"&gt;RTP H.264 + RTCP PLI&lt;/text&gt;

    &lt;line x1="516" y1="340" x2="516" y2="378" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="528" y="364" class="arch-edge-label"&gt;SIP Events&lt;/text&gt;

    &lt;!-- Internal Row 3: Media &amp; MQTT --&gt;
    &lt;rect x="44" y="384" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="164" y="410" text-anchor="middle" class="arch-node-title"&gt;OpenH264 Decoder&lt;/text&gt;
    &lt;text x="164" y="429" text-anchor="middle" class="arch-node-sub"&gt;&amp;amp; JPEG Encoder&lt;/text&gt;

    &lt;line x1="284" y1="415" x2="390" y2="415" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;

    &lt;rect x="396" y="384" width="240" height="62" rx="4" class="arch-subnode" /&gt;
    &lt;text x="516" y="410" text-anchor="middle" class="arch-node-title"&gt;MQTT Auto-Discovery&lt;/text&gt;
    &lt;text x="516" y="429" text-anchor="middle" class="arch-node-sub"&gt;&amp;amp; Command Bridge&lt;/text&gt;

    &lt;!-- Downstream MQTT Connector --&gt;
    &lt;line x1="516" y1="446" x2="516" y2="498" class="arch-edge" marker-end="url(#arch-arrow-iplus)" /&gt;
    &lt;text x="528" y="488" class="arch-edge-label"&gt;MQTT (homeassistant/#)&lt;/text&gt;

    &lt;!-- Home Assistant Target --&gt;
    &lt;rect x="396" y="504" width="240" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="516" y="530" text-anchor="middle" class="arch-node-title"&gt;Home Assistant&lt;/text&gt;
    &lt;text x="516" y="549" text-anchor="middle" class="arch-node-sub"&gt;(Camera, Lock, Ring)&lt;/text&gt;
  &lt;/svg&gt;
&lt;/figure&gt;

&lt;h3 id="zero-touch-provisioning-fcm-emulation"&gt;Zero-Touch Provisioning &amp;amp; FCM Emulation&lt;/h3&gt;
&lt;p&gt;On startup, if no cached state exists on its persistent volume, &lt;code&gt;iplusliving2mqtt&lt;/code&gt; performs a two-stage bootstrap:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;FCM Registration&lt;/strong&gt;: Emulates an Android check-in against &lt;code&gt;android.clients.google.com/checkin&lt;/code&gt; and registers with Google Cloud Messaging (&lt;code&gt;/c2dm/register3&lt;/code&gt;) using the official app's Firebase project parameters to obtain a valid &lt;code&gt;fcm_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iPlus Auto-Provisioning&lt;/strong&gt;: Logs into the iPlus Living API using the resident's email and password, binds the &lt;code&gt;fcm_token&lt;/code&gt;, and caches the resulting SIP accounts and call panel directory to &lt;code&gt;state.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCS Stream&lt;/strong&gt;: Opens a persistent TLS connection to &lt;code&gt;mtalk.google.com:5228&lt;/code&gt; speaking Google's binary MCS framing + Protobuf protocol. The moment an &lt;code&gt;IC_CALL&lt;/code&gt; data message arrives, it signals the SIP worker tasks over an internal &lt;code&gt;tokio::sync::mpsc&lt;/code&gt; channel to fire an immediate SIP &lt;code&gt;REGISTER&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="in-memory-h264-keyframe-extraction"&gt;In-Memory H.264 Keyframe Extraction&lt;/h3&gt;
&lt;p&gt;Lobby panels stream H.264 video encapsulated in RTP packets (RFC 6184), including fragmented NAL units (&lt;code&gt;FU-A&lt;/code&gt;, type 28) and aggregation packets (&lt;code&gt;STAP-A&lt;/code&gt;, type 24).&lt;/p&gt;
&lt;p&gt;Rather than spawning &lt;code&gt;ffmpeg&lt;/code&gt; on every doorbell ring, &lt;code&gt;iplusliving2mqtt&lt;/code&gt; handles the media stream directly in memory:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sends UDP NAT-punching packets plus an &lt;strong&gt;RTCP Picture Loss Indication (PLI)&lt;/strong&gt; and a &lt;strong&gt;SIP &lt;code&gt;INFO&lt;/code&gt; (&lt;code&gt;picture_fast_update&lt;/code&gt;)&lt;/strong&gt; request immediately upon answering with &lt;code&gt;183 Session Progress&lt;/code&gt; so the panel transmits an IDR keyframe right away.&lt;/li&gt;
&lt;li&gt;Reassembles fragmented H.264 NAL units (&lt;code&gt;SPS&lt;/code&gt;, &lt;code&gt;PPS&lt;/code&gt;, and &lt;code&gt;IDR&lt;/code&gt; slices) and feeds the Annex-B bitstream into Cisco's &lt;strong&gt;&lt;code&gt;openh264&lt;/code&gt;&lt;/strong&gt; decoder in memory.&lt;/li&gt;
&lt;li&gt;Converts the decoded YUV420p frame to RGB and encodes a JPEG snapshot directly onto the MQTT camera topic (&lt;code&gt;fermax/intercom/snapshot/image&lt;/code&gt;) and an HTTP &lt;code&gt;/snapshot.jpg&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="dual-mode-dtmf-unlock"&gt;Dual-Mode DTMF Unlock&lt;/h3&gt;
&lt;p&gt;Different intercom relays along the call path can be picky about how DTMF digits are delivered. When you press the Unlock button in Home Assistant (or when auto-unlock is enabled), the daemon:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Answers the pending &lt;code&gt;INVITE&lt;/code&gt; with &lt;code&gt;200 OK&lt;/code&gt; (if still in &lt;code&gt;183 Session Progress&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Transmits &lt;strong&gt;RFC 2833 (&lt;code&gt;telephone-event/8000&lt;/code&gt;)&lt;/strong&gt; RTP payload packets with the end-of-event marker bit and triple redundancy.&lt;/li&gt;
&lt;li&gt;Follows up with synthesized &lt;strong&gt;in-band G.711 µ-law (PCMU)&lt;/strong&gt; dual-tone sine waves (&lt;code&gt;697–941 Hz&lt;/code&gt; + &lt;code&gt;1209–1477 Hz&lt;/code&gt;) at 8,000 Hz so both digital SIP gateways and analog door strike controllers reliably trigger.&lt;/li&gt;
&lt;li&gt;Sends a clean SIP &lt;code&gt;BYE&lt;/code&gt; to release the lobby panel.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2 id="a-fun-dual-stack-debugging-quirk-ipv6-vs-ipv4-in-sip"&gt;A Fun Dual-Stack Debugging Quirk: IPv6 vs. IPv4 in SIP&lt;/h2&gt;
&lt;p&gt;When testing the daemon on a dual-stack host with native IPv6 connectivity, the initial SIP TLS connection to &lt;code&gt;sip5.fermax.com:5223&lt;/code&gt; completed its TLS handshake and then immediately dropped with an &lt;code&gt;EOF&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Digging into the packet trace uncovered two separate issues:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;RFC 3261 IPv6 Formatting&lt;/strong&gt;: &lt;code&gt;sip5.fermax.com&lt;/code&gt; publishes both &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;AAAA&lt;/code&gt; records. Because the host preferred IPv6, &lt;code&gt;tcp.local_addr()&lt;/code&gt; returned an IPv6 address. Formatting &lt;code&gt;Via&lt;/code&gt; and &lt;code&gt;Contact&lt;/code&gt; headers as &lt;code&gt;ip:port&lt;/code&gt; without enclosing the IPv6 literal in square brackets (&lt;code&gt;[2a09:...]:5060&lt;/code&gt;) caused &lt;code&gt;Flexisip&lt;/code&gt;'s parser to reject the &lt;code&gt;REGISTER&lt;/code&gt; packet immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IPv4-Only Media Relays&lt;/strong&gt;: Even with bracketed IPv6 headers, the building's local PBX and the RTP audio/video media relays (&lt;code&gt;c=IN IP4 ...&lt;/code&gt; in SDP) are strictly IPv4. Because the SIP engine learns its external NAT address from the SIP server's &lt;code&gt;Via: ...;received=&amp;lt;ip&amp;gt;;rport=&amp;lt;port&amp;gt;&lt;/code&gt; response header to advertise in the SDP answer, registering over IPv6 leaves the media engine with an IPv6 address that an IPv4 RTP relay can never reach.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Explicitly resolving and selecting IPv4 socket addresses for the SIP/RTP path immediately brought both the &lt;code&gt;MeetMe&lt;/code&gt; and &lt;code&gt;Grandstream&lt;/code&gt; registrations to &lt;code&gt;200 OK&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="home-assistant-integration"&gt;Home Assistant Integration&lt;/h2&gt;
&lt;p&gt;Because &lt;code&gt;iplusliving2mqtt&lt;/code&gt; publishes Home Assistant MQTT Auto-Discovery payloads under &lt;code&gt;homeassistant/#&lt;/code&gt;, zero manual YAML configuration is required in Home Assistant. All entities automatically group under a single &lt;strong&gt;Fermax MeetMe Intercom&lt;/strong&gt; device:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Entity&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Type&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Doorbell Ringing&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;binary_sensor&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Turns &lt;code&gt;ON&lt;/code&gt; the instant an incoming &lt;code&gt;INVITE&lt;/code&gt; arrives from any gate or lobby panel.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Call State&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;sensor&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Tracks the call lifecycle: &lt;code&gt;idle&lt;/code&gt; → &lt;code&gt;ringing&lt;/code&gt; → &lt;code&gt;answered&lt;/code&gt; → &lt;code&gt;unlocked&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Last Caller&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;sensor&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Displays the resolved lobby or gate name (e.g., &lt;em&gt;Side Gate&lt;/em&gt; or &lt;em&gt;Block Lobby&lt;/em&gt;) plus JSON attributes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Intercom Camera&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;camera&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Live JPEG snapshot decoded from the panel's H.264 RTP video stream.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Unlock Door&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;button&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Immediately answers the active call and sends the DTMF unlock sequence.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Door Lock&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;lock&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Reflects &lt;code&gt;LOCKED&lt;/code&gt; / &lt;code&gt;UNLOCKED&lt;/code&gt; state and supports standard Home Assistant lock actions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Auto-Unlock on Ring&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;code&gt;switch&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Optional toggle that automatically answers and buzzes the door open after a configurable delay.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With everything in Home Assistant, a doorbell ring now triggers an instant push notification to our phones with the live decoded camera snapshot attached and an inline &lt;strong&gt;"Unlock Gate"&lt;/strong&gt; action button—plus a temporary "expecting a delivery" toggle that auto-unlocks the lobby gate for the next ring and then turns itself off.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="kubernetes-deployment"&gt;Kubernetes Deployment&lt;/h2&gt;
&lt;p&gt;True to the rest of my home infrastructure, &lt;code&gt;iplusliving2mqtt&lt;/code&gt; is packaged as a minimal multi-stage container (&lt;code&gt;gcr.io/distroless/cc-debian13:nonroot&lt;/code&gt;, running as UID &lt;code&gt;65532&lt;/code&gt; with a read-only root filesystem and all Linux capabilities dropped) and deployed via a Helm chart with &lt;code&gt;/healthz&lt;/code&gt; and &lt;code&gt;/readyz&lt;/code&gt; probes that verify both MQTT broker connectivity and active SIP registration state.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Fixing Entra ID / Azure AD AADSTS75011 on Ceph Dashboard SAML</title>
    <link href="https://dj.vg/posts/fixing-ceph-dashboard-saml-aadsts75011/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/fixing-ceph-dashboard-saml-aadsts75011/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/fixing-ceph-dashboard-saml-aadsts75011/</id>
    <published>2026-10-17T00:00:00Z</published>
    <updated>2026-10-17T00:00:00Z</updated>
    <summary>How to resolve Microsoft Entra ID error AADSTS75011 when authenticating to the Ceph Dashboard with FIDO2 security keys or MFA by disabling requestedAuthnContext in the Ceph monitor store.</summary>
    <content type="html">&lt;p&gt;While configuring SAML 2.0 Single Sign-On (SSO) for the &lt;a href="https://ceph.io/"&gt;Ceph&lt;/a&gt; Dashboard against Microsoft Entra ID (formerly Azure AD), standard password authentication works out of the box. However, the moment you authenticate with modern, phishing-resistant credentials—such as FIDO2 security keys, Windows Hello, or passwordless MFA—Entra ID rejects the login with &lt;code&gt;AADSTS75011&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;AADSTS75011: Authentication method 'MultiFactor, Fido' by which the user 
authenticated with the service doesn't match requested authentication method 
'Password, ProtectedTransport'. Contact the Ceph Dashboard application owner.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;At first glance, the error seems backwards: authenticating with a FIDO2 hardware key and MFA is strictly more secure than a password, yet the Identity Provider blocks the session.&lt;/p&gt;
&lt;p&gt;Here is why this happens in Ceph and how to fix it cleanly in the Ceph monitor configuration store without patching Python files inside the &lt;code&gt;ceph-mgr&lt;/code&gt; container.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="why-it-happens-python-saml-and-requestedauthncontext"&gt;Why It Happens: &lt;code&gt;python-saml&lt;/code&gt; and &lt;code&gt;RequestedAuthnContext&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Under the hood, the Ceph Dashboard relies on OneLogin's &lt;a href="https://github.com/SAML-Toolkits/python-saml"&gt;&lt;code&gt;python-saml&lt;/code&gt;&lt;/a&gt; library to act as a SAML Service Provider.&lt;/p&gt;
&lt;p&gt;By default, &lt;code&gt;python-saml&lt;/code&gt; initializes its security configuration with:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="s1"&gt;'requestedAuthnContext'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Unless overridden, every outbound &lt;code&gt;AuthnRequest&lt;/code&gt; generated by the Ceph Dashboard includes an explicit &lt;code&gt;&amp;lt;samlp:RequestedAuthnContext&amp;gt;&lt;/code&gt; block demanding &lt;code&gt;PasswordProtectedTransport&lt;/code&gt; with &lt;code&gt;Comparison="exact"&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;samlp:RequestedAuthnContext&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;Comparison=&lt;/span&gt;&lt;span class="s"&gt;"exact"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;saml:AuthnContextClassRef&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/saml:AuthnContextClassRef&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/samlp:RequestedAuthnContext&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Because &lt;code&gt;Comparison="exact"&lt;/code&gt; is enforced, Entra ID checks the exact authentication method used to satisfy the session. When a user signs in with a passkey/FIDO2 token or MFA, Entra ID records the session context as &lt;code&gt;MultiFactor, Fido&lt;/code&gt;. Since that does not literally match &lt;code&gt;Password, ProtectedTransport&lt;/code&gt;, Entra ID aborts the SAML flow with &lt;code&gt;AADSTS75011&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To allow FIDO2, passkeys, and MFA, Ceph needs to omit &lt;code&gt;&amp;lt;samlp:RequestedAuthnContext&amp;gt;&lt;/code&gt; from its &lt;code&gt;AuthnRequest&lt;/code&gt; and let Entra ID's Conditional Access policies govern acceptable authentication methods.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="the-fix-updating-ssodb_v1-in-the-monitor-store"&gt;The Fix: Updating &lt;code&gt;ssodb_v1&lt;/code&gt; in the Monitor Store&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;ceph dashboard sso setup saml2&lt;/code&gt; CLI command does not expose a flag to toggle &lt;code&gt;requestedAuthnContext&lt;/code&gt;, and inspecting &lt;code&gt;ceph dashboard sso show saml2&lt;/code&gt; will show it enabled by default:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;ceph&lt;span class="w"&gt; &lt;/span&gt;dashboard&lt;span class="w"&gt; &lt;/span&gt;sso&lt;span class="w"&gt; &lt;/span&gt;show&lt;span class="w"&gt; &lt;/span&gt;saml2&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'.onelogin_settings.security.requestedAuthnContext'&lt;/span&gt;
&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Instead of modifying &lt;code&gt;sso.py&lt;/code&gt; on the container filesystem—which would be wiped on the next container restart or Ceph upgrade—we can patch the serialized SSO database (&lt;code&gt;mgr/dashboard/ssodb_v1&lt;/code&gt;) directly in the Ceph monitor &lt;code&gt;config-key&lt;/code&gt; store.&lt;/p&gt;
&lt;h3 id="1-export-the-current-sso-configuration"&gt;1. Export the Current SSO Configuration&lt;/h3&gt;
&lt;p&gt;Dump the raw JSON configuration from the monitor store:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ceph&lt;span class="w"&gt; &lt;/span&gt;config-key&lt;span class="w"&gt; &lt;/span&gt;get&lt;span class="w"&gt; &lt;/span&gt;mgr/dashboard/ssodb_v1&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;ssodb.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="2-disable-requestedauthncontext-under-saml2"&gt;2. Disable &lt;code&gt;requestedAuthnContext&lt;/code&gt; Under &lt;code&gt;.saml2&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Ceph stores SAML 2.0 settings under &lt;code&gt;.saml2.onelogin_settings&lt;/code&gt;. Use &lt;code&gt;jq&lt;/code&gt; to set both &lt;code&gt;requestedAuthnContext&lt;/code&gt; and &lt;code&gt;failOnAuthnContextMismatch&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; inside &lt;code&gt;.saml2.onelogin_settings.security&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;jq&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'.saml2.onelogin_settings.security += {&lt;/span&gt;
&lt;span class="s1"&gt;  "requestedAuthnContext": false,&lt;/span&gt;
&lt;span class="s1"&gt;  "failOnAuthnContextMismatch": false&lt;/span&gt;
&lt;span class="s1"&gt;}'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssodb.json&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;ssodb_updated.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Verify the updated security block before applying it:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;jq&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'.saml2.onelogin_settings.security'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssodb_updated.json
&lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"nameIdEncrypted"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"authnRequestsSigned"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"logoutRequestSigned"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"logoutResponseSigned"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"signMetadata"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"wantMessagesSigned"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"wantAssertionsSigned"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"wantAssertionsEncrypted"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"wantNameIdEncrypted"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"metadataValidUntil"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"wantAttributeStatement"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"requestedAuthnContext"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;false,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"failOnAuthnContextMismatch"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="3-write-the-configuration-back-and-reload-the-dashboard"&gt;3. Write the Configuration Back and Reload the Dashboard&lt;/h3&gt;
&lt;p&gt;Write the updated JSON back to &lt;code&gt;mgr/dashboard/ssodb_v1&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ceph&lt;span class="w"&gt; &lt;/span&gt;config-key&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mgr/dashboard/ssodb_v1&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;ssodb_updated.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Reload the &lt;code&gt;dashboard&lt;/code&gt; manager module so &lt;code&gt;ceph-mgr&lt;/code&gt; picks up the new settings from the monitor store:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ceph&lt;span class="w"&gt; &lt;/span&gt;mgr&lt;span class="w"&gt; &lt;/span&gt;module&lt;span class="w"&gt; &lt;/span&gt;disable&lt;span class="w"&gt; &lt;/span&gt;dashboard
ceph&lt;span class="w"&gt; &lt;/span&gt;mgr&lt;span class="w"&gt; &lt;/span&gt;module&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dashboard
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Confirm that the active SAML configuration now reflects &lt;code&gt;false&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;ceph&lt;span class="w"&gt; &lt;/span&gt;dashboard&lt;span class="w"&gt; &lt;/span&gt;sso&lt;span class="w"&gt; &lt;/span&gt;show&lt;span class="w"&gt; &lt;/span&gt;saml2&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'.onelogin_settings.security.requestedAuthnContext'&lt;/span&gt;
&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;hr /&gt;
&lt;h2 id="persistence-and-operational-notes"&gt;Persistence and Operational Notes&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;requestedAuthnContext&lt;/code&gt; disabled, SP-initiated logins to the Ceph Dashboard omit the &lt;code&gt;&amp;lt;samlp:RequestedAuthnContext&amp;gt;&lt;/code&gt; constraint, allowing Entra ID to complete authentication using FIDO2 security keys, Windows Hello, or MFA without triggering &lt;code&gt;AADSTS75011&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Because the configuration lives in the Ceph monitor database (&lt;code&gt;config-key&lt;/code&gt;), it persists across &lt;code&gt;ceph-mgr&lt;/code&gt; failovers, container restarts, and node reboots. The only caveat to keep in mind is that running &lt;code&gt;ceph dashboard sso setup saml2&lt;/code&gt; in the future (for instance, when rotating IdP signing certificates) will regenerate the &lt;code&gt;.saml2&lt;/code&gt; dictionary and reset &lt;code&gt;requestedAuthnContext&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;—so keep this &lt;code&gt;jq&lt;/code&gt; patch in your cluster runbook or configuration management whenever SAML metadata is re-imported.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Why I Built BCRSTracking: Telemetry for Singapore's Bottle Return Scheme</title>
    <link href="https://dj.vg/posts/why-i-built-bcrs-tracking/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/why-i-built-bcrs-tracking/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/why-i-built-bcrs-tracking/</id>
    <published>2026-09-12T00:00:00Z</published>
    <updated>2026-09-12T00:00:00Z</updated>
    <summary>Tired of walking to full reverse vending machines with bags of recyclables, I built a Go and ClickHouse service to monitor Singapore's nationwide container return scheme.</summary>
    <content type="html">&lt;p&gt;Few things in modern municipal living are as quietly frustrating as carrying two giant tote bags of rinsed plastic bottles and aluminum cans through Singapore's midday heat to your neighborhood supermarket, only to be greeted by a glowing red screen:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Machine Status: Bin Full. Please try again later.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Singapore’s Beverage Container Return Scheme (BCRS) is a fantastic sustainability initiative. Across the island, Reverse Vending Machines (RVMs) accept beverage containers and return deposits. But like any physical infrastructure distributed across hundreds of residential hubs, MRT stations, and supermarkets, real-world machines fill up, experience mechanical jams, or undergo routine maintenance.&lt;/p&gt;
&lt;p&gt;As a Site Reliability Engineer, I don't like guessing whether a distributed system is healthy before interacting with it. If a network node has a status, it should have telemetry. &lt;/p&gt;
&lt;p&gt;So I built &lt;a href="https://l.dj.vg/bcrs"&gt;&lt;strong&gt;BCRSTracking&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="the-upstream-api"&gt;The Upstream API&lt;/h2&gt;
&lt;p&gt;When inspecting how the official tracking system works (&lt;code&gt;bts.bcrs.sg&lt;/code&gt;), it turns out the platform exposes public API endpoints under &lt;code&gt;/forapi/v2/&lt;/code&gt;. These endpoints provide:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Location Listings&lt;/strong&gt; (&lt;code&gt;/locations&lt;/code&gt;): A directory of all deployed RVM stations across the island, including operating hours, physical coordinates, and machine identifiers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bin Status &amp;amp; Telemetry&lt;/strong&gt; (&lt;code&gt;/bin-status&lt;/code&gt;): Real-time machine operational status, current bin capacity percentage, and hardware availability.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;While the data was publicly accessible, there was no historical trend tracking, no differential event stream, and no way to query capacity curves over time to see when specific machines are emptied or filled.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id="engineering-requirements"&gt;Engineering Requirements&lt;/h2&gt;
&lt;p&gt;When architecting the tracker, I wanted it to be lightweight, resilient, and polite:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Polite Upstream Concurrency&lt;/strong&gt;: Never overwhelm public services. Polling must use strict token-bucket rate limiting (&lt;code&gt;golang.org/x/time/rate&lt;/code&gt;), bounded worker pools, and exponential backoff with jitter on HTTP 429 or 5xx responses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Differential State Engine&lt;/strong&gt;: Instead of blindly dumping snapshots, compute state transitions in memory (&lt;code&gt;MACHINE_ADDED&lt;/code&gt;, &lt;code&gt;STATUS_CHANGED&lt;/code&gt;, &lt;code&gt;CAPACITY_THRESHOLD_CROSSED&lt;/code&gt;) so downstream systems can alert on state changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast Columnar Analytics&lt;/strong&gt;: Store telemetry snapshots in &lt;a href="https://clickhouse.com/"&gt;ClickHouse&lt;/a&gt; using compressed columnar engines (&lt;code&gt;ReplacingMergeTree&lt;/code&gt; for current state, &lt;code&gt;MergeTree&lt;/code&gt; with TTL for historical bin levels).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native Observability&lt;/strong&gt;: Expose Prometheus metrics for scrapers, queue depth, upstream latency, and error rates, scraped by VictoriaMetrics.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2 id="system-architecture"&gt;System Architecture&lt;/h2&gt;
&lt;p&gt;The resulting service is written in &lt;strong&gt;Go 1.27&lt;/strong&gt; and runs as a lightweight container in Kubernetes:&lt;/p&gt;
&lt;figure class="arch-diagram" role="group" aria-label="bcrs-tracker System Architecture"&gt;
  &lt;svg viewBox="0 0 680 652" width="100%" height="auto" role="img" aria-labelledby="bcrs-arch-title bcrs-arch-desc" xmlns="http://www.w3.org/2000/svg"&gt;
    &lt;title id="bcrs-arch-title"&gt;bcrs-tracker System Architecture&lt;/title&gt;
    &lt;desc id="bcrs-arch-desc"&gt;Architecture diagram showing the Upstream BCRS Platform polled over rate-limited HTTP/2 by bcrs-tracker (Location Poller and Diff Engine, Bin Worker Pool, and Batch Writer), inserting into ClickHouse and queried by VictoriaMetrics and Grafana Dashboards.&lt;/desc&gt;
    &lt;defs&gt;
      &lt;marker id="arch-arrow-bcrs" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"&gt;
        &lt;path d="M 1 1.5 L 8.5 5 L 1 8.5 z" class="arch-arrow-head" /&gt;
      &lt;/marker&gt;
    &lt;/defs&gt;

    &lt;!-- Upstream BCRS Platform --&gt;
    &lt;rect x="130" y="16" width="420" height="62" rx="6" class="arch-node" /&gt;
    &lt;text x="340" y="42" text-anchor="middle" class="arch-node-title"&gt;Upstream BCRS Platform&lt;/text&gt;
    &lt;text x="340" y="61" text-anchor="middle" class="arch-node-sub"&gt;(bts.bcrs.sg/forapi/v2)&lt;/text&gt;

    &lt;!-- Upstream Connector --&gt;
    &lt;line x1="340" y1="78" x2="340" y2="128" class="arch-edge" marker-end="url(#arch-arrow-bcrs)" /&gt;
    &lt;text x="352" y="101" class="arch-edge-label"&gt;Rate-Limited HTTP/2 (Token Bucket)&lt;/text&gt;
    &lt;text x="352" y="118" class="arch-edge-label"&gt;Exponential Jittered Backoff&lt;/text&gt;

    &lt;!-- bcrs-tracker Daemon Boundary --&gt;
    &lt;rect x="104" y="134" width="472" height="270" rx="6" class="arch-group" /&gt;
    &lt;text x="340" y="159" text-anchor="middle" class="arch-group-title"&gt;bcrs-tracker&lt;/text&gt;

    &lt;!-- Pipeline Stage 1 --&gt;
    &lt;rect x="134" y="174" width="412" height="46" rx="4" class="arch-subnode" /&gt;
    &lt;text x="340" y="202" text-anchor="middle" class="arch-node-title"&gt;Location Poller &amp;amp; Diff Engine&lt;/text&gt;

    &lt;line x1="340" y1="220" x2="340" y2="250" class="arch-edge" marker-end="url(#arch-arrow-bcrs)" /&gt;
    &lt;text x="352" y="240" class="arch-edge-label"&gt;Active RVM IDs&lt;/text&gt;

    &lt;!-- Pipeline Stage 2 --&gt;
    &lt;rect x="134" y="256" width="412" height="46" rx="4" class="arch-subnode" /&gt;
    &lt;text x="340" y="284" text-anchor="middle" class="arch-node-title"&gt;Bin Worker Pool (Bounded Concurrency)&lt;/text&gt;

    &lt;line x1="340" y1="302" x2="340" y2="332" class="arch-edge" marker-end="url(#arch-arrow-bcrs)" /&gt;

    &lt;!-- Pipeline Stage 3 --&gt;
    &lt;rect x="134" y="338" width="412" height="46" rx="4" class="arch-subnode" /&gt;
    &lt;text x="340" y="366" text-anchor="middle" class="arch-node-title"&gt;Batch Writer (500 rows / 5s flush)&lt;/text&gt;

    &lt;!-- ClickHouse Connector --&gt;
    &lt;line x1="340" y1="384" x2="340" y2="442" class="arch-edge" marker-end="url(#arch-arrow-bcrs)" /&gt;
    &lt;text x="352" y="428" class="arch-edge-label"&gt;Native Batch Insert (LZ4)&lt;/text&gt;

    &lt;!-- ClickHouse Database --&gt;
    &lt;rect x="130" y="448" width="420" height="104" rx="6" class="arch-node" /&gt;
    &lt;text x="340" y="474" text-anchor="middle" class="arch-node-title"&gt;ClickHouse Database&lt;/text&gt;
    &lt;text x="172" y="499" class="arch-node-sub"&gt;• rvm_current&lt;/text&gt;
    &lt;text x="352" y="499" class="arch-node-sub"&gt;(ReplacingMergeTree)&lt;/text&gt;
    &lt;text x="172" y="518" class="arch-node-sub"&gt;• rvm_events&lt;/text&gt;
    &lt;text x="352" y="518" class="arch-node-sub"&gt;(MergeTree)&lt;/text&gt;
    &lt;text x="172" y="537" class="arch-node-sub"&gt;• rvm_bin_snapshots&lt;/text&gt;
    &lt;text x="352" y="537" class="arch-node-sub"&gt;(MergeTree + 2yr TTL)&lt;/text&gt;

    &lt;!-- Observability Connector --&gt;
    &lt;line x1="340" y1="590" x2="340" y2="558" class="arch-edge" marker-end="url(#arch-arrow-bcrs)" /&gt;
    &lt;text x="352" y="576" class="arch-edge-label"&gt;SQL / PromQL&lt;/text&gt;

    &lt;!-- VictoriaMetrics &amp; Grafana --&gt;
    &lt;rect x="130" y="590" width="420" height="46" rx="6" class="arch-node" /&gt;
    &lt;text x="340" y="618" text-anchor="middle" class="arch-node-title"&gt;VictoriaMetrics &amp;amp; Grafana Dashboards&lt;/text&gt;
  &lt;/svg&gt;
&lt;/figure&gt;

&lt;hr /&gt;
&lt;h2 id="what-the-telemetry-reveals"&gt;What the Telemetry Reveals&lt;/h2&gt;
&lt;p&gt;Having continuous time-series telemetry on reverse vending machines across Singapore reveals fascinating patterns:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The Weekend Surge&lt;/strong&gt;: Neighborhood machines near residential housing estates typically reach 80–100% capacity on Saturday and Sunday between 2:00 PM and 6:00 PM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clearance Cadence&lt;/strong&gt;: Logistics crews typically clear bins during early morning runs between 7:00 AM and 9:30 AM on weekdays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-Trip Confirmation&lt;/strong&gt;: Instead of walking down hoping for the best, I can query my nearest machine via Prometheus/Grafana or a quick API curl to confirm it is operational and has available bin capacity.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2 id="open-source"&gt;Open Source&lt;/h2&gt;
&lt;p&gt;The entire tracker—including the Go poller, ClickHouse migrations, and Helm charts—is open-source on GitHub:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/TheDJVG/BCRSTracking"&gt;github.com/TheDJVG/BCRSTracking&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you're in Singapore and interested in tracking local smart-city infrastructure, feel free to check out the repository, submit PRs, or send me feedback at &lt;a href="mailto:me@dj.vg"&gt;me@dj.vg&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>The 'Datacenter at Home': A Multi-Homed BGP &amp; WireGuard Mesh Architecture</title>
    <link href="https://dj.vg/posts/datacenter-at-home-bgp-wireguard-saltstack/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/datacenter-at-home-bgp-wireguard-saltstack/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/datacenter-at-home-bgp-wireguard-saltstack/</id>
    <published>2026-01-31T00:00:00Z</published>
    <updated>2026-01-31T00:00:00Z</updated>
    <summary>How to apply hyperscale datacenter design principles to a home network using eBGP, WireGuard, and SaltStack orchestration.</summary>
    <content type="html">&lt;p&gt;For the past five years, my home network hasn't just been a gateway to the internet; it's been a miniature ISP backbone. While most residential setups rely on a single ISP router doing basic NAT, I wanted something more robust: carrier-grade reliability, deterministic traffic engineering, and seamless multi-homing using my own public Autonomous System Number (&lt;strong&gt;AS209792&lt;/strong&gt;).&lt;/p&gt;
&lt;p&gt;By applying hyperscale datacenter design principles—like &lt;strong&gt;eBGP-on-the-host&lt;/strong&gt; and &lt;strong&gt;Infrastructure as Code&lt;/strong&gt;—I’ve built a network that treats residential ISPs as mere transport utilities.&lt;/p&gt;
&lt;h2 id="the-high-level-architecture"&gt;The High-Level Architecture&lt;/h2&gt;
&lt;p&gt;The core of the network is built on redundancy. I am connected to two primary fiber ISPs in Singapore, &lt;strong&gt;MyRepublic&lt;/strong&gt; and &lt;strong&gt;ViewQwest&lt;/strong&gt;, along with a &lt;strong&gt;5G backup&lt;/strong&gt; link. Instead of relying on these ISPs for routing, I use them as "underlay" transport to reach my "Upstream Edge Cloud"—a fleet of six VPS nodes (edge1.sin through edge6.sin) hosted across providers.&lt;/p&gt;
&lt;p&gt;The edge nodes are the true gateways to the internet. They announce my public prefixes to the global BGP table, while my home Core Router manages the paths between them.&lt;/p&gt;
&lt;h2 id="the-connectivity-fabric-wireguard-and-deterministic-addressing"&gt;The Connectivity Fabric: WireGuard and Deterministic Addressing&lt;/h2&gt;
&lt;p&gt;To link my Core Router to the cloud, I use a mesh of &lt;strong&gt;WireGuard&lt;/strong&gt; tunnels. Rather than managing a messy internal IPAM (IP Address Management) database, I use a &lt;strong&gt;deterministic addressing logic&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Every tunnel interface is assigned an IPv6 address and a specific UDP listen port generated procedurally based on the IDs of the two nodes. This ensures that the same two peers always get the same IP pair and port, making firewalling and BGP peering definitions completely predictable across the entire mesh. To simplify things further, I utilize &lt;strong&gt;RFC 5549&lt;/strong&gt;, which allows me to carry IPv4 routing information over an IPv6 next-hop, eliminating the need for private IPv4 point-to-point links.&lt;/p&gt;
&lt;h2 id="design-choice-ebgp-everywhere"&gt;Design Choice: eBGP Everywhere&lt;/h2&gt;
&lt;p&gt;A common question in BGP design is whether to use internal BGP (iBGP) or external BGP (eBGP). While iBGP is standard for single-ASN networks, it introduces complexity with split-horizon rules and requires Route Reflectors.&lt;/p&gt;
&lt;p&gt;I chose the &lt;strong&gt;"eBGP in the Datacenter"&lt;/strong&gt; approach. Every node in my network—the Core Router, the VPS edges, the pfSense firewall, and the Kubernetes nodes—operates under its own &lt;strong&gt;unique private ASN&lt;/strong&gt; (645xx range).&lt;/p&gt;
&lt;h3 id="why-this-works"&gt;Why this works:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loop Prevention:&lt;/strong&gt; BGP’s &lt;code&gt;AS_PATH&lt;/code&gt; attribute naturally prevents loops. If a node sees its own ASN in a path, it drops the route. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability:&lt;/strong&gt; I can look at my routing table and instantly see the path a packet took by looking at the sequence of ASNs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A Clean Public Face:&lt;/strong&gt; To the internet, I am a single entity. My edge nodes use BIRD (Internet Routing Daemon) with a strict export filter that "scrubs" these internal private ASNs, ensuring the world only sees a clean, single-hop path originating from &lt;strong&gt;AS209792&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-brain-saltstack-the-salt-mine"&gt;The Brain: SaltStack &amp;amp; The Salt Mine&lt;/h2&gt;
&lt;p&gt;Orchestrating this manually would be impossible. I use &lt;strong&gt;SaltStack&lt;/strong&gt; as the network’s "Control Plane." The secret sauce is the &lt;strong&gt;Salt Mine&lt;/strong&gt;, a secure metadata store on the Salt Master.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Metadata Exchange:&lt;/strong&gt; When a node joins, it generates its own WireGuard keys locally and pushes them, along with its current ISP IP, into the Salt Mine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated Handshakes:&lt;/strong&gt; All other nodes query the Mine and automatically "wire" themselves to the new peer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full System State:&lt;/strong&gt; Salt doesn't just configure BGP. It manages the entire stack:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;systemd-networkd:&lt;/strong&gt; Generates &lt;code&gt;.netdev&lt;/code&gt; and &lt;code&gt;.network&lt;/code&gt; files for the tunnels.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firewalling:&lt;/strong&gt; Automatically opens the procedurally generated UDP ports only for authorized peers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BIRD:&lt;/strong&gt; Templates the BGP sessions and filters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kubernetes:&lt;/strong&gt; Peering pfSense with the K8s nodes so that Pod/Service IPs are reachable across the entire network without NAT.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="reliability-and-convergence"&gt;Reliability and Convergence&lt;/h2&gt;
&lt;p&gt;With residential ISPs, "silent failures" (where the link is up but traffic is blackholed) are a reality. To counter this, every BGP session runs &lt;strong&gt;BFD (Bidirectional Forwarding Detection)&lt;/strong&gt;. If a link drops even a few packets, BFD detects the failure in milliseconds and BGP reroutes traffic to a healthy ISP.&lt;/p&gt;
&lt;p&gt;As a final failover, the &lt;strong&gt;5G link&lt;/strong&gt; is configured with heavy AS-path prepending. It stays in standby, receiving no traffic, until every other fiber path is exhausted.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This setup has been rock-solid for over five years. By moving the "intelligence" into SaltStack and using BGP for what it does best—path selection—I've created a home network that behaves like a professional datacenter fabric. It’s stateless, modular, and can scale to a new cloud provider with a single &lt;code&gt;state.apply&lt;/code&gt;.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Easy and free Dynamic DNS</title>
    <link href="https://dj.vg/posts/free-dynamic-dns/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/free-dynamic-dns/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/free-dynamic-dns/</id>
    <published>2025-10-04T00:00:00Z</published>
    <updated>2025-10-04T00:00:00Z</updated>
    <summary>A lightweight, stateless Dynamic DNS service built with FastAPI.</summary>
    <content type="html">&lt;h2 id="the-nasty-need-for-dynamic-dns"&gt;The nasty need for dynamic DNS.&lt;/h2&gt;
&lt;p&gt;For a while now I've been maintaining a couple of 4G/5G enabled out of band devices. These devices are used to provide access to serial consoles of network devices. Why you ask? Well, sometimes a configuration error or something else causes the normal access paths to be unavailable. Who hasn't made a L2 loop by accident...&lt;/p&gt;
&lt;p&gt;I call these devices "last resort". They provide a last resort access to my network when everything else fails, and they use 4G/5G for connectivity. The downside? They don't have a fixed IP address, quite often the IPs change. For most cases this is fine because I have reverse tunnels, but I still want to access them in case those fail. But for that to work I need to know 1 crucial thing: its IP address on the internet.&lt;/p&gt;
&lt;p&gt;I'm sure many people have used a service like "dyn.com" to solve this problem, the device in question sends a HTTP request (we wish those devices support HTTPS...) and a DNS record gets updated with the IP of the requesting device. That way every time the IP changes the DNS record gets updated and we can access it using the DNS name and it makes it easy to monitor its availibility. The main problem I had with this solution is the cost (also the annoying ratelimiting, but I partially blame the devices in question), it was not worth the money to use "dyn.com" for something I can make myself, right?&lt;/p&gt;
&lt;h2 id="so-i-made-a-service-myself"&gt;So I made a service myself.&lt;/h2&gt;
&lt;p&gt;When I started to think about this had the following requirements:
- Dead simple to request a new dynamic hostname
- No need for accounts
- Compatibility with DynDNS style updates (&lt;code&gt;/nic/update&lt;/code&gt;) (more to add?)&lt;/p&gt;
&lt;p&gt;When I had the requirements clear I also wanted to make it stateless, who wants to keep track of what hostnames are in use and not? If you lose the credentials, just get a fresh hostname. I quickly wrote an implementation using &lt;a href="https://fastapi.tiangolo.com/"&gt;FastAPI&lt;/a&gt; and looked in my unused domain invenstory for a good candidate.&lt;/p&gt;
&lt;h2 id="i-present-to-you-xyzfrl"&gt;I present to you: &lt;a href="https://l.dj.vg/xyz.frl"&gt;XYZ.FRL&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Now the initial version of this service is deployed I really like it myself, mainly because how simple it is. You want to know how simple? Open your terminal and come along.&lt;/p&gt;
&lt;h3 id="request-a-new-dynamic-hostname"&gt;Request a new dynamic hostname&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;›&lt;span class="w"&gt; &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;https://xyz.frl/generate&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq&lt;span class="w"&gt; &lt;/span&gt;.
&lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"hostname"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4bd36fd6538b45b3b5449e615afc8e21.xyz.frl"&lt;/span&gt;,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"username"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4bd36fd6538b45b3b5449e615afc8e21"&lt;/span&gt;,
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"password"&lt;/span&gt;:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+MihUvQ8pc1QwYA6/16A/D/155qHfJKlUqUhNgD6osE="&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="update-the-hostname-with-a-new-ip"&gt;Update the hostname with a new IP&lt;/h3&gt;
&lt;p&gt;Simple use &lt;code&gt;curl&lt;/code&gt; to send an update request and set the ip to the valuse set in the &lt;code&gt;myip&lt;/code&gt; query param (you can also leave it out to use the IP the request is coming from).&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;›&lt;span class="w"&gt; &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;--user&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'4bd36fd6538b45b3b5449e615afc8e21:+MihUvQ8pc1QwYA6/16A/D/155qHfJKlUqUhNgD6osE='&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'http://xyz.frl/nic/update?myip=192.0.2.0,100::'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="check-your-new-ip"&gt;Check your new IP!&lt;/h3&gt;
&lt;p&gt;It might take a brief moment for changes to propagate &lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;›&lt;span class="w"&gt; &lt;/span&gt;host&lt;span class="w"&gt; &lt;/span&gt;4bd36fd6538b45b3b5449e615afc8e21.xyz.frl
4bd36fd6538b45b3b5449e615afc8e21.xyz.frl&lt;span class="w"&gt; &lt;/span&gt;has&lt;span class="w"&gt; &lt;/span&gt;address&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192&lt;/span&gt;.0.2.0
4bd36fd6538b45b3b5449e615afc8e21.xyz.frl&lt;span class="w"&gt; &lt;/span&gt;has&lt;span class="w"&gt; &lt;/span&gt;IPv6&lt;span class="w"&gt; &lt;/span&gt;address&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;::
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="profit"&gt;Profit.&lt;/h3&gt;
&lt;p&gt;From now simply use your compatible client (or curl) with our without the &lt;code&gt;myip&lt;/code&gt; query param to update the IP address in the record. Make your own life easy by simply CNAMEing to this hostname and you should be good to go.&lt;/p&gt;
&lt;h2 id="final-thoughts"&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;This was a small fun project that I threw together on a Saturday morning when I got a renewal notice (and now cancellation) for DynDNS. I hope it can benefit others too.&lt;/p&gt;
&lt;p&gt;Do you have any ideas to make this service even better? Contact me on me@dj.vg.&lt;/p&gt;
&lt;h3 id="security"&gt;Security&lt;/h3&gt;
&lt;p&gt;While I've applied pretty aggressive rate limiting (1 request per minute on the generate and update endpoints) it's of course possible people might try to abuse this, if that happens I'll deal with it.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title>Timelapse system: a start</title>
    <link href="https://dj.vg/posts/timelapse-system-a-start/" rel="alternate" type="text/html"/>
    <link href="https://dj.vg/posts/timelapse-system-a-start/index.md" rel="alternate" type="text/markdown"/>
    <id>https://dj.vg/posts/timelapse-system-a-start/</id>
    <published>2022-01-25T00:00:00Z</published>
    <updated>2022-01-25T00:00:00Z</updated>
    <summary>How I built a system to take a photo every minute, eventually storing over 750k photos and 6.2TB of data.</summary>
    <content type="html">&lt;p&gt;Everyone that’s interested in technology will at some point face the same struggle: how do I apply all these interesting technologies in my own projects.&lt;/p&gt;
&lt;p&gt;This is really the start of a system that (at the moment of writing) stores over 750 thousand photos occupying 6.2 terabyte of storage and every single minute this grows.&lt;/p&gt;
&lt;p&gt;In the following comparison you can see a picture taken just after I started this project and one that has been taken on the day of publishing this piece.&lt;/p&gt;
&lt;div class="image-comparison-grid"&gt;
    &lt;figure&gt;
        &lt;img src="/images/timelapse/first-scaled.jpg" alt="First Timelapse Image" loading="lazy" decoding="async"&gt;
        &lt;figcaption&gt;Just after starting the project&lt;/figcaption&gt;
    &lt;/figure&gt;
    &lt;figure&gt;
        &lt;img src="/images/timelapse/last-scaled.jpg" alt="Latest Timelapse Image" loading="lazy" decoding="async"&gt;
        &lt;figcaption&gt;Day of publishing&lt;/figcaption&gt;
    &lt;/figure&gt;
&lt;/div&gt;

&lt;h2 id="times-are-changing"&gt;Times are changing&lt;/h2&gt;
&lt;p&gt;I’m living in an area that’s actively being developed and for some reason I can’t let go of the past. I want to keep track of how the area is changing and feel nostalgic on “how thing once were”. This is why is decided to put a &lt;a href="https://en.wikipedia.org/wiki/Canon_EOS_2000D"&gt;camera&lt;/a&gt; on my balcony, with a &lt;a href="https://www.raspberrypi.org/"&gt;Raspberry Pi&lt;/a&gt; and a &lt;a href="https://en.wikipedia.org/wiki/Cron"&gt;Cronjob&lt;/a&gt; to take a picture every single minute using &lt;a href="http://www.gphoto.org/"&gt;gPhoto2&lt;/a&gt;. The pictures would be saved on a simple &lt;a href="https://en.wikipedia.org/wiki/Network_File_System"&gt;NFS&lt;/a&gt; share backed by 2 times 1TB hard disks in &lt;a href="https://en.wikipedia.org/wiki/Standard_RAID_levels#RAID_1"&gt;raid 1&lt;/a&gt;. That was it. Anyone working in IT will immediately see a couple of problems with this approach, just to name a couple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reliability&lt;/li&gt;
&lt;li&gt;Monitoring&lt;/li&gt;
&lt;li&gt;Observability&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="reliability"&gt;Reliability&lt;/h3&gt;
&lt;p&gt;As I was not actively looking at the pictures it would happen multiple times that the Raspberry Pi was hanging or that for some reason the camera was not responding ending up with thousands of hanging Cronjobs. Also unreliable Wifi from outdoor made NFS very sad. That had to change, it should just work, even if one of the components is not working.&lt;/p&gt;
&lt;h3 id="monitoring"&gt;Monitoring&lt;/h3&gt;
&lt;p&gt;As mentioned in the previous paragraphs I was not aware of these problems. It was just (not) running and that is not great for a project that tries to capture a changing landscape one minute at a time. The moment there’s a problem with the setup I want to know about and preferable it should recover automatically (by for example resetting the power of the camera).&lt;/p&gt;
&lt;h3 id="observability"&gt;Observability&lt;/h3&gt;
&lt;p&gt;Taking a photo is one thing, but there’s so much extra information that I was not aware of. Some of the information is obvious, for example the &lt;a href="https://en.wikipedia.org/wiki/Film_speed#ISO"&gt;ISO&lt;/a&gt; and &lt;a href="https://en.wikipedia.org/wiki/Exposure_(photography)"&gt;exposure&lt;/a&gt;, which is stored in the &lt;a href="https://en.wikipedia.org/wiki/Exif"&gt;Exif&lt;/a&gt; portion of the file. Other information that I wanted to store was: Outside temperature and relative humidity, how much time is spend on the camera, how reliable is this process, how long does it take to write the file to storage, and how much energy does all of this consume!&lt;/p&gt;
&lt;h2 id="how-to-get-there"&gt;How to get there&lt;/h2&gt;
&lt;p&gt;In the blogs after this I will write down exactly how I got to a system that I almost never have to touch. How videos are automatically created daily. How the camera and controller is able to recover from almost any failure, how data is stored and replicated to the other side to the world.&lt;/p&gt;
&lt;p&gt;So if you’re interested in &lt;a href="https://kubernetes.io/"&gt;Kubernetes&lt;/a&gt;, &lt;a href="https://docs.ceph.com/en/pacific/"&gt;Ceph&lt;/a&gt; (and its S3 compatible API &lt;a href="https://docs.ceph.com/en/pacific/radosgw/index.html"&gt;radosgw&lt;/a&gt;), &lt;a href="https://www.djangoproject.com/"&gt;Django&lt;/a&gt;, &lt;a href="https://github.com/VictoriaMetrics/VictoriaMetrics"&gt;VictoriaMetrics&lt;/a&gt; (and &lt;a href="https://openmetrics.io/"&gt;metrics&lt;/a&gt; in general), &lt;a href="https://www.home-assistant.io/"&gt;Home Assistant&lt;/a&gt;, and many more (don’t forget the hardware). Come back soon to see how all of these things are combined to create a system that allows me to take reliable pictures that I plan to store forever!&lt;/p&gt;</content>
  </entry>
</feed>
