Просмотр исходного кода

Buffering: unify hotplug + Lua through shared on-disk buffer

Before, hotplug scripts (WiFi/DHCP) and the Lua agent had two
separate delivery paths. Hotplug events went directly to the
server via curl and were lost during internet outages; only Lua
events were buffered. This made WiFi/DHCP events unreliable
precisely when they're most useful (during connectivity events).

New design:
- /var/run/client2server/buffer  NDJSON event queue (shared)
- /var/run/client2server/wake    hotplug -> agent wake file
- /var/run/client2server/pid     Lua agent PID (was broken: literal $)
- /var/run/client2server/env     UCI exports for child processes
- /usr/share/client2server/hotplug-lib.sh  shared shell helpers

Hotplug scripts (01-wifi, 02-dhcp) no longer curl the server.
They append to the shared buffer and touch the wake file. The
Lua agent polls the wake file each loop iteration (~1s) and
flushes everything in order via WebSocket.

Bumps:
- max_buffer: 100 -> 1000 (now shared)
- fixed broken PID file write (was outputting literal '$')
- init.d creates state dir, preserves buffer across restarts
- backoff stays 30s->5min, drops to 10s when buffer >80% full

Survives: internet outage, server restart, agent restart
Does NOT survive: router reboot (tmpfs) — acceptable

Docs updated: ARCHITECTURE.md (new 'Offline Buffering' section +
system diagram), README.md (new 'Offline Behavior' section),
MEMORY.md (architecture description, evolution log, install cmds).

All 13 mermaid diagrams re-validated via mermaid-validator.
Luis Rosales 2 месяцев назад
Родитель
Сommit
2af96b034a
9 измененных файлов с 414 добавлено и 139 удалено
  1. 103 20
      ARCHITECTURE.md
  2. 36 8
      MEMORY.md
  3. 25 5
      README.md
  4. 4 0
      package/Makefile
  5. 11 2
      package/files/etc/init.d/client2server
  6. 24 45
      package/hotplug/01-wifi
  7. 29 47
      package/hotplug/02-dhcp
  8. 128 0
      package/hotplug/_lib.sh
  9. 54 12
      package/src/client2server-unified.lua

+ 103 - 20
ARCHITECTURE.md

@@ -24,13 +24,15 @@ flowchart TB
     subgraph Router["OpenWrt Router"]
     subgraph Router["OpenWrt Router"]
         direction TB
         direction TB
         Lua[("client2server-unified.lua")]
         Lua[("client2server-unified.lua")]
-        Hotplug[("/etc/hotplug.d/* scripts")]
+        Hotplug[("/etc/hotplug.d/{wireless,dhcp}/* scripts")]
         Wifi[("wifi_connected / disconnected")]
         Wifi[("wifi_connected / disconnected")]
         DHCP[("dhcp_lease_new / expire")]
         DHCP[("dhcp_lease_new / expire")]
         WAN[("wan_link / dhcp events")]
         WAN[("wan_link / dhcp events")]
         CMD[("Command Executor")]
         CMD[("Command Executor")]
-        Buf[("Event buffer
-/tmp/event_buffer")]
+        Shared[("Shared buffer
+/var/run/client2server/buffer")]
+        Wake[("wake file
+/var/run/client2server/wake")]
     end
     end
 
 
     subgraph Edge["Reverse Proxy / LB"]
     subgraph Edge["Reverse Proxy / LB"]
@@ -63,9 +65,12 @@ Events / Commands / Alerts")]
     Router -->|WebSocket :3843| Caddy
     Router -->|WebSocket :3843| Caddy
     Caddy -->|lb| S1
     Caddy -->|lb| S1
     Caddy -->|lb| S2
     Caddy -->|lb| S2
-    Lua -->|offline| Buf
-    Buf -->|reconnect| Lua
-    Hotplug -->|HTTP POST /api/events| S1
+    Lua -->|offline| Shared
+    Hotplug -->|spool| Shared
+    Hotplug -->|touch| Wake
+    Wake -->|polled by| Lua
+    Shared -->|flush on reconnect| Lua
+    Lua -->|WS frame| Caddy
     S1 -->|publish| REvents
     S1 -->|publish| REvents
     S1 -->|publish| RCommands
     S1 -->|publish| RCommands
     S2 -->|publish| REvents
     S2 -->|publish| REvents
@@ -82,6 +87,12 @@ Events / Commands / Alerts")]
     Router --> CMD
     Router --> CMD
 ```
 ```
 
 
+> **Key change (v2.x):** Hotplug scripts no longer POST to the server
+> directly. Both hotplug events and Lua-generated events go through a
+> **single shared on-disk buffer** (`/var/run/client2server/buffer`) that
+> survives internet outages. See [Offline Buffering](#offline-buffering)
+> for the full flow.
+
 **Read it as three planes:**
 **Read it as three planes:**
 
 
 | Plane | What flows | Direction |
 | Plane | What flows | Direction |
@@ -100,27 +111,33 @@ Events / Commands / Alerts")]
 sequenceDiagram
 sequenceDiagram
     participant R as Router
     participant R as Router
     participant H as Hotplug
     participant H as Hotplug
+    participant Buf as Shared buffer<br/>(/var/run/client2server/buffer)
+    participant L as Lua agent
     participant S as Go Server
     participant S as Go Server
     participant DB as SQLite
     participant DB as SQLite
     participant RP as Redpanda
     participant RP as Redpanda
     participant D as Dashboard (SSE)
     participant D as Dashboard (SSE)
     participant C as Consumer
     participant C as Consumer
 
 
-    Note over R,C: Hybrid delivery: instant hotplug + ≤1s luv state-diff
+    Note over R,C: Hybrid delivery: instant hotplug + ≤1s luv state-diff<br/>Both paths share one on-disk buffer for offline resilience
 
 
     par Hotplug path (instant)
     par Hotplug path (instant)
-        H->>S: POST /api/events (curl)
+        H->>Buf: append NDJSON event
+        H->>L: touch wake file
     and Lua state-diff (≤1s)
     and Lua state-diff (≤1s)
-        R->>S: WS frame: {type: event, ...}
+        L->>L: detect (WAN/DHCP/state change)
     end
     end
 
 
+    L->>L: main loop polls wake file
+    L->>Buf: read queued events
+    L->>S: WS frame: {type: event, ...}
+
     S->>S: authenticate (legacy token or JWT)
     S->>S: authenticate (legacy token or JWT)
     S->>DB: INSERT INTO events
     S->>DB: INSERT INTO events
     S->>RP: publish (fire-and-forget)
     S->>RP: publish (fire-and-forget)
     S->>D: SSE push to all subscribers
     S->>D: SSE push to all subscribers
     RP-->>C: consume (any Kafka client)
     RP-->>C: consume (any Kafka client)
-    S-->>R: ACK
-    S-->>H: 200 OK
+    S-->>L: ACK
 ```
 ```
 
 
 ### 2. Command Flow (Dashboard → Router)
 ### 2. Command Flow (Dashboard → Router)
@@ -194,6 +211,64 @@ sequenceDiagram
     Note over S,D: On reconnect: alert auto-cleared
     Note over S,D: On reconnect: alert auto-cleared
 ```
 ```
 
 
+### 5. Offline Buffering (router → server, with outages)
+
+```mermaid
+sequenceDiagram
+    participant Ev as Event source
+    participant H as Hotplug script
+    participant L as Lua agent
+    participant Buf as Shared buffer<br/>NDJSON file
+    participant W as Wake file
+    participant S as Go server
+    participant D as SQLite
+
+    Note over Ev,D: Single buffer handles BOTH hotplug and Lua events.
+    Note over Ev,D: Survives: internet outage, server restart, agent restart.
+    Note over Ev,D: Does NOT survive: router reboot (tmpfs).
+
+    Ev->>H: WiFi/DHCP event
+    H->>Buf: append NDJSON line
+    H->>W: touch wake file
+    H->>L: send SIGHUP (best-effort)
+
+    L->>W: poll wake file (each loop)
+    L->>Buf: read queued events
+    loop per event (in order)
+        L->>S: WS frame {type: event, ...}
+        S->>D: INSERT events
+        S-->>L: ACK
+    end
+    L->>Buf: remove sent lines
+
+    alt Internet down
+        L->>L: WS send fails
+        L->>Buf: append own event
+        Note over L: exponential backoff
+        L->>L: 30s → 60s → 120s → ... → 5min
+        Note over L: 10s retry when buffer >80% full
+    end
+
+    alt Buffer overflow (>1000 events)
+        L->>L: drop oldest (FIFO), log warning
+    end
+```
+
+**File locations:**
+
+| File | Purpose | Owner |
+|---|---|---|
+| `/var/run/client2server/buffer` | NDJSON event queue | Both hotplug + Lua (shared) |
+| `/var/run/client2server/pid` | Lua agent's PID | Lua (written at startup) |
+| `/var/run/client2server/wake` | Hotplug → agent signal | Hotplug (touch), Lua (consume) |
+| `/var/run/client2server/env` | UCI exports for hotplug | init.d (write), hotplug (read) |
+| `/usr/share/client2server/hotplug-lib.sh` | Shared hotplug helpers | Makefile (install) |
+
+**Wake latency:** bounded by the Lua agent's main loop (1s sleep between
+iterations), so a hotplug event is typically picked up within ~1s. SIGHUP
+is sent as a fast path but is not relied on (Lua 5.1 has no portable
+signal API).
+
 ---
 ---
 
 
 ## Component Architecture
 ## Component Architecture
@@ -305,24 +380,32 @@ fetch + JWT header")]
 flowchart LR
 flowchart LR
     subgraph Router["OpenWrt"]
     subgraph Router["OpenWrt"]
         Init[("/etc/init.d/client2server")]
         Init[("/etc/init.d/client2server")]
+        State[("State dir
+/var/run/client2server/
+{buffer,pid,env,wake}")]
         Lua[("client2server-unified.lua")]
         Lua[("client2server-unified.lua")]
+        Lib[("hotplug-lib.sh
+/usr/share/client2server/")]
         HP1[("hotplug.d/wireless/01-wifi")]
         HP1[("hotplug.d/wireless/01-wifi")]
         HP2[("hotplug.d/dhcp/02-dhcp")]
         HP2[("hotplug.d/dhcp/02-dhcp")]
         UCI[("/etc/config/client2server")]
         UCI[("/etc/config/client2server")]
         UB[("UCI store")]
         UB[("UCI store")]
-        Buf[("/tmp/event_buffer")]
         WScli[("WebSocket client
         WScli[("WebSocket client
-(mosquitto/lua-websockets)")]
+(luasocket)")]
         CMDex[("Command executor")]
         CMDex[("Command executor")]
     end
     end
 
 
-    Init --> Lua
+    Init -->|create state dir| State
+    Init -->|export UCI env| State
+    Init -->|launch| Lua
     UCI --> Lua
     UCI --> Lua
-    HP1 -->|curl POST /api/events| Server[("Go server :3843")]
-    HP2 -->|curl POST /api/events| Server
-    Lua -->|WS| Server
-    Lua --> Buf
-    Buf --> Lua
+    Lua -->|write pid| State
+    HP1 --> Lib
+    HP2 --> Lib
+    Lib -->|spool NDJSON| State
+    Lib -->|touch wake| State
+    Lua -->|poll wake file| State
+    Lua -->|WS| Server[("Go server :3843")]
     Server -->|WS frame| WScli
     Server -->|WS frame| WScli
     WScli --> CMDex
     WScli --> CMDex
     CMDex --> UB
     CMDex --> UB
@@ -574,7 +657,7 @@ client2server/
 ├── package/           # OpenWrt IPK build
 ├── package/           # OpenWrt IPK build
 │   ├── src/client2server-unified.lua
 │   ├── src/client2server-unified.lua
 │   ├── files/etc/{init.d,config}/client2server
 │   ├── files/etc/{init.d,config}/client2server
-│   └── hotplug/{01-wifi,02-dhcp}
+│   └── hotplug/{01-wifi,02-dhcp,_lib.sh}
 ├── server/            # Go server (single binary, 6 files)
 ├── server/            # Go server (single binary, 6 files)
 │   ├── main.go        # HTTP/WS routes + ingest
 │   ├── main.go        # HTTP/WS routes + ingest
 │   ├── auth.go        # JWT + scrypt
 │   ├── auth.go        # JWT + scrypt

+ 36 - 8
MEMORY.md

@@ -5,7 +5,8 @@
 
 
 ## Purpose
 ## Purpose
 - Routers push events (DHCP leases, WiFi connects/disconnects, WAN state) to a central server.
 - Routers push events (DHCP leases, WiFi connects/disconnects, WAN state) to a central server.
-- Two delivery paths: WebSocket long-lived connection for command roundtrips, and direct HTTP POST for hotplug-triggered events.
+- Two delivery paths (router → server): WebSocket long-lived connection for command roundtrips, and hotplug scripts that spool events to a shared on-disk buffer for resilience.
+- Both paths share `/var/run/client2server/buffer` (NDJSON) so events survive internet outages; the Lua agent flushes the buffer on reconnect.
 - Server fans out via Redpanda topics; LuIS backend consumes.
 - Server fans out via Redpanda topics; LuIS backend consumes.
 - Server can also push commands back to routers (uci_set, shell, reboot, wifi_restart, status).
 - Server can also push commands back to routers (uci_set, shell, reboot, wifi_restart, status).
 - Dashboard (new in 2.1) provides Apple-style web UI for monitoring and control.
 - Dashboard (new in 2.1) provides Apple-style web UI for monitoring and control.
@@ -14,7 +15,7 @@
 | Layer | Tech |
 | Layer | Tech |
 |-------|------|
 |-------|------|
 | Router client | Lua (OpenWrt), `client2server-unified.lua` |
 | Router client | Lua (OpenWrt), `client2server-unified.lua` |
-| Hotplug paths | Shell + curl, `/etc/hotplug.d/{wireless,dhcp}/*` |
+| Hotplug paths | Shell + shared `hotplug-lib.sh`, `/etc/hotplug.d/{wireless,dhcp}/*` |
 | Server runtime | Go 1.22 |
 | Server runtime | Go 1.22 |
 | WebSocket lib | `github.com/coder/websocket` v1.8.13 |
 | WebSocket lib | `github.com/coder/websocket` v1.8.13 |
 | Event bus | Redpanda (Kafka-compatible) :9092 |
 | Event bus | Redpanda (Kafka-compatible) :9092 |
@@ -37,7 +38,7 @@ client2server/
 │   ├── Makefile
 │   ├── Makefile
 │   ├── src/client2server-unified.lua
 │   ├── src/client2server-unified.lua
 │   ├── files/etc/{config,init.d}/client2server
 │   ├── files/etc/{config,init.d}/client2server
-│   └── hotplug/{01-wifi,02-dhcp}
+│   └── hotplug/{01-wifi,02-dhcp,_lib.sh}
 ├── server/              # Go server
 ├── server/              # Go server
 │   ├── main.go          # WS handler + HTTP API + ingestEvent
 │   ├── main.go          # WS handler + HTTP API + ingestEvent
 │   ├── auth.go          # JWT + scrypt + login + middleware
 │   ├── auth.go          # JWT + scrypt + login + middleware
@@ -94,6 +95,31 @@ client2server/
 | `wifi_connected` | hostapd hotplug | mac, interface |
 | `wifi_connected` | hostapd hotplug | mac, interface |
 | `wifi_disconnected` | hostapd hotplug | mac, interface |
 | `wifi_disconnected` | hostapd hotplug | mac, interface |
 
 
+## Offline Buffering
+
+Both event paths share a single on-disk buffer so events survive internet outages.
+
+- **State dir:** `/var/run/client2server/` (created by init.d at boot)
+- **Buffer file:** `/var/run/client2server/buffer` (NDJSON, one event per line)
+- **PID file:** `/var/run/client2server/pid` (written by the Lua agent)
+- **Wake file:** `/var/run/client2server/wake` (touched by hotplug scripts to nudge the agent)
+- **Env file:** `/var/run/client2server/env` (UCI exports for child processes)
+
+**Flow (hotplug script → server):**
+1. Hotplug event fires (WiFi/DHCP).
+2. `hotplug-lib.sh` appends JSON event to `/var/run/client2server/buffer`.
+3. Hotplug touches `wake` file (and SIGHUPs agent as fast path).
+4. Lua agent's main loop sees the wake file → flushes buffer via WebSocket.
+5. On reconnect, agent drains everything; on overflow, drops oldest (FIFO, `max_buffer=1000`).
+
+**Flow (Lua agent → server):**
+1. Event generated internally (DHCP lease, WAN link, command result).
+2. `ws.send()` fails → `buffer.add()` writes to the same buffer file.
+3. Reconnect logic (exponential 30s→5min backoff, 10s when buffer >80% full) retries; on success, `buffer.flush()` drains in order.
+
+**Survives:** agent restarts, internet outages, server downtime.
+**Doesn't survive:** router reboot (buffer is in tmpfs). Acceptable: network events from a rebooting router are stale anyway.
+
 ## Commands (server → router)
 ## Commands (server → router)
 | Command | Args | Notes |
 | Command | Args | Notes |
 |---------|------|-------|
 |---------|------|-------|
@@ -133,7 +159,8 @@ scp package/files/etc/init.d/client2server root@router:/etc/init.d/
 scp package/files/etc/config/client2server root@router:/etc/config/
 scp package/files/etc/config/client2server root@router:/etc/config/
 scp package/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 scp package/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 scp package/hotplug/02-dhcp root@router:/etc/hotplug.d/dhcp/
 scp package/hotplug/02-dhcp root@router:/etc/hotplug.d/dhcp/
-ssh root@router "chmod +x /usr/sbin/client2server-unified.lua /etc/init.d/client2server /etc/hotplug.d/wireless/01-wifi /etc/hotplug.d/dhcp/02-dhcp"
+scp package/hotplug/_lib.sh root@router:/usr/share/client2server/hotplug-lib.sh
+ssh root@router "chmod +x /usr/sbin/client2server-unified.lua /etc/init.d/client2server /etc/hotplug.d/wireless/01-wifi /etc/hotplug.d/dhcp/02-dhcp /usr/share/client2server/hotplug-lib.sh"
 ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
 ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
 ```
 ```
 
 
@@ -153,17 +180,18 @@ ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server s
 To add users: connect to SQLite, INSERT into `users` table with scrypt hash from `HashPassword()`.
 To add users: connect to SQLite, INSERT into `users` table with scrypt hash from `HashPassword()`.
 
 
 ## Architecture: Hybrid Event Delivery (router side)
 ## Architecture: Hybrid Event Delivery (router side)
-The router has two parallel event paths:
-1. **Hotplug path (instant)** — kernel fires, shell runs, curl POSTs to `/api/events`
-2. **Lua state-diff path (≤1s)** — luv async loop polls state, sends diffs
+The router has two parallel event paths, **both** writing to a shared on-disk buffer:
+1. **Hotplug path (instant)** — kernel fires, shell runs, `hotplug-lib.sh` appends NDJSON to `/var/run/client2server/buffer` + touches wake file
+2. **Lua state-diff path (≤1s)** — luv async loop polls state, generates events, writes to the same buffer on send failure
 
 
-Both paths POST to `/api/events`; server saves to SQLite, publishes to Redpanda, broadcasts via SSE.
+The Lua agent's main loop polls the wake file each iteration and flushes the buffer over WebSocket. Both paths are now resilient to internet outages; hotplug events are no longer dropped when the server is unreachable.
 
 
 ## Evolution (commit history)
 ## Evolution (commit history)
 - `6d6780c` — Removed legacy event-forwarder + unused Lua clients, fixed event names, wired UCI to hotplug
 - `6d6780c` — Removed legacy event-forwarder + unused Lua clients, fixed event names, wired UCI to hotplug
 - `9f2269a` — Migrated Go server to coder/websocket + franz-go (the legacy deps didn't exist/weren't archived), unified port 3843
 - `9f2269a` — Migrated Go server to coder/websocket + franz-go (the legacy deps didn't exist/weren't archived), unified port 3843
 - `6af966d` — Added SQLite, JWT auth, SSE live feed, metrics, alerts, Redpanda consumer
 - `6af966d` — Added SQLite, JWT auth, SSE live feed, metrics, alerts, Redpanda consumer
 - `5b57be3` — Built the Apple-style React dashboard SPA + fixed publish() to be fire-and-forget (was blocking HTTP for 3s when Redpanda down)
 - `5b57be3` — Built the Apple-style React dashboard SPA + fixed publish() to be fire-and-forget (was blocking HTTP for 3s when Redpanda down)
+- *(pending)* — Unified offline buffering: hotplug scripts + Lua agent share `/var/run/client2server/buffer` (NDJSON), wake-file signaling, fixed broken PID file write, raised `max_buffer` to 1000
 
 
 ## Known Issues / TODO
 ## Known Issues / TODO
 - ⚠️ Default admin/admin must be changed in production
 - ⚠️ Default admin/admin must be changed in production

+ 25 - 5
README.md

@@ -17,7 +17,7 @@ SPA gives you live visibility and command control from any browser.
 ├────────────────────────────────────────────────────────────────┤
 ├────────────────────────────────────────────────────────────────┤
 │                                                                 │
 │                                                                 │
 │   OpenWrt Routers                                               │
 │   OpenWrt Routers                                               │
-│       │  WebSocket :3843  +  Hotplug (curl)         
+│       │  WebSocket :3843  +  Hotplug (shared buffer)
 │       ▼                                                          │
 │       ▼                                                          │
 │   ┌──────────────┐                                              │
 │   ┌──────────────┐                                              │
 │   │ Caddy LB     │  ────────────────┐                          │
 │   │ Caddy LB     │  ────────────────┐                          │
@@ -64,7 +64,8 @@ SPA gives you live visibility and command control from any browser.
 - ✅ **Hotplug-driven** WiFi connect/disconnect + DHCP lease events (instant, no polling)
 - ✅ **Hotplug-driven** WiFi connect/disconnect + DHCP lease events (instant, no polling)
 - ✅ **luv-based** async state-diff for WAN link, DHCP renew, IP changes (≤1s latency)
 - ✅ **luv-based** async state-diff for WAN link, DHCP renew, IP changes (≤1s latency)
 - ✅ **UCI-configurable** server URL, token, check intervals
 - ✅ **UCI-configurable** server URL, token, check intervals
-- ✅ **Event buffer** for store-and-forward while offline
+- ✅ **Unified event buffer** (`/var/run/client2server/buffer`) — shared by hotplug + Lua, survives internet outages
+- ✅ **Wake-file signaling** — hotplug events nudge the agent for ~1s latency instead of waiting for the 30s flush cycle
 - ✅ **Bidirectional** command execution (uci_set, shell, reboot, wifi_restart, status)
 - ✅ **Bidirectional** command execution (uci_set, shell, reboot, wifi_restart, status)
 
 
 ### Dashboard (React)
 ### Dashboard (React)
@@ -126,7 +127,8 @@ scp package/files/etc/init.d/client2server root@router:/etc/init.d/
 scp package/files/etc/config/client2server root@router:/etc/config/
 scp package/files/etc/config/client2server root@router:/etc/config/
 scp package/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 scp package/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 scp package/hotplug/02-dhcp root@router:/etc/hotplug.d/dhcp/
 scp package/hotplug/02-dhcp root@router:/etc/hotplug.d/dhcp/
-ssh root@router "chmod +x /usr/sbin/client2server-unified.lua /etc/init.d/client2server /etc/hotplug.d/wireless/01-wifi /etc/hotplug.d/dhcp/02-dhcp"
+scp package/hotplug/_lib.sh root@router:/usr/share/client2server/hotplug-lib.sh
+ssh root@router "chmod +x /usr/sbin/client2server-unified.lua /etc/init.d/client2server /etc/hotplug.d/wireless/01-wifi /etc/hotplug.d/dhcp/02-dhcp /usr/share/client2server/hotplug-lib.sh"
 ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
 ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
 ```
 ```
 
 
@@ -224,7 +226,7 @@ dashboard). The router doesn't need a JWT — it sends the shared `TOKEN` direct
 
 
 ## Events
 ## Events
 
 
-Router sends these events to server:
+Router sends these events to server (via the shared on-disk buffer):
 
 
 | Event | Source | Payload |
 | Event | Source | Payload |
 |-------|--------|---------|
 |-------|--------|---------|
@@ -237,6 +239,23 @@ Router sends these events to server:
 | `wifi_connected` | hostapd (hotplug) | `mac`, `interface` |
 | `wifi_connected` | hostapd (hotplug) | `mac`, `interface` |
 | `wifi_disconnected` | hostapd (hotplug) | `mac`, `interface` |
 | `wifi_disconnected` | hostapd (hotplug) | `mac`, `interface` |
 
 
+## Offline Behavior
+
+Both event paths (hotplug scripts + Lua agent) share a single on-disk
+buffer at `/var/run/client2server/buffer` (NDJSON, max 1000 events).
+When the internet is down:
+
+1. Hotplug scripts append events to the buffer + touch a wake file.
+2. The Lua agent polls the wake file on every loop iteration (~1s).
+3. The Lua agent tries to flush on reconnect; on failure, events stay
+   in the buffer and reconnect logic kicks in (30s→5min backoff,
+   10s when buffer >80% full).
+4. When the buffer overflows, the **oldest** events are dropped (FIFO).
+
+The buffer survives agent restarts and internet outages; it does NOT
+survive router reboots (it's in tmpfs), which is acceptable since
+network events from a rebooting router are stale anyway.
+
 ## Project Structure
 ## Project Structure
 
 
 ```
 ```
@@ -255,7 +274,8 @@ client2server/
 │   │   └── etc/config/client2server
 │   │   └── etc/config/client2server
 │   └── hotplug/
 │   └── hotplug/
 │       ├── 01-wifi         # /etc/hotplug.d/wireless/ - instant WiFi events
 │       ├── 01-wifi         # /etc/hotplug.d/wireless/ - instant WiFi events
-│       └── 02-dhcp         # /etc/hotplug.d/dhcp/     - instant DHCP events
+│       ├── 02-dhcp         # /etc/hotplug.d/dhcp/     - instant DHCP events
+│       └── _lib.sh         # /usr/share/client2server/hotplug-lib.sh (shared buffer/spool)
 ├── server/                 # Go server
 ├── server/                 # Go server
 │   ├── main.go             # WS handler + HTTP API + ingestEvent
 │   ├── main.go             # WS handler + HTTP API + ingestEvent
 │   ├── auth.go             # JWT + scrypt + login + middleware
 │   ├── auth.go             # JWT + scrypt + login + middleware

+ 4 - 0
package/Makefile

@@ -63,6 +63,10 @@ define Package/$(PKG_NAME)/install
 	$(INSTALL_BIN) ./hotplug/01-wifi $(1)/etc/hotplug.d/wireless/01-wifi
 	$(INSTALL_BIN) ./hotplug/01-wifi $(1)/etc/hotplug.d/wireless/01-wifi
 	$(INSTALL_DIR) $(1)/etc/hotplug.d/dhcp
 	$(INSTALL_DIR) $(1)/etc/hotplug.d/dhcp
 	$(INSTALL_BIN) ./hotplug/02-dhcp $(1)/etc/hotplug.d/dhcp/02-dhcp
 	$(INSTALL_BIN) ./hotplug/02-dhcp $(1)/etc/hotplug.d/dhcp/02-dhcp
+
+	# Shared hotplug lib (sourced by all hotplug scripts)
+	$(INSTALL_DIR) $(1)/usr/share/client2server
+	$(INSTALL_DATA) ./hotplug/_lib.sh $(1)/usr/share/client2server/hotplug-lib.sh
 endef
 endef
 
 
 $(eval $(call BuildPackage,$(PKG_NAME)))
 $(eval $(call BuildPackage,$(PKG_NAME)))

+ 11 - 2
package/files/etc/init.d/client2server

@@ -7,8 +7,9 @@
 START=95
 START=95
 STOP=10
 STOP=10
 NAME=client2server
 NAME=client2server
-PIDFILE="/var/run/${NAME}.pid"
-ENV_FILE="/var/run/${NAME}.env"
+STATE_DIR="/var/run/${NAME}"
+PIDFILE="${STATE_DIR}/pid"
+ENV_FILE="${STATE_DIR}/env"
 
 
 start() {
 start() {
     logger -t "$NAME" -p user.info "Starting $NAME..."
     logger -t "$NAME" -p user.info "Starting $NAME..."
@@ -19,6 +20,10 @@ start() {
         exit 1
         exit 1
     fi
     fi
 
 
+    # Ensure state dir exists (shared with hotplug scripts)
+    mkdir -p "$STATE_DIR"
+    chmod 0755 "$STATE_DIR"
+
     # Check if enabled
     # Check if enabled
     local enabled
     local enabled
     enabled=$(uci get client2server.general.enabled 2>/dev/null)
     enabled=$(uci get client2server.general.enabled 2>/dev/null)
@@ -68,6 +73,10 @@ stop() {
         rm -f $PIDFILE
         rm -f $PIDFILE
     fi
     fi
     rm -f "$ENV_FILE" 2>/dev/null
     rm -f "$ENV_FILE" 2>/dev/null
+    # NOTE: keep STATE_DIR and buffer file across restarts so queued
+    # events survive an agent restart. They live in /var/run (tmpfs),
+    # so they will still be lost on reboot - which is acceptable since
+    # the events are transient network events.
     logger -t "$NAME" -p user.info "Stopped"
     logger -t "$NAME" -p user.info "Stopped"
 }
 }
 
 

+ 24 - 45
package/hotplug/01-wifi

@@ -2,61 +2,40 @@
 # WiFi hotplug script - triggers on wireless events
 # WiFi hotplug script - triggers on wireless events
 # Install to /etc/hotplug.d/wireless/
 # Install to /etc/hotplug.d/wireless/
 #
 #
-# Event names match package/src/client2server-unified.lua:
+# Events emitted (must match client2server-unified.lua's handler):
 #   wifi_connected / wifi_disconnected
 #   wifi_connected / wifi_disconnected
-
-# Source UCI config (set by /etc/init.d/client2server)
-SERVER_URL="${SERVER_URL:-}"
-ROUTER_ID="${ROUTER_ID:-}"
-TOKEN="${TOKEN:-}"
-
-if [ -z "$SERVER_URL" ] || [ -z "$ROUTER_ID" ]; then
-    # Fall back to UCI directly (when called outside init.d context)
-    SERVER_URL=$(uci get client2server.server.url 2>/dev/null)
-    ROUTER_ID=$(uci get client2server.router.id 2>/dev/null)
-    TOKEN=$(uci get client2server.server.token 2>/dev/null)
+#
+# Events are spooled to /var/run/client2server/buffer and the Lua agent
+# is woken via SIGHUP. If the agent is offline (internet down), events
+# accumulate on disk and are flushed on reconnect.
+
+SCRIPT_DIR="$(dirname "$0")"
+# Source the shared lib from its canonical install location.
+# Falls back to the development path (hotplug/_lib.sh) for tests.
+if [ -f /usr/share/client2server/hotplug-lib.sh ]; then
+    # shellcheck disable=SC1091
+    . /usr/share/client2server/hotplug-lib.sh
+elif [ -f "$SCRIPT_DIR/_lib.sh" ]; then
+    # shellcheck disable=SC1091
+    . "$SCRIPT_DIR/_lib.sh"
+else
+    logger -t client2server-hotplug -p user.err "hotplug-lib.sh not found"
+    exit 1
 fi
 fi
 
 
-# Final fallback (sane default)
-SERVER_URL="${SERVER_URL:-http://127.0.0.1:3843}"
-ROUTER_ID="${ROUTER_ID:-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo unknown)}"
-
-log() {
-    logger -t client2server-hotplug -p user.info "$1"
-}
-
-send_event() {
-    local event_type="$1"
-    local data="$2"
-
-    json="{\"router_id\":\"$ROUTER_ID\",\"event\":\"$event_type\",\"data\":$data}"
-
-    if [ -n "$TOKEN" ]; then
-        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
-            -H "Content-Type: application/json" \
-            -H "Authorization: Bearer $TOKEN" \
-            -d "$json" >/dev/null 2>&1
-    else
-        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
-            -H "Content-Type: application/json" \
-            -d "$json" >/dev/null 2>&1
-    fi
-
-    log "Sent: $event_type"
-}
-
-# Handle WiFi client association
 case "$ACTION" in
 case "$ACTION" in
     associate|associated)
     associate|associated)
         if [ -n "$MACADDR" ]; then
         if [ -n "$MACADDR" ]; then
-            log "WiFi CONNECTED: $MACADDR on $INTERFACE"
-            send_event "wifi_connected" "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
+            log_msg "WiFi CONNECTED: $MACADDR on $INTERFACE"
+            send_event "wifi_connected" \
+                "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
         fi
         fi
         ;;
         ;;
     disassociate|disassociated|deauth|expired)
     disassociate|disassociated|deauth|expired)
         if [ -n "$MACADDR" ]; then
         if [ -n "$MACADDR" ]; then
-            log "WiFi DISCONNECTED: $MACADDR on $INTERFACE"
-            send_event "wifi_disconnected" "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
+            log_msg "WiFi DISCONNECTED: $MACADDR on $INTERFACE"
+            send_event "wifi_disconnected" \
+                "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
         fi
         fi
         ;;
         ;;
 esac
 esac

+ 29 - 47
package/hotplug/02-dhcp

@@ -2,64 +2,46 @@
 # DHCP hotplug script - triggers on DHCP events
 # DHCP hotplug script - triggers on DHCP events
 # Install to /etc/hotplug.d/dhcp/
 # Install to /etc/hotplug.d/dhcp/
 #
 #
-# Event names match package/src/client2server-unified.lua:
+# Events emitted (must match client2server-unified.lua's handler):
 #   dhcp_lease_new / dhcp_lease_expire
 #   dhcp_lease_new / dhcp_lease_expire
-
-# Source UCI config (set by /etc/init.d/client2server)
-SERVER_URL="${SERVER_URL:-}"
-ROUTER_ID="${ROUTER_ID:-}"
-TOKEN="${TOKEN:-}"
-
-if [ -z "$SERVER_URL" ] || [ -z "$ROUTER_ID" ]; then
-    # Fall back to UCI directly (when called outside init.d context)
-    SERVER_URL=$(uci get client2server.server.url 2>/dev/null)
-    ROUTER_ID=$(uci get client2server.router.id 2>/dev/null)
-    TOKEN=$(uci get client2server.server.token 2>/dev/null)
+#
+# Events are spooled to /var/run/client2server/buffer and the Lua agent
+# is woken via SIGHUP. If the agent is offline (internet down), events
+# accumulate on disk and are flushed on reconnect.
+
+SCRIPT_DIR="$(dirname "$0")"
+# Source the shared lib from its canonical install location.
+# Falls back to the development path (hotplug/_lib.sh) for tests.
+if [ -f /usr/share/client2server/hotplug-lib.sh ]; then
+    # shellcheck disable=SC1091
+    . /usr/share/client2server/hotplug-lib.sh
+elif [ -f "$SCRIPT_DIR/_lib.sh" ]; then
+    # shellcheck disable=SC1091
+    . "$SCRIPT_DIR/_lib.sh"
+else
+    logger -t client2server-hotplug -p user.err "hotplug-lib.sh not found"
+    exit 1
 fi
 fi
 
 
-# Final fallback (sane default)
-SERVER_URL="${SERVER_URL:-http://127.0.0.1:3843}"
-ROUTER_ID="${ROUTER_ID:-$(cat /proc/sys/kernel/hostname 2>/dev/null || echo unknown)}"
-
-log() {
-    logger -t client2server-hotplug -p user.info "$1"
-}
-
-send_event() {
-    local event_type="$1"
-    local data="$2"
-
-    json="{\"router_id\":\"$ROUTER_ID\",\"event\":\"$event_type\",\"data\":$data}"
-
-    if [ -n "$TOKEN" ]; then
-        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
-            -H "Content-Type: application/json" \
-            -H "Authorization: Bearer $TOKEN" \
-            -d "$json" >/dev/null 2>&1
-    else
-        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
-            -H "Content-Type: application/json" \
-            -d "$json" >/dev/null 2>&1
-    fi
-
-    log "Sent: $event_type"
-}
-
 # Handle DHCP events
 # Handle DHCP events
-#   add      - new lease (or renew with new IP)
-#   del      - lease released
-#   remove   - same as del, alias used by some builds
+#   add    - new lease (or renew with new IP)
+#   update - lease renewed (same IP, new expiry)
+#   old    - lease rebind/old
+#   del    - lease released
+#   remove - alias for del in some builds
 case "$ACTION" in
 case "$ACTION" in
     add|update|old)
     add|update|old)
         if [ -n "$MAC" ] && [ -n "$IP" ]; then
         if [ -n "$MAC" ] && [ -n "$IP" ]; then
-            log "DHCP LEASE NEW: $MAC -> $IP ($HOSTNAME)"
-            send_event "dhcp_lease_new" "{\"mac\":\"$MAC\",\"ip\":\"$IP\",\"hostname\":\"$HOSTNAME\"}"
+            log_msg "DHCP LEASE NEW: $MAC -> $IP ($HOSTNAME)"
+            send_event "dhcp_lease_new" \
+                "{\"mac\":\"$MAC\",\"ip\":\"$IP\",\"hostname\":\"$HOSTNAME\"}"
         fi
         fi
         ;;
         ;;
     del|remove)
     del|remove)
         if [ -n "$MAC" ] && [ -n "$IP" ]; then
         if [ -n "$MAC" ] && [ -n "$IP" ]; then
-            log "DHCP LEASE EXPIRE: $MAC -> $IP"
-            send_event "dhcp_lease_expire" "{\"mac\":\"$MAC\",\"old_ip\":\"$IP\"}"
+            log_msg "DHCP LEASE EXPIRE: $MAC -> $IP"
+            send_event "dhcp_lease_expire" \
+                "{\"mac\":\"$MAC\",\"old_ip\":\"$IP\"}"
         fi
         fi
         ;;
         ;;
 esac
 esac

+ 128 - 0
package/hotplug/_lib.sh

@@ -0,0 +1,128 @@
+#!/bin/sh
+# Shared helpers for client2server hotplug scripts.
+# Source this file from 01-wifi / 02-dhcp / etc.
+#
+# Behavior:
+#   - Loads UCI config (SERVER_URL / TOKEN / ROUTER_ID).
+#   - Provides send_event() that:
+#       1. Appends the JSON event to a shared on-disk buffer.
+#       2. Wakes the Lua agent via SIGHUP so it flushes ASAP.
+#       3. Falls back to a direct curl POST if the buffer is not writable
+#          (e.g. /var/run not yet mounted in early boot).
+#
+# Shared buffer location: /var/run/client2server/buffer
+#   - Same format as client2server-unified.lua's buffer file
+#     (one JSON event per line, NDJSON).
+#   - The Lua agent's buffer.init() reads it on startup, and
+#     buffer.flush() drains it on every reconnect / SIGHUP.
+
+CLIENT2SERVER_DIR="${CLIENT2SERVER_DIR:-/var/run/client2server}"
+BUFFER_FILE="${BUFFER_FILE:-${CLIENT2SERVER_DIR}/buffer}"
+PID_FILE="${PID_FILE:-${CLIENT2SERVER_DIR}/pid}"
+ENV_FILE="${ENV_FILE:-${CLIENT2SERVER_DIR}/env}"
+
+# Ensure runtime dir exists
+mkdir -p "$CLIENT2SERVER_DIR" 2>/dev/null
+
+# Load config: prefer env file (set by init.d), fall back to UCI
+if [ -f "$ENV_FILE" ]; then
+    # shellcheck disable=SC1090
+    . "$ENV_FILE"
+fi
+
+: "${SERVER_URL:=$(uci get client2server.server.url 2>/dev/null)}"
+: "${ROUTER_ID:=$(uci get client2server.router.id 2>/dev/null)}"
+: "${TOKEN:=$(uci get client2server.server.token 2>/dev/null)}"
+
+# Final fallbacks
+: "${SERVER_URL:=http://127.0.0.1:3843}"
+: "${ROUTER_ID:=$(cat /proc/sys/kernel/hostname 2>/dev/null || echo unknown)}"
+
+log_msg() {
+    logger -t client2server-hotplug -p user.info "$1"
+}
+
+log_err() {
+    logger -t client2server-hotplug -p user.err "$1"
+}
+
+# Build a JSON event in the same shape the Lua agent uses.
+# Args: event_type, data_json
+build_event_json() {
+    local event_type="$1"
+    local data="$2"
+    printf '{"router_id":"%s","event":"%s","data":%s}' \
+        "$ROUTER_ID" "$event_type" "$data"
+}
+
+# Append to disk buffer (atomic-ish via mv from a temp file).
+# Returns 0 on success, 1 on failure.
+spool_event() {
+    local json="$1"
+    local tmp
+    tmp=$(mktemp "${BUFFER_FILE}.XXXXXX") || return 1
+    if ! printf '%s\n' "$json" >> "$tmp"; then
+        rm -f "$tmp" 2>/dev/null
+        return 1
+    fi
+    if ! cat "$tmp" >> "$BUFFER_FILE" 2>/dev/null; then
+        rm -f "$tmp" 2>/dev/null
+        return 1
+    fi
+    rm -f "$tmp" 2>/dev/null
+    return 0
+}
+
+# Wake the Lua agent (best-effort).
+# We touch `<state_dir>/wake`; the agent's main loop polls for it
+# and triggers an immediate buffer flush. This is more portable than
+# SIGHUP across Lua 5.1/5.2/LuaJIT builds.
+wake_agent() {
+    touch "$CLIENT2SERVER_DIR/wake" 2>/dev/null || true
+    # Also try SIGHUP as a fast path (harmless if PID is stale or
+    # the agent doesn't install a handler).
+    if [ -f "$PID_FILE" ]; then
+        local pid
+        pid=$(cat "$PID_FILE" 2>/dev/null)
+        if [ -n "$pid" ] && [ "$pid" -gt 0 ] 2>/dev/null; then
+            kill -HUP "$pid" 2>/dev/null || true
+        fi
+    fi
+}
+
+# Direct HTTP POST fallback. Args: json_event
+direct_post() {
+    local json="$1"
+    if [ -n "$TOKEN" ]; then
+        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
+            -H "Content-Type: application/json" \
+            -H "Authorization: Bearer $TOKEN" \
+            -d "$json" >/dev/null 2>&1
+    else
+        curl -s -m 3 -X POST "$SERVER_URL/api/events" \
+            -H "Content-Type: application/json" \
+            -d "$json" >/dev/null 2>&1
+    fi
+}
+
+# Main entry point. Args: event_type, data_json
+# Behavior:
+#   1. Try to spool to disk + wake agent (preferred: survives outages).
+#   2. If spooling fails, do a one-shot direct POST (best effort, may drop).
+send_event() {
+    local event_type="$1"
+    local data="$2"
+    local json
+    json=$(build_event_json "$event_type" "$data")
+
+    if spool_event "$json"; then
+        log_msg "Spooled: $event_type"
+        wake_agent
+        return 0
+    fi
+
+    # Fallback: try direct POST (may be lost if offline)
+    log_err "Spool failed, falling back to direct POST: $event_type"
+    direct_post "$json"
+    log_msg "Sent (fallback): $event_type"
+}

+ 54 - 12
package/src/client2server-unified.lua

@@ -16,9 +16,12 @@ local cfg = {
     check_interval = 5,
     check_interval = 5,
     log_level = "info",
     log_level = "info",
     debug = false,
     debug = false,
-    buffer_file = "/tmp/event_buffer",
-    max_buffer = 100,
-    pid_file = "/var/run/client2server.pid",
+    -- Shared buffer (also written by hotplug scripts)
+    state_dir    = "/var/run/client2server",
+    buffer_file  = "/var/run/client2server/buffer",
+    pid_file     = "/var/run/client2server/pid",
+    env_file     = "/var/run/client2server/env",
+    max_buffer   = 1000,
     -- Server
     -- Server
     server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
     server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
     server_token = os.getenv("SERVER_TOKEN") or "secret-token",
     server_token = os.getenv("SERVER_TOKEN") or "secret-token",
@@ -51,6 +54,11 @@ pcall(function()
     cfg.debug = uci:get("client2server", "general", "debug") == "1"
     cfg.debug = uci:get("client2server", "general", "debug") == "1"
     cfg.buffer_file = uci:get("client2server", "general", "buffer_file") or cfg.buffer_file
     cfg.buffer_file = uci:get("client2server", "general", "buffer_file") or cfg.buffer_file
     cfg.max_buffer = tonumber(uci:get("client2server", "general", "max_buffer")) or cfg.max_buffer
     cfg.max_buffer = tonumber(uci:get("client2server", "general", "max_buffer")) or cfg.max_buffer
+    cfg.state_dir  = uci:get("client2server", "general", "state_dir") or cfg.state_dir
+
+    -- Ensure state dir exists (shared with hotplug scripts)
+    os.execute("mkdir -p '" .. cfg.state_dir .. "' 2>/dev/null")
+    os.execute("chmod 0755 '" .. cfg.state_dir .. "' 2>/dev/null")
     -- Server
     -- Server
     cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
     cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
     cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
     cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
@@ -138,6 +146,26 @@ local function json_decode(str)
     return result
     return result
 end
 end
 
 
+-- ============================================================================
+-- WAKE FILE (hotplug -> agent signaling)
+-- ============================================================================
+-- Plain Lua 5.1 (used by OpenWrt) has no portable signal() API, so we
+-- use a polling-based wake file: hotplug scripts touch
+-- `<state_dir>/wake` after spooling an event, and the main loop checks
+-- for it on every iteration. This works on any Lua build and keeps the
+-- wake latency bounded by the main loop's 1s sleep.
+
+local signal_pending = { flush = false }
+
+local function check_wake_file()
+    local f = io.open(cfg.state_dir .. "/wake", "r")
+    if f then
+        f:close()
+        os.remove(cfg.state_dir .. "/wake")
+        signal_pending.flush = true
+    end
+end
+
 -- ============================================================================
 -- ============================================================================
 -- BUFFER
 -- BUFFER
 -- ============================================================================
 -- ============================================================================
@@ -674,7 +702,13 @@ local function co_connect()
             -- Even when connected, periodically flush to clear buffer buildup
             -- Even when connected, periodically flush to clear buffer buildup
             if cfg.debug then log("debug", "FLUSH buffer") end
             if cfg.debug then log("debug", "FLUSH buffer") end
                 buffer.flush(function(d) return ws.send(d) end)
                 buffer.flush(function(d) return ws.send(d) end)
-            coroutine.yield(30)
+            -- Short yield if a hotplug wake is pending; otherwise idle 30s
+            local idle = 30
+            if signal_pending.flush then
+                signal_pending.flush = false
+                idle = 0
+            end
+            coroutine.yield(idle)
         end
         end
     end
     end
 end
 end
@@ -717,7 +751,10 @@ local function scheduler()
             if cfg.debug then log("debug", "FLUSH buffer") end
             if cfg.debug then log("debug", "FLUSH buffer") end
                 buffer.flush(function(d) return ws.send(d) end)
                 buffer.flush(function(d) return ws.send(d) end)
         end
         end
-        
+
+        -- Check for wake file (hotplug-spooled events) - poll-based signal
+        check_wake_file()
+
         os.execute("sleep 1")
         os.execute("sleep 1")
     end
     end
 end
 end
@@ -726,15 +763,20 @@ local function main()
     log_info("Starting client2server...")
     log_info("Starting client2server...")
     log_info("Router: " .. cfg.router_id)
     log_info("Router: " .. cfg.router_id)
     log_info("Server: " .. cfg.server_url)
     log_info("Server: " .. cfg.server_url)
-    
+
+    -- Ensure state dir exists
+    os.execute("mkdir -p '" .. cfg.state_dir .. "' 2>/dev/null")
+
     buffer.init()
     buffer.init()
-    
-    -- Write PID file
-    local f = io.popen("echo $", "r")
-    local pid = f and (f:read("*a") or "") or "0"
-    if f then f:close() end
+
+    -- Write our PID file so hotplug scripts can signal us.
+    -- We shell out to read /proc/self (works on OpenWrt/Linux) since
+    -- plain Lua 5.1 has no portable way to get the current PID.
+    local pidf = io.popen("cat /proc/self/stat 2>/dev/null | awk '{print $1}'")
+    local my_pid = pidf and pidf:read("*l") or "0"
+    if pidf then pidf:close() end
     local pf = io.open(cfg.pid_file, "w")
     local pf = io.open(cfg.pid_file, "w")
-    if pf then pf:write(pid); pf:close() end
+    if pf then pf:write(tostring(my_pid or "0")); pf:close() end
     
     
     -- Connect
     -- Connect
     -- Seed random
     -- Seed random