浏览代码

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 月之前
父节点
当前提交
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"]
         direction TB
         Lua[("client2server-unified.lua")]
-        Hotplug[("/etc/hotplug.d/* scripts")]
+        Hotplug[("/etc/hotplug.d/{wireless,dhcp}/* scripts")]
         Wifi[("wifi_connected / disconnected")]
         DHCP[("dhcp_lease_new / expire")]
         WAN[("wan_link / dhcp events")]
         CMD[("Command Executor")]
-        Buf[("Event buffer
-/tmp/event_buffer")]
+        Shared[("Shared buffer
+/var/run/client2server/buffer")]
+        Wake[("wake file
+/var/run/client2server/wake")]
     end
 
     subgraph Edge["Reverse Proxy / LB"]
@@ -63,9 +65,12 @@ Events / Commands / Alerts")]
     Router -->|WebSocket :3843| Caddy
     Caddy -->|lb| S1
     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| RCommands
     S2 -->|publish| REvents
@@ -82,6 +87,12 @@ Events / Commands / Alerts")]
     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:**
 
 | Plane | What flows | Direction |
@@ -100,27 +111,33 @@ Events / Commands / Alerts")]
 sequenceDiagram
     participant R as Router
     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 DB as SQLite
     participant RP as Redpanda
     participant D as Dashboard (SSE)
     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)
-        H->>S: POST /api/events (curl)
+        H->>Buf: append NDJSON event
+        H->>L: touch wake file
     and Lua state-diff (≤1s)
-        R->>S: WS frame: {type: event, ...}
+        L->>L: detect (WAN/DHCP/state change)
     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->>DB: INSERT INTO events
     S->>RP: publish (fire-and-forget)
     S->>D: SSE push to all subscribers
     RP-->>C: consume (any Kafka client)
-    S-->>R: ACK
-    S-->>H: 200 OK
+    S-->>L: ACK
 ```
 
 ### 2. Command Flow (Dashboard → Router)
@@ -194,6 +211,64 @@ sequenceDiagram
     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
@@ -305,24 +380,32 @@ fetch + JWT header")]
 flowchart LR
     subgraph Router["OpenWrt"]
         Init[("/etc/init.d/client2server")]
+        State[("State dir
+/var/run/client2server/
+{buffer,pid,env,wake}")]
         Lua[("client2server-unified.lua")]
+        Lib[("hotplug-lib.sh
+/usr/share/client2server/")]
         HP1[("hotplug.d/wireless/01-wifi")]
         HP2[("hotplug.d/dhcp/02-dhcp")]
         UCI[("/etc/config/client2server")]
         UB[("UCI store")]
-        Buf[("/tmp/event_buffer")]
         WScli[("WebSocket client
-(mosquitto/lua-websockets)")]
+(luasocket)")]
         CMDex[("Command executor")]
     end
 
-    Init --> Lua
+    Init -->|create state dir| State
+    Init -->|export UCI env| State
+    Init -->|launch| 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
     WScli --> CMDex
     CMDex --> UB
@@ -574,7 +657,7 @@ client2server/
 ├── package/           # OpenWrt IPK build
 │   ├── src/client2server-unified.lua
 │   ├── 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)
 │   ├── main.go        # HTTP/WS routes + ingest
 │   ├── auth.go        # JWT + scrypt

+ 36 - 8
MEMORY.md

@@ -5,7 +5,8 @@
 
 ## Purpose
 - 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 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.
@@ -14,7 +15,7 @@
 | Layer | Tech |
 |-------|------|
 | 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 |
 | WebSocket lib | `github.com/coder/websocket` v1.8.13 |
 | Event bus | Redpanda (Kafka-compatible) :9092 |
@@ -37,7 +38,7 @@ client2server/
 │   ├── Makefile
 │   ├── src/client2server-unified.lua
 │   ├── files/etc/{config,init.d}/client2server
-│   └── hotplug/{01-wifi,02-dhcp}
+│   └── hotplug/{01-wifi,02-dhcp,_lib.sh}
 ├── server/              # Go server
 │   ├── main.go          # WS handler + HTTP API + ingestEvent
 │   ├── auth.go          # JWT + scrypt + login + middleware
@@ -94,6 +95,31 @@ client2server/
 | `wifi_connected` | 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)
 | 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/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 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"
 ```
 
@@ -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()`.
 
 ## 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)
 - `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
 - `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)
+- *(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
 - ⚠️ 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                                               │
-│       │  WebSocket :3843  +  Hotplug (curl)         
+│       │  WebSocket :3843  +  Hotplug (shared buffer)
 │       ▼                                                          │
 │   ┌──────────────┐                                              │
 │   │ 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)
 - ✅ **luv-based** async state-diff for WAN link, DHCP renew, IP changes (≤1s latency)
 - ✅ **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)
 
 ### 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/hotplug/01-wifi root@router:/etc/hotplug.d/wireless/
 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"
 ```
 
@@ -224,7 +226,7 @@ dashboard). The router doesn't need a JWT — it sends the shared `TOKEN` direct
 
 ## Events
 
-Router sends these events to server:
+Router sends these events to server (via the shared on-disk buffer):
 
 | Event | Source | Payload |
 |-------|--------|---------|
@@ -237,6 +239,23 @@ Router sends these events to server:
 | `wifi_connected` | 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
 
 ```
@@ -255,7 +274,8 @@ client2server/
 │   │   └── etc/config/client2server
 │   └── hotplug/
 │       ├── 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
 │   ├── main.go             # WS handler + HTTP API + ingestEvent
 │   ├── 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_DIR) $(1)/etc/hotplug.d/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
 
 $(eval $(call BuildPackage,$(PKG_NAME)))

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

@@ -7,8 +7,9 @@
 START=95
 STOP=10
 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() {
     logger -t "$NAME" -p user.info "Starting $NAME..."
@@ -19,6 +20,10 @@ start() {
         exit 1
     fi
 
+    # Ensure state dir exists (shared with hotplug scripts)
+    mkdir -p "$STATE_DIR"
+    chmod 0755 "$STATE_DIR"
+
     # Check if enabled
     local enabled
     enabled=$(uci get client2server.general.enabled 2>/dev/null)
@@ -68,6 +73,10 @@ stop() {
         rm -f $PIDFILE
     fi
     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"
 }
 

+ 24 - 45
package/hotplug/01-wifi

@@ -2,61 +2,40 @@
 # WiFi hotplug script - triggers on wireless events
 # 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
-
-# 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
 
-# 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
     associate|associated)
         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
         ;;
     disassociate|disassociated|deauth|expired)
         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
         ;;
 esac

+ 29 - 47
package/hotplug/02-dhcp

@@ -2,64 +2,46 @@
 # DHCP hotplug script - triggers on DHCP events
 # 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
-
-# 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
 
-# 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
-#   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
     add|update|old)
         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
         ;;
     del|remove)
         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
         ;;
 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,
     log_level = "info",
     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_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
     server_token = os.getenv("SERVER_TOKEN") or "secret-token",
@@ -51,6 +54,11 @@ pcall(function()
     cfg.debug = uci:get("client2server", "general", "debug") == "1"
     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.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
     cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
     cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
@@ -138,6 +146,26 @@ local function json_decode(str)
     return result
 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
 -- ============================================================================
@@ -674,7 +702,13 @@ local function co_connect()
             -- Even when connected, periodically flush to clear buffer buildup
             if cfg.debug then log("debug", "FLUSH buffer") 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
@@ -717,7 +751,10 @@ local function scheduler()
             if cfg.debug then log("debug", "FLUSH buffer") end
                 buffer.flush(function(d) return ws.send(d) end)
         end
-        
+
+        -- Check for wake file (hotplug-spooled events) - poll-based signal
+        check_wake_file()
+
         os.execute("sleep 1")
     end
 end
@@ -726,15 +763,20 @@ local function main()
     log_info("Starting client2server...")
     log_info("Router: " .. cfg.router_id)
     log_info("Server: " .. cfg.server_url)
-    
+
+    -- Ensure state dir exists
+    os.execute("mkdir -p '" .. cfg.state_dir .. "' 2>/dev/null")
+
     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")
-    if pf then pf:write(pid); pf:close() end
+    if pf then pf:write(tostring(my_pid or "0")); pf:close() end
     
     -- Connect
     -- Seed random