Pārlūkot izejas kodu

Cleanup: remove legacy code, fix event names, wire UCI to hotplug

- Remove legacy event-forwarder (etc/, usr/) - predecessor project, unused
- Remove package/src/client2server-minimal.lua and client2server-ws.lua - not wired into init.d
- Remove server/index.js and server-ws.js - Node fallback, superseded by main.go
- Fix event names in hotplug scripts to match unified.lua vocabulary:
  dhcp_new       -> dhcp_lease_new
  dhcp_release   -> dhcp_lease_expire
  (wifi_* were already aligned)
- hotplug scripts: read UCI config (SERVER_URL, TOKEN, ROUTER_ID) instead of
  hardcoded fallback; add -m 3 timeout, Bearer auth, more WiFi action cases
- init.d/client2server: write UCI values to /var/run/client2server.env and
  export to Lua's environment; clean up env file on stop
- Makefile: include hotplug scripts in IPK build
- Caddyfile: comment out placeholder email (auto_https off anyway)
- README: update project structure, install steps, event table
- MEMORY: new file - project memory with architecture, deployment, history
Gogs 2 mēneši atpakaļ
vecāks
revīzija
6d6780c687

+ 1 - 1
Caddyfile

@@ -3,7 +3,7 @@
 
 {
     # Global options
-    email your@email.com
+    # email your@email.com
     admin off
     auto_https off
 }

+ 136 - 0
MEMORY.md

@@ -0,0 +1,136 @@
+# client2server — Project Memory
+
+> Bi-directional event forwarder: OpenWrt routers → Go server → Redpanda → LuIS backend.
+
+## 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.
+- Server fans out via Redpanda topics; LuIS backend consumes.
+- Server can also push commands back to routers (uci_set, shell, reboot, wifi_restart, status).
+
+## Stack
+| Layer | Tech |
+|-------|------|
+| Router client | Lua (OpenWrt), `client2server-unified.lua` |
+| Hotplug paths | Shell + curl, `/etc/hotplug.d/{wireless,dhcp}/*` |
+| Transport | WebSocket :3843 (Caddy LB → 2× Go servers) |
+| API | HTTP :3844 (Caddy → server1) |
+| Event bus | Redpanda (Kafka-compatible) :9092 |
+| Server | Go (`nhooyr.io/websocket`, `redpanda-data/redpanda-sdk-go`) |
+
+## Repo Layout (current)
+```
+client2server/
+├── ARCHITECTURE.md      # Mermaid diagrams, full spec
+├── README.md            # User-facing docs
+├── MEMORY.md            # ← you are here
+├── Caddyfile            # LB + reverse proxy
+├── docker-compose.yml   # redpanda + 2× server + caddy
+├── package/
+│   ├── Makefile         # IPK build (includes hotplug)
+│   ├── src/
+│   │   └── client2server-unified.lua   # CANONICAL Lua client
+│   ├── files/
+│   │   ├── etc/config/client2server    # UCI defaults
+│   │   └── etc/init.d/client2server    # Procd init script (exports UCI → env)
+│   └── hotplug/
+│       ├── 01-wifi      # wireless hotplug → wifi_connected/disconnected
+│       └── 02-dhcp      # dhcp hotplug → dhcp_lease_new/expire
+└── server/
+    ├── main.go          # WS handler + Redpanda producer/consumer
+    ├── go.mod
+    └── Dockerfile
+```
+
+**Removed in 2026-06-09 cleanup:** `package/src/{minimal,ws}.lua`, legacy `etc/` and `usr/` (predecessor `event-forwarder`), `server/{index,server-ws}.js` (Node fallback). All in git history if needed.
+
+## Event Types (router → server)
+| Event | Source | Payload |
+|-------|--------|---------|
+| `dhcp_lease_new` | dnsmasq (luv timer + hotplug) | mac, ip, hostname |
+| `dhcp_lease_expire` | dnsmasq (luv timer + hotplug) | mac, old_ip |
+| `wan_link_up` | /sys/class/net/* | device |
+| `wan_link_down` | /sys/class/net/* | device |
+| `wan_dhcp_new` | ubus | new_ip |
+| `wan_dhcp_changed` | ubus | old_ip, new_ip |
+| `wifi_connected` | hostapd hotplug | mac, interface |
+| `wifi_disconnected` | hostapd hotplug | mac, interface |
+
+✅ **All event names are now consistent** (hotplug scripts renamed to match unified.lua vocabulary on 2026-06-09).
+
+## Commands (server → router)
+| Command | Args |
+|---------|------|
+| `uci_set` | config, section, option, value |
+| `shell` | command |
+| `reboot` | — |
+| `wifi_restart` | — |
+| `status` | — |
+
+## Ports
+| Service | Port | Notes |
+|---------|------|-------|
+| WebSocket LB | 3843 | Routers connect here |
+| HTTP API | 3844 | REST + health |
+| Redpanda Kafka | 9092 | Internal |
+| Redpanda REST | 8082 | Schema/management |
+| Redpanda Schema | 8081 | Schema registry |
+
+## Quick Run
+```bash
+# Full stack
+cd /root/.openclaw/workspace/client2server
+TOKEN=*** docker-compose up -d
+
+# Server only
+cd server && go build -o server . && \
+  REDPANDA_BROKERS=localhost:9092 TOKEN=*** ./server
+
+# Install on router (manual)
+scp package/src/client2server-unified.lua root@router:/usr/sbin/
+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"
+ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
+
+# Or build IPK
+make package/client2server-unified/ipk
+```
+
+## Architecture: Hybrid Event Delivery
+The router has **two parallel event paths** to the server:
+
+1. **Hotplug path (instant)** — kernel fires, shell runs, curl POSTs
+   - `01-wifi` for WiFi connect/disconnect
+   - `02-dhcp` for DHCP lease add/del
+   - Latency: ~10ms
+
+2. **Lua state-diff path (≤1s)** — luv async loop polls state, sends diffs
+   - SSID name changes
+   - `wan_link_up/down`
+   - `wan_dhcp_new/changed`
+   - `dhcp_lease_new/expire` (fallback / redundancy with hotplug)
+
+Both paths post to the same `POST /api/events` endpoint, so server-side sees one event stream.
+
+## Environment Wiring (init.d → hotplug)
+- `init.d/client2server` reads UCI on `start()` and writes `/var/run/client2server.env`
+- Exports `SERVER_URL`, `TOKEN`, `ROUTER_ID` to Lua's environment
+- Hotplug scripts also `uci get` directly as fallback (in case called outside init.d context)
+- Env file removed on `stop()`
+
+## Client Evolution
+The Lua client went through ~6 architectural rewrites in 2 days (Jun 7 2026) trying to kill polling. Final settled state = hybrid hotplug + luv state-diff. See git log `9e248cd..0b6d8ff` for the iteration history.
+
+## Known Issues / TODO
+- ⚠️ Caddyfile email is commented out — set real value if enabling `auto_https`
+- ⚠️ UCI default `wss://your-server.com/ws` is a placeholder — must be edited per-deployment
+- ⚠️ Docker-compose TOKEN is the literal string `***` — override via env or `.env` file
+- `unified.lua` is 759 lines — worth splitting into modules (DHCP/WiFi/WAN/WS/CMD) but functional as-is
+- No tests for the Go server; would benefit from integration tests using a mock WebSocket client
+- `command_id` idempotency + per-router command queue are server-side features (per commits 4134845, 7c85b33) — verify they work end-to-end
+
+## Author
+Luis Rosales — MIT License 2026

+ 20 - 10
README.md

@@ -74,9 +74,14 @@ cd server
 go build -o server .
 REDPANDA_BROKERS=localhost:9092 TOKEN=*** ./server
 
-# On OpenWrt router (copy Lua script)
+# On OpenWrt router (copy all files)
 scp package/src/client2server-unified.lua root@router:/usr/sbin/
-ssh root@router "chmod +x /usr/sbin/client2server-unified.lua"
+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"
+ssh root@router "/etc/init.d/client2server enable && /etc/init.d/client2server start"
 ```
 
 ## Ports
@@ -150,12 +155,14 @@ Router sends these events to server:
 
 | Event | Source | Payload |
 |-------|--------|---------|
-| `dhcp_lease_new` | dnsmasq | `mac`, `ip`, `hostname` |
-| `dhcp_lease_expire` | dnsmasq | `mac`, `old_ip` |
+| `dhcp_lease_new` | dnsmasq (luv + hotplug) | `mac`, `ip`, `hostname` |
+| `dhcp_lease_expire` | dnsmasq (luv + hotplug) | `mac`, `old_ip` |
 | `wan_link_up` | /sys/class/net/* | `device` |
 | `wan_link_down` | /sys/class/net/* | `device` |
 | `wan_dhcp_new` | ubus | `new_ip` |
 | `wan_dhcp_changed` | ubus | `old_ip`, `new_ip` |
+| `wifi_connected` | hostapd (hotplug) | `mac`, `interface` |
+| `wifi_disconnected` | hostapd (hotplug) | `mac`, `interface` |
 
 ## Project Structure
 
@@ -163,20 +170,23 @@ Router sends these events to server:
 client2server/
 ├── ARCHITECTURE.md         # Architecture docs
 ├── README.md             # This file
+├── MEMORY.md             # Project memory (AI/agent context)
 ├── Caddyfile            # Caddy load balancer
 ├── docker-compose.yml   # Full stack
 ├── package/
 │   ├── Makefile         # IPK build
 │   ├── src/
-│   │   └── client2server-unified.lua  # Router script
-│   └── files/
-│       ├── etc/init.d/
-│       └── etc/config/
+│   │   └── client2server-unified.lua  # Router script (canonical)
+│   ├── files/
+│   │   ├── etc/init.d/client2server
+│   │   └── etc/config/client2server
+│   └── hotplug/
+│       ├── 01-wifi      # /etc/hotplug.d/wireless/ - instant WiFi events
+│       └── 02-dhcp      # /etc/hotplug.d/dhcp/     - instant DHCP events
 └── server/
     ├── main.go
     ├── go.mod
-    ├── Dockerfile
-    └── index.js         # HTTP fallback
+    └── Dockerfile
 ```
 
 ## Building IPK

+ 0 - 23
etc/config/event-forwarder

@@ -1,23 +0,0 @@
-config event-forwarder 'general'
-	option enabled '1'
-	option interval '60'
-	option debug '0'
-
-config server
-	option url 'https://your-server.com/api/events'
-	option token 'CHANGE_ME_SECRET_TOKEN'
-	option method 'POST'
-	option timeout '10'
-	option retry '3'
-
-config router
-	option id ''
-	option name ''
-
-config events
-	option wifi_connect '1'
-	option wifi_disconnect '1'
-	option dhcp_lease '1'
-	option dhcp_expire '1'
-	option interface '1'
-	option config_change '0'

+ 0 - 46
etc/init.d/event-forwarder

@@ -1,46 +0,0 @@
-#!/bin/sh /etc/rc.common
-# Copyright (c) 2026 Luis Rosales - MIT License
-
-START=95
-STOP=10
-PIDFILE="/var/run/event-forwarder.pid"
-
-start() {
-    logger -t event-forwarder "Starting event forwarder..."
-    
-    # Check config exists
-    if [ ! -f /etc/config/event-forwarder ]; then
-        logger -t event-forwarder "ERROR: Config not found"
-        exit 1
-    fi
-    
-    # Check server URL is configured
-    local server_url
-    config_load event-forwarder
-    config_get server_url server url
-    if [ -z "$server_url" ]; then
-        logger -t event-forwarder "ERROR: server.url not configured"
-        exit 1
-    fi
-    
-    # Start the forwarder
-    echo $$ > $PIDFILE
-    /usr/sbin/event-forwarder &
-    
-    logger -t event-forwarder "Started with PID $(cat $PIDFILE)"
-}
-
-stop() {
-    if [ -f $PIDFILE ]; then
-        local pid=$(cat $PIDFILE)
-        kill -TERM $pid 2>/dev/null
-        rm -f $PIDFILE
-        logger -t event-forwarder "Stopped"
-    fi
-}
-
-reload() {
-    stop
-    sleep 1
-    start
-}

+ 9 - 1
package/Makefile

@@ -27,9 +27,11 @@ define Package/$(PKG_NAME)/description
   Features:
   - WebSocket connection with auto-reconnect
   - Local buffer (store-and-forward while offline)
-  - DHCP lease events (new/expire)
+  - DHCP lease events (new/expire) via luv + hotplug
+  - WiFi connect/disconnect events via hotplug
   - WAN link state monitoring
   - DHCP IP changes (new ISP detection)
+  - Bidirectional commands from server (uci_set, shell, reboot)
 endef
 
 define Build/Prepare
@@ -55,6 +57,12 @@ define Package/$(PKG_NAME)/install
 	# Config
 	$(INSTALL_DIR) $(1)/etc/config
 	$(INSTALL_DATA) ./files/etc/config/client2server $(1)/etc/config/client2server
+
+	# Hotplug scripts (instant WiFi/DHCP events)
+	$(INSTALL_DIR) $(1)/etc/hotplug.d/wireless
+	$(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
 endef
 
 $(eval $(call BuildPackage,$(PKG_NAME)))

+ 42 - 12
package/files/etc/init.d/client2server

@@ -1,48 +1,78 @@
 #!/bin/sh /etc/rc.common
 # Copyright (c) 2026 Luis Rosales - MIT License
+#
+# Starts the Lua event forwarder and exports UCI config to the environment
+# so that /etc/hotplug.d/{wireless,dhcp}/* scripts can read SERVER_URL, etc.
 
 START=95
 STOP=10
 NAME=client2server
 PIDFILE="/var/run/${NAME}.pid"
+ENV_FILE="/var/run/${NAME}.env"
 
 start() {
     logger -t "$NAME" -p user.info "Starting $NAME..."
-    
+
     # Check config
     if [ ! -f /etc/config/client2server ]; then
         logger -t "$NAME" -p user.err "Config not found"
         exit 1
     fi
-    
-    # Check if enabled (simple check without /etc/functions.sh)
-    local enabled=$(uci get client2server.general.enabled 2>/dev/null)
+
+    # Check if enabled
+    local enabled
+    enabled=$(uci get client2server.general.enabled 2>/dev/null)
     if [ "$enabled" = "0" ]; then
         logger -t "$NAME" -p user.info "Disabled in config"
         exit 0
     fi
-    
+
+    # Read UCI into environment file (so hotplug scripts can inherit it)
+    local server_url token router_id
+    server_url=$(uci get client2server.server.url 2>/dev/null)
+    token=$(uci get client2server.server.token 2>/dev/null)
+    router_id=$(uci get client2server.router.id 2>/dev/null)
+    if [ -z "$router_id" ]; then
+        router_id=$(cat /proc/sys/kernel/hostname 2>/dev/null || echo unknown)
+    fi
+
+    cat > "$ENV_FILE" <<EOF
+SERVER_URL='${server_url}'
+TOKEN='${token}'
+ROUTER_ID='${router_id}'
+EOF
+    chmod 0640 "$ENV_FILE"
+
     # Make sure Lua script is executable
     chmod +x /usr/sbin/client2server-unified.lua 2>/dev/null
-    
-    # Start the Lua script with lua interpreter
+
+    # Start the Lua script with UCI env exported (and load env file for any
+    # child processes that need it - e.g. hotplug scripts forked by netifd)
+    logger -t "$NAME" -p user.info "router_id=$router_id url=$server_url"
+
+    # shellcheck disable=SC1090
+    set -a
+    . "$ENV_FILE"
+    set +a
     lua /usr/sbin/client2server-unified.lua &
     echo $! > $PIDFILE
-    
+
     logger -t "$NAME" -p user.info "Started with PID $(cat $PIDFILE)"
 }
 
 stop() {
     if [ -f $PIDFILE ]; then
-        local pid=$(cat $PIDFILE)
-        kill -TERM $pid 2>/dev/null
+        local pid
+        pid=$(cat $PIDFILE)
+        kill -TERM "$pid" 2>/dev/null
         rm -f $PIDFILE
-        logger -t "$NAME" -p user.info "Stopped"
     fi
+    rm -f "$ENV_FILE" 2>/dev/null
+    logger -t "$NAME" -p user.info "Stopped"
 }
 
 reload() {
     stop
     sleep 1
     start
-}
+}

+ 36 - 13
package/hotplug/01-wifi

@@ -1,9 +1,25 @@
 #!/bin/sh
 # WiFi hotplug script - triggers on wireless events
 # Install to /etc/hotplug.d/wireless/
+#
+# Event names match package/src/client2server-unified.lua:
+#   wifi_connected / wifi_disconnected
 
-SERVER_URL="${SERVER_URL:-http://163.245.193.47:3843}"
-ROUTER_ID="${ROUTER_ID:-$(cat /proc/sys/kernel/hostname)}"
+# 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)
+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"
@@ -12,28 +28,35 @@ log() {
 send_event() {
     local event_type="$1"
     local data="$2"
-    
+
     json="{\"router_id\":\"$ROUTER_ID\",\"event\":\"$event_type\",\"data\":$data}"
-    
-    curl -s -X POST "$SERVER_URL/api/events" \
-        -H "Content-Type: application/json" \
-        -d "$json" 2>/dev/null
-    
+
+    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)
+    associate|associated)
         if [ -n "$MACADDR" ]; then
-            log "WiFi CONNECTED: $MACADDR"
+            log "WiFi CONNECTED: $MACADDR on $INTERFACE"
             send_event "wifi_connected" "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
         fi
         ;;
-    disassociate)
+    disassociate|disassociated|deauth|expired)
         if [ -n "$MACADDR" ]; then
-            log "WiFi DISCONNECTED: $MACADDR"
+            log "WiFi DISCONNECTED: $MACADDR on $INTERFACE"
             send_event "wifi_disconnected" "{\"mac\":\"$MACADDR\",\"interface\":\"$INTERFACE\"}"
         fi
         ;;
-esac
+esac

+ 40 - 14
package/hotplug/02-dhcp

@@ -1,9 +1,25 @@
 #!/bin/sh
 # DHCP hotplug script - triggers on DHCP events
 # Install to /etc/hotplug.d/dhcp/
+#
+# Event names match package/src/client2server-unified.lua:
+#   dhcp_lease_new / dhcp_lease_expire
 
-SERVER_URL="${SERVER_URL:-http://163.245.193.47:3843}"
-ROUTER_ID="${ROUTER_ID:-$(cat /proc/sys/kernel/hostname)}"
+# 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)
+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"
@@ -12,28 +28,38 @@ log() {
 send_event() {
     local event_type="$1"
     local data="$2"
-    
+
     json="{\"router_id\":\"$ROUTER_ID\",\"event\":\"$event_type\",\"data\":$data}"
-    
-    curl -s -X POST "$SERVER_URL/api/events" \
-        -H "Content-Type: application/json" \
-        -d "$json" 2>/dev/null
-    
+
+    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
 case "$ACTION" in
-    add)
+    add|update|old)
         if [ -n "$MAC" ] && [ -n "$IP" ]; then
-            log "DHCP NEW: $MAC -> $IP"
-            send_event "dhcp_new" "{\"mac\":\"$MAC\",\"ip\":\"$IP\",\"hostname\":\"$HOSTNAME\"}"
+            log "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 RELEASE: $MAC -> $IP"
-            send_event "dhcp_release" "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}"
+            log "DHCP LEASE EXPIRE: $MAC -> $IP"
+            send_event "dhcp_lease_expire" "{\"mac\":\"$MAC\",\"old_ip\":\"$IP\"}"
         fi
         ;;
-esac
+esac

+ 0 - 353
package/src/client2server-minimal.lua

@@ -1,353 +0,0 @@
--- client2server-minimal.lua - Unified DHCP + WiFi + SSID monitoring via ubus
-local cfg = {
-    server_url = "http://163.245.193.47:3843",
-    router_id = "Guayabal",
-    check_interval = 10,
-    state_file = "/tmp/client2server_state",
-}
-
--------------------------------------------------
--- Utilities
--------------------------------------------------
-local function log_info(msg)
-    os.execute('logger -t client2server -p user.info "' .. msg .. '"')
-end
-
-local function http_post(event_type, data)
-    local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
-    local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
-    local result = f:read("*a")
-    f:close()
-    return result
-end
-
--- Validate MAC address (proper format with valid hex)
-local function is_valid_mac(mac)
-    if not mac or #mac ~= 17 then return false end
-    local parts = {}
-    for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
-        table.insert(parts, part)
-    end
-    if #parts ~= 6 then return false end
-    -- First byte: not 00 or FF
-    local first = tonumber(parts[1], 16)
-    if first == 0 or first == 255 then return false end
-    -- Second byte: not FF (multicast)
-    local second = tonumber(parts[2], 16)
-    if second == 255 then return false end
-    return true
-end
-
-local function send_event(event_type, data)
-    log_info("Event: " .. event_type)
-    local resp = http_post(event_type, data)
-    if resp and resp:match("OK") then
-        log_info("OK")
-    else
-        log_info("Fail: " .. (resp or "nil"):sub(1, 50))
-    end
-end
-
--------------------------------------------------
--- State Management
--------------------------------------------------
-local function load_state()
-    local f = io.open(cfg.state_file, "r")
-    if f then
-        local content = f:read("*a")
-        f:close()
-        local clients = {}
-        local ssid_enabled = {}
-        local dhcp = {}
-        for line in content:gmatch("[^\n]+") do
-            local t, v = line:match("^([^:]+):(.+)$")
-            if t == "client" then
-                local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$")
-                -- Validate MAC when loading from state
-                if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end
-            elseif t == "ssid" then ssid_enabled[v] = true
-            elseif t == "dhcp" then
-                local mac, ip = v:match("^(.+)->(.+)$")
-                -- Validate MAC for DHCP too
-                if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end
-            end
-        end
-        return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp }
-    end
-    return { clients = {}, ssid_enabled = {}, dhcp = {} }
-end
-
-local function save_state(state)
-    local f = io.open(cfg.state_file, "w")
-    if f then
-        for mac, info in pairs(state.clients) do
-            f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n")
-        end
-        for iface in pairs(state.ssid_enabled) do
-            f:write("ssid:" .. iface .. "\n")
-        end
-        for mac, info in pairs(state.dhcp) do
-            f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
-        end
-        f:close()
-    end
-end
-
--------------------------------------------------
--- WiFi: Get ALL hostapd interfaces + SSID names
--------------------------------------------------
-local function get_hostapd_interfaces()
-    local f = io.popen("ubus list | grep hostapd")
-    local interfaces = {}
-    if f then
-        for line in f:lines() do
-            table.insert(interfaces, line)
-        end
-        f:close()
-    end
-    return interfaces
-end
-
--- Get SSID name for an interface via iw dev (most reliable)
-local function get_ssid_name(iface)
-    -- Map hostapd interface to wlan interface
-    local wlan_map = {
-        ["hostapd.wlan0-1"] = "wlan0-1",
-        ["hostapd.wlan0-2"] = "wlan0-2",
-    }
-    local wlan_iface = wlan_map[iface] or iface
-    
-    -- Use iw dev to get exact SSID
-    local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
-    if f then
-        local result = f:read("*a")
-        f:close()
-        local ssid = result:match("ssid%s+(.+)")
-        if ssid and ssid ~= "" then
-            return ssid:gsub("%s+$", "")  -- trim
-        end
-    end
-    
-    -- Fallback: try iw dev without specific interface
-    f = io.popen("iw dev 2>/dev/null")
-    if f then
-        local result = f:read("*a")
-        f:close()
-        -- Extract SSIDs from output
-        for line in result:gmatch("[^\n]+") do
-            local ssid = line:match("ssid%s+(.+)")
-            if ssid and ssid ~= "" then
-                return ssid
-            end
-        end
-    end
-    return iface
-end
-
--- Get all SSIDs mapped to interfaces
-local function get_all_ssids()
-    local ssids = {}
-    local interfaces = get_hostapd_interfaces()
-    for _, iface in ipairs(interfaces) do
-        local ssid = get_ssid_name(iface)
-        ssids[iface] = { ssid = ssid }
-    end
-    return ssids
-end
-
--- Get all SSID statuses via iw
-local function get_all_ssids()
-    local ssids = {}
-    local f = io.popen("iw dev 2>/dev/null")
-    if f then
-        local result = f:read("*a")
-        f:close()
-        local current_iface = nil
-        for line in result:gmatch("[^\n]+") do
-            -- Detect interface from "Interface wlanX-1"
-            local iface = line:match("Interface%s+(wlan%d-%d)")
-            if iface then
-                current_iface = iface
-            end
-            -- Extract SSID
-            local ssid = line:match("ssid%s+(.+)")
-            if ssid and current_iface then
-                local hostapd_iface = "hostapd." .. current_iface
-                ssids[hostapd_iface] = { enabled = true, ssid = ssid }
-            end
-        end
-    end
-    return ssids
-end
-
--- Get clients from ALL interfaces WITH their SSID
--- Validate MAC address (proper format with valid hex)
-local function get_wifi_clients()
-    local clients = {}
-    local interfaces = get_hostapd_interfaces()
-    
-    -- Get SSID mapping first
-    local ssid_map = {}
-    for _, iface in ipairs(interfaces) do
-        ssid_map[iface] = get_ssid_name(iface)
-    end
-    
-    -- Query each interface separately
-    local count = 0
-    for _, iface in ipairs(interfaces) do
-        log_info("Querying: " .. iface)
-        local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
-        if f then
-            local result = f:read("*a")
-            f:close()
-            log_info(iface .. " result: " .. result:sub(1, 100))
-            -- Check if there are actually clients (result contains "clients": {})
-            if result:find('"clients"') and not result:find('"clients":%s*{}') then
-                local mac_count = 0
-                -- Extract MAC addresses - match pattern for valid MACs
-                for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
-                    -- Validate MAC before adding
-                    if is_valid_mac(mac) and not clients[mac] then
-                        clients[mac] = { iface = iface, ssid = ssid_map[iface] }
-                        mac_count = mac_count + 1
-                    end
-                end
-                log_info(iface .. " clients: " .. mac_count)
-                count = count + mac_count
-            end
-        end
-    end
-    log_info("Total WiFi clients: " .. count)
-    
-    return clients
-end
-
--- Get SSID status for ALL interfaces
-local function get_ssid_status()
-    local status = {}
-    local ssids = get_all_ssids()
-    for iface, info in pairs(ssids) do
-        if info.enabled then
-            status[iface] = true
-        end
-    end
-    return status
-end
-
--------------------------------------------------
--- DHCP: Get leases via ubus
--------------------------------------------------
-local function get_dhcp_leases()
-    local leases = {}
-    
-    -- Try ubus first
-    local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
-    if f then
-        local result = f:read("*a")
-        f:close()
-        if result:match('"leases"') then
-            for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
-                -- Validate MAC before adding
-                if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end
-            end
-            return leases
-        end
-    end
-    
-    -- Fallback: read lease file
-    f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
-    if f then
-        for line in f:lines() do
-            local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)")
-            -- Validate MAC before adding
-            if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=h} end
-        end
-        f:close()
-    end
-    return leases
-end
-
--------------------------------------------------
--- Main Loop
--------------------------------------------------
-function main()
-    log_info("Starting client2server-minimal...")
-    log_info("Router: " .. cfg.router_id)
-    
-    local state = load_state()
-    
-    -- Initial event
-    local leases = get_dhcp_leases()
-    local count = 0
-    for _ in pairs(leases) do count = count + 1 end
-    log_info("DHCP leases: " .. count)
-    send_event("router_online", '{"lease_count":' .. count .. '}')
-    
-    while true do
-        os.execute("sleep " .. cfg.check_interval)
-        
-        -------------------------------------------------
-        -- WiFi Clients
-        -------------------------------------------------
-        local current_clients = get_wifi_clients()
-        
-        for mac, info in pairs(current_clients) do
-            if not state.clients[mac] then
-                log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
-                send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
-            end
-        end
-        for mac, info in pairs(state.clients) do
-            if not current_clients[mac] then
-                log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
-                send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
-            end
-        end
-        state.clients = current_clients
-        
-        -------------------------------------------------
-        -- SSID Status
-        -------------------------------------------------
-        local current_ssid = get_ssid_status()
-        
-        for iface in pairs(current_ssid) do
-            if not state.ssid_enabled[iface] then
-                local ssid = get_ssid_name(iface)
-                log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
-                send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
-            end
-        end
-        for iface in pairs(state.ssid_enabled) do
-            if not current_ssid[iface] then
-                local ssid = get_ssid_name(iface)
-                log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
-                send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
-            end
-        end
-        state.ssid_enabled = current_ssid
-        
-        -------------------------------------------------
-        -- DHCP Leases
-        -------------------------------------------------
-        local current_dhcp = get_dhcp_leases()
-        
-        for mac, info in pairs(current_dhcp) do
-            if not state.dhcp[mac] then
-                log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
-                send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
-            end
-        end
-        for mac, info in pairs(state.dhcp) do
-            if not current_dhcp[mac] then
-                log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
-                send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
-            end
-        end
-        state.dhcp = current_dhcp
-        
-        -- Save state
-        save_state(state)
-    end
-end
-
-main()

+ 0 - 147
package/src/client2server-ws.lua

@@ -1,147 +0,0 @@
--- ============================================================================
--- WEBSOCKET (RFC 6455) - Proper WebSocket implementation
--- ============================================================================
-
-local ws = { sock = nil, connected = false, key = "" }
-
--- Simple base64 encoder (no external deps)
-local function base64_encode(data)
-    local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
-    local result = {}
-    local i = 1
-    while i <= #data do
-        local b1, b2, b3 = string.byte(data, i, i+2)
-        b2 = b2 or 0
-        b3 = b3 or 0
-        table.insert(result, string.sub(b64, math.floor(b1/4)+1, math.floor(b1/4)+1))
-        table.insert(result, string.sub(b64, ((b1%16)*4) + math.floor(b2/16)+1, ((b1%16)*4) + math.floor(b2/16)+1))
-        if i+1 > #data then table.insert(result, "=") else
-            table.insert(result, string.sub(b64, ((b2%16)*4) + math.floor(b3/64)+1, ((b2%16)*4) + math.floor(b3/64)+1))
-        end
-        if i+2 > #data then table.insert(result, "=") else
-            table.insert(result, string.sub(b64, (b3%64)+1, (b3%64)+1))
-        end
-        i = i + 3
-    end
-    return table.concat(result)
-end
-
--- SHA1 (for WebSocket handshake) - uses openssl
-local function sha1_binary(data)
-    local f = io.popen("echo -n '" .. data:gsub("'", "'\\''") .. "' | openssl sha1 -binary | base64 | tr -d '\\n' 2>/dev/null")
-    if f then
-        local result = f:read("*a")
-        f:close()
-        return result:gsub("%s+$", "")
-    end
-    return ""
-end
-
--- Compute Sec-WebSocket-Accept
-local function compute_accept(key)
-    local combined = key .. "258EAFA5-E914-47DA-95CA-C5C753455362"
-    return sha1_binary(combined)
-end
-
-function ws.connect(url)
-    local is_ssl = url:match("wss://") ~= nil
-    local host = url:match("wss?://([^:/]+)")
-    local port = url:match(":(%d+)") or (is_ssl and "443" or "80")
-    if not host then return nil end
-
-    local sock = require("socket").tcp()
-    sock:settimeout(10)
-
-    local ok, err = sock:connect(host, tonumber(port))
-    if not ok then
-        log_err("Cannot connect to " .. host .. ":" .. port .. ": " .. tostring(err))
-        return nil
-    end
-
-    -- Generate random Sec-WebSocket-Key
-    local key = ""
-    for i = 1, 16 do key = key .. string.char(math.random(32, 126)) end
-    key = base64_encode(key)
-    ws.key = key
-
-    local request = "GET /ws HTTP/1.1\r\n" ..
-            "Host: " .. host .. ":" .. port .. "\r\n" ..
-            "Upgrade: websocket\r\n" ..
-            "Connection: Upgrade\r\n" ..
-            "Sec-WebSocket-Key: " .. key .. "\r\n" ..
-            "Sec-WebSocket-Version: 13\r\n" ..
-            "Origin: http://" .. host .. "\r\n" ..
-            "\r\n"
-
-    sock:send(request)
-
-    -- Read response
-    local response = {}
-    sock:settimeout(5)
-    for i = 1, 20 do
-        local line = sock:receive("*l")
-        if not line or line == "" then break end
-        table.insert(response, line)
-    end
-
-    -- Check for 101 Switching Protocols
-    local ok_response = false
-    for _, line in ipairs(response) do
-        if line:match("^HTTP/.* 101") then ok_response = true end
-    end
-
-    if not ok_response then
-        log_err("WebSocket handshake failed")
-        sock:close()
-        return nil
-    end
-
-    ws.sock = sock
-    ws.connected = true
-    log_info("WebSocket connected to " .. host .. ":" .. port)
-    return sock
-end
-
-function ws.send(data)
-    if not ws.connected then buffer.add(data); return false end
-
-    -- WebSocket frame: FIN(1) + opcode(1) = 0x81 (text)
-    local payload = data
-    local frame = string.char(0x81) .. payload
-
-    if not pcall(function() ws.sock:send(frame) end) then
-        ws.connected = false
-        buffer.add(data)
-        return false
-    end
-    return true
-end
-
-function ws.recv()
-    if not ws.connected then return nil end
-
-    ws.sock:settimeout(0.5)
-    local data, err = ws.sock:receive("*l")
-    ws.sock:settimeout(10)
-
-    if err and err ~= "timeout" then
-        ws.connected = false
-        return nil
-    end
-
-    -- Strip WebSocket frame header (first byte)
-    if data and #data > 1 then
-        data = data:sub(2)
-    end
-
-    return data
-end
-
-function ws.close()
-    if ws.sock and ws.connected then
-        pcall(function() ws.sock:send(string.char(0x88, 0x00)) end)
-    end
-    if ws.sock then pcall(ws.sock.close, ws.sock) end
-    ws.sock = nil
-    ws.connected = false
-end

+ 0 - 87
server/index.js

@@ -1,87 +0,0 @@
-// Simple server-side event receiver (Node.js example)
-// Save as: server/index.js
-
-const express = require('express');
-const crypto = require('crypto');
-
-const app = express();
-app.use(express.json());
-
-// In-memory store (use Redis/DB in production)
-const events = [];
-const routers = new Map();
-
-// Authentication middleware
-function authenticate(req, res, next) {
-    const token = req.headers.authorization?.replace('Bearer ', '');
-    
-    if (!token || token !== process.env.EVENT_TOKEN) {
-        return res.status(401).json({ error: 'Unauthorized' });
-    }
-    next();
-}
-
-// Event endpoint
-app.post('/api/events', authenticate, (req, res) => {
-    const event = {
-        id: crypto.randomUUID(),
-        ...req.body,
-        received_at: new Date().toISOString()
-    };
-    
-    // Store
-    events.push(event);
-    if (events.length > 10000) events.shift();
-    
-    // Track router
-    routers.set(event.router_id, {
-        last_seen: new Date(),
-        last_event: event.event_type
-    });
-    
-    // Log
-    console.log(`[${event.router_id}] ${event.event_type}`, event.payload);
-    
-    // Emit real-time (WebSocket)
-    // io.emit('event', event);
-    
-    res.json({ success: true, event_id: event.id });
-});
-
-// routers endpoint
-app.get('/api/routers', (req, res) => {
-    const online = Array.from(routers.entries()).map(([id, data]) => ({
-        id,
-        ...data,
-        online: (Date.now() - data.last_seen < 300000) // 5 min
-    }));
-    res.json({ routers: online });
-});
-
-// events endpoint
-app.get('/api/events', (req, res) => {
-    const limit = parseInt(req.query.limit) || 100;
-    const type = req.query.type;
-    
-    let filtered = events;
-    if (type) filtered = events.filter(e => e.event_type === type);
-    
-    res.json({ 
-        events: filtered.slice(-limit),
-        total: filtered.length
-    });
-});
-
-// Health check
-app.get('/health', (req, res) => {
-    res.json({ 
-        status: 'ok', 
-        events: events.length,
-        routers: routers.size 
-    });
-});
-
-const PORT = process.env.PORT || 3000;
-app.listen(PORT, () => {
-    console.log(`📡 Event server listening on port ${PORT}`);
-});

+ 0 - 184
server/server-ws.js

@@ -1,184 +0,0 @@
-// Simple server with WebSocket support
-// Run: npm install express ws && node server-ws.js
-
-const express = require('express');
-const { WebSocketServer } = require('ws');
-const crypto = require('crypto');
-
-const app = express();
-app.use(express.json());
-
-const PORT = process.env.PORT || 3000;
-
-// In-memory stores
-const events = [];
-const routers = new Map();  // router_id -> { last_seen, events_sent }
-
-// WebSocket clients
-const clients = new Set();
-
-// Auth middleware
-function auth(req, res, next) {
-    const token = req.headers.authorization?.replace('Bearer ', '');
-    if (token !== process.env.EVENT_TOKEN && token !== process.env.WS_TOKEN) {
-        return res.status(401).json({ error: 'Unauthorized' });
-    }
-    next();
-}
-
-// ============================================
-// HTTP ENDPOINTS
-// ============================================
-
-// Health check
-app.get('/health', (req, res) => {
-    res.json({ 
-        status: 'ok',
-        events_stored: events.length,
-        routers_online: routers.size,
-        ws_clients: clients.size,
-        uptime: process.uptime()
-    });
-});
-
-// Event webhook (HTTP fallback)
-app.post('/api/events', auth, (req, res) => {
-    const event = {
-        id: crypto.randomUUID(),
-        ...req.body,
-        received_at: new Date().toISOString(),
-        connection: 'http'
-    };
-    
-    // Store
-    events.push(event);
-    if (events.length > 10000) events.shift();
-    
-    // Track router
-    const router_id = req.body.router_id;
-    if (router_id) {
-        routers.set(router_id, {
-            last_seen: new Date(),
-            last_event: event.event_type,
-            events_sent: (routers.get(router_id)?.events_sent || 0) + 1
-        });
-    }
-    
-    console.log(`[${router_id}] ${event.event_type}`, event.payload);
-    res.json({ success: true, event_id: event.id });
-});
-
-// Routers list
-app.get('/api/routers', (req, res) => {
-    const router_list = [];
-    
-    for (const [id, data] of routers) {
-        router_list.push({
-            id,
-            ...data,
-            online: (Date.now() - data.last_seen.getTime()) < 60000  // 1 min
-        });
-    }
-    
-    res.json({ routers: router_list });
-});
-
-// Events query
-app.get('/api/events', (req, res) => {
-    const { router, type, limit = 100 } = req.query;
-    
-    let filtered = events;
-    if (router) filtered = filtered.filter(e => e.router_id === router);
-    if (type) filtered = filtered.filter(e => e.event_type === type);
-    
-    res.json({
-        events: filtered.slice(-parseInt(limit)),
-        total: filtered.length
-    });
-});
-
-// ============================================
-// WEBSOCKET SERVER
-// ============================================
-
-const server = require('http').createServer(app);
-const wss = new WebSocketServer({ server, path: '/ws' });
-
-wss.on('connection', (ws, req) => {
-    const ip = req.socket.remoteAddress;
-    let router_id = null;
-    
-    console.log(`Client connected: ${ip}`);
-    clients.add(ws);
-    
-    ws.on('message', (data) => {
-        try {
-            const event = JSON.parse(data);
-            router_id = event.router_id;
-            
-            // Store event
-            events.push({
-                ...event,
-                id: crypto.randomUUID(),
-                received_at: new Date().toISOString(),
-                connection: 'websocket'
-            });
-            
-            // Keep buffer size manageable
-            if (events.length > 10000) events.shift();
-            
-            // Track router
-            routers.set(router_id, {
-                last_seen: new Date(),
-                last_event: event.event_type,
-                events_sent: (routers.get(router_id)?.events_sent || 0) + 1
-            });
-            
-            console.log(`[WS ${router_id}] ${event.event_type}`, event.payload);
-            
-            // Echo back acknowledgment
-            ws.send(JSON.stringify({ ack: true, event_id: event.id }));
-            
-        } catch (e) {
-            console.error('WS parse error:', e.message);
-        }
-    });
-    
-    ws.on('close', () => {
-        console.log(`Client disconnected: ${ip}, router: ${router_id}`);
-        clients.delete(ws);
-    });
-    
-    ws.on('error', (err) => {
-        console.error(`WS error from ${ip}:`, err.message);
-    });
-});
-
-// Broadcast to all clients (for real-time updates)
-function broadcast(type, data) {
-    const msg = JSON.stringify({ type, data });
-    for (const client of clients) {
-        if (client.readyState === 1) {  // OPEN
-            client.send(msg);
-        }
-    }
-}
-
-// Start server
-server.listen(PORT, () => {
-    console.log(`
-╔═══════════════════════════════════════╗
-║   📡 client2server Central          ║
-║   HTTP:  http://localhost:${PORT}         ║
-║   WS:    ws://localhost:${PORT}/ws        ║
-╚═══════════════════════════════════════╝
-  `);
-});
-
-// Graceful shutdown
-process.on('SIGINT', () => {
-    console.log('\nShutting down...');
-    wss.close();
-    server.close();
-    process.exit(0);
-});

+ 0 - 56
usr/lib/rpcd/event-forwarder

@@ -1,56 +0,0 @@
-#!/usr/bin/lua
--- event-forwarder ubus RPC plugin
--- Provides remote control via ubus
-
-local json = require "cbi.json"
-local ubus = require "ubus"
-local uci = require "luci.model.uci".cursor()
-
-module("luci.rpcd.event-forwarder", package.seeall)
-
-function getStatus()
-    local conn = ubus.connect()
-    local status = {
-        running = (tonumber(luci.sys.exec("pgrep -f event-forwarder")) > 0),
-        router_id = uci:get("event-forwarder", "router", "id") or "unknown",
-        server_url = uci:get("event-forwarder", "server", "url") or "",
-        events_sent = 0,  -- Could track this in a file
-    }
-    conn:close()
-    return status
-end
-
-function getEvents(count)
-    count = tonumber(count) or 10
-    local events = {}
-    local f = io.popen("tail -n " .. count .. " /var/log/event-forwarder.log 2>/dev/null")
-    if f then
-        for line in f:lines() do
-            table.insert(events, line)
-        end
-        f:close()
-    end
-    return events
-end
-
-function testConnection()
-    local conn = ubus.connect()
-    local res = conn:call("network", "getStatus", {})
-    conn:close()
-    return { success = true, network = res }
-end
-
-function _init()
-    local lp = require "luci.http"
-    local cb = require "luci.rpcd".callback
-    
-    local rv = {
-        ["getStatus"]   = { cb=getStatus,   desc="Get event forwarder status" },
-        ["getEvents"]   = { cb=getEvents,   desc="Get recent events" },
-        ["test"]       = { cb=testConnection, desc="Test connection" },
-    }
-    
-    return rv
-end
-
-return _init()

+ 0 - 188
usr/sbin/client2server-luv.lua

@@ -1,188 +0,0 @@
---[[
-    client2server-luv - Event-driven using hotplug + ubus
-    
-    Features:
-    - Listens to hotplug events (IMMEDIATE reaction)
-    - Falls back to fast state checker if hotplug not available
-    - Uses luv for async event loop
-    
-    Copyright (c) 2026 Luis Rosales - MIT License
-]]
-
-local luv_ok, luv = pcall(require, "luv")
-
-local cfg = {
-    server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
-    router_id = os.getenv("ROUTER_ID") or "unknown",
-    state_file = "/tmp/client2server_state",
-    event_fifo = "/tmp/client2server_events",
-}
-
-local function log_info(msg)
-    os.execute('logger -t client2server -p user.info "' .. msg .. '"')
-end
-
-local function http_post(event_type, data)
-    local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
-    local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
-    local result = f:read("*a")
-    f:close()
-    return result
-end
-
-local function send_event(event_type, data)
-    log_info("Event: " .. event_type)
-    local resp = http_post(event_type, data)
-    if resp and resp:match("OK") then
-        log_info("OK")
-    else
-        log_info("Fail: " .. (resp or "nil"):sub(1, 50))
-    end
-end
-
--- State management
-local function load_state()
-    local f = io.open(cfg.state_file, "r")
-    if f then
-        local content = f:read("*a")
-        f:close()
-        local clients = {}
-        local dhcp = {}
-        for line in content:gmatch("[^\n]+") do
-            local t, v = line:match("^([^:]+):(.+)$")
-            if t == "client" then
-                local mac, iface = v:match("^(.+)|(.+)$")
-                if mac then clients[mac] = {iface=iface} end
-            elseif t == "dhcp" then
-                local mac, ip = v:match("^(.+)->(.+)$")
-                if mac and ip then dhcp[mac] = {ip=ip} end
-            end
-        end
-        return { clients = clients, dhcp = dhcp }
-    end
-    return { clients = {}, dhcp = {} }
-end
-
-local function save_state(state)
-    local f = io.open(cfg.state_file, "w")
-    if f then
-        for mac, info in pairs(state.clients) do
-            f:write("client:" .. mac .. "|" .. (info.iface or "") .. "\n")
-        end
-        for mac, info in pairs(state.dhcp) do
-            f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
-        end
-        f:close()
-    end
-end
-
--- Create FIFO for hotplug events
-local function create_event_fifo()
-    os.execute("mkfifo " .. cfg.event_fifo .. " 2>/dev/null")
-end
-
--- Listen to hotplug events (event-driven!)
-local function listen_hotplug_events()
-    log_info("Listening to hotplug events...")
-    
-    -- Method 1: Listen to /tmp/client2server_events FIFO
-    local fifo = io.open(cfg.event_fifo, "r")
-    
-    if fifo then
-        log_info("Reading from FIFO...")
-        while true do
-            local line = fifo:read("*line")
-            if not line then break end
-            
-            log_info("Hotplug event: " .. line)
-            
-            -- Parse event
-            local event_type = line:match("([^|]+)|")
-            local data = line:match("|(.+)$") or "{}"
-            
-            send_event(event_type, data)
-        end
-        fifo:close()
-    else
-        log_info("FIFO not available, using fallback")
-    end
-end
-
--- Alternative: use ubus to subscribe to events
-local function subscribe_ubus_events()
-    if not luv_ok then return false end
-    
-    log_info("Subscribing to ubus events...")
-    
-    -- Use a timer to check for new events in a file
-    local timer = luv.new_timer()
-    local last_check = 0
-    
-    local function check_hotplug_log()
-        -- Read from system log for hotplug events
-        local f = io.popen("logread -l 50 2>/dev/null | grep client2server-hotplug")
-        if f then
-            for line in f:lines() do
-                if line:match("WiFi CONNECTED") then
-                    local mac = line:match("([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])")
-                    if mac then
-                        send_event("wifi_connected", '{"mac":"' .. mac .. '"}')
-                    end
-                elseif line:match("WiFi DISCONNECTED") then
-                    local mac = line:match("([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])")
-                    if mac then
-                        send_event("wifi_disconnected", '{"mac":"' .. mac .. '"}')
-                    end
-                elseif line:match("DHCP NEW") then
-                    local mac = line:match("([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])")
-                    local ip = line:match("-> (%d+%.%d+%.%d+%.%d+)")
-                    if mac and ip then
-                        send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. ip .. '"}')
-                    end
-                elseif line:match("DHCP RELEASE") then
-                    local mac = line:match("([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])")
-                    local ip = line:match("-> (%d+%.%d+%.%d+%.%d+)")
-                    if mac and ip then
-                        send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. ip .. '"}')
-                    end
-                end
-            end
-            f:close()
-        end
-    end
-    
-    -- Check every 1 second
-    luv.timer_start(timer, 1000, 1000, check_hotplug_log)
-    
-    return true
-end
-
--- Main
-function main()
-    log_info("Starting client2server-luv...")
-    log_info("Router: " .. cfg.router_id)
-    log_info("Server: " .. cfg.server_url)
-    
-    if luv_ok then
-        log_info("luv available - async mode")
-    else
-        log_info("luv NOT available")
-    end
-    
-    -- Initial online event
-    send_event("router_online", '{"status":"online"}')
-    
-    if luv_ok then
-        -- Start listening to hotplug log
-        subscribe_ubus_events()
-        
-        -- Run event loop
-        luv.run()
-    else
-        while true do
-            os.execute("sleep 60")
-        end
-    end
-end
-
-main()

+ 0 - 410
usr/sbin/client2server-unified.lua

@@ -1,410 +0,0 @@
---[[
-    client2server-unified.lua - All-in-one event forwarder for OpenWrt
-    Copyright (c) 2026 Luis Rosales - MIT License
-    
-    Combines:
-    - client2server: DHCP/WiFi events → WebSocket
-    - wan-watcher: WAN link + DHCP monitoring
-    
-    Features:
-    - WebSocket connection with auto-reconnect
-    - Local buffer (store-and-forward while offline)
-    - DHCP lease events
-    - WAN link state monitoring
-    - DHCP lease changes (new ISP detection)
-    
-    Size: ~15KB (shared codebase, no duplication)
-]]
-
--- ============================================================================
--- CONFIG
--- ============================================================================
-
-local cfg = {
-    -- Server
-    server_url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
-    server_token = os.getenv("SERVER_TOKEN") or "secret-token",
-    router_id = os.getenv("ROUTER_ID") or "",
-    
-    -- Connection
-    reconnect_delay = 5,
-    ping_interval = 30,
-    max_retries = 10,
-    
-    -- Monitoring
-    check_interval = 5,
-    wan_interface = "wan",
-    wan_device = "eth0",
-    
-    -- Files
-    buffer_file = "/tmp/event_buffer",
-    status_file = "/var/run/client2server.status",
-    pid_file = "/var/run/client2server.pid",
-    max_buffer = 100,
-}
-
--- Load from UCI if available
-pcall(function()
-    local uci = require("luci.model.uci").cursor()
-    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.router_id = uci:get("client2server", "router", "id") or cfg.router_id
-    cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface
-    cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device
-end)
-
--- Set router_id from hostname if not set
-if cfg.router_id == "" then
-    local f = io.popen("hostname")
-    cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown"
-    if f then f:close() end
-end
-
--- ============================================================================
--- LOGGING
--- ============================================================================
-
-local function log(level, msg)
-    os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"')))
-end
-
-local function log_info(msg)  log("info", msg) end
-local function log_err(msg)  log("err", msg) end
-
--- ============================================================================
--- JSON (Minimal implementation)
--- ============================================================================
-
-local function json_encode(t)
-    local parts = {}
-    for k, v in pairs(t) do
-        if type(v) == "string" then
-            table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
-        elseif type(v) == "number" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        elseif type(v) == "boolean" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        elseif type(v) == "table" then
-            -- Nested object
-            table.insert(parts, string.format('"%s": %s', k, json_encode(v)))
-        end
-    end
-    return "{" .. table.concat(parts, ",") .. "}"
-end
-
--- ============================================================================
--- BUFFER (Offline Support)
--- ============================================================================
-
-local buffer = { events = {} }
-
-function buffer.load()
-    local f = io.open(cfg.buffer_file, "r")
-    if not f then return end
-    
-    for line in f:lines() do
-        if line and line ~= "" then
-            table.insert(buffer.events, line)
-        end
-    end
-    f:close()
-    log_info("Loaded " .. #buffer.events .. " buffered events")
-end
-
-function buffer.save()
-    if #buffer.events == 0 then
-        os.execute("rm -f " .. cfg.buffer_file)
-        return
-    end
-    
-    local f = io.open(cfg.buffer_file, "w")
-    if not f then return end
-    
-    for _, ev in ipairs(buffer.events) do
-        f:write(ev .. "\n")
-    end
-    f:close()
-end
-
-function buffer.add(json_event)
-    table.insert(buffer.events, json_event)
-    while #buffer.events > cfg.max_buffer do
-        table.remove(buffer.events, 1)
-    end
-    buffer.save()
-end
-
-function buffer.flush(send_fn)
-    if #buffer.events == 0 then return end
-    
-    log_info("Flushing " .. #buffer.events .. " buffered events...")
-    
-    local i = 1
-    while i <= #buffer.events do
-        local ok = send_fn(buffer.events[i])
-        
-        if ok then
-            table.remove(buffer.events, i)
-        else
-            i = i + 1
-        end
-    end
-    
-    buffer.save()
-    log_info("Flush complete, " .. #buffer.events .. " remaining")
-end
-
--- ============================================================================
--- WEBSOCKET (Simplified)
--- ============================================================================
-
-local ws = { sock = nil, connected = false }
-
-function ws.send(data)
-    if not ws.connected then
-        buffer.add(data)
-        return false
-    end
-    
-    -- Simple frame construction
-    local frame = string.format("\x81\x80%s", data)
-    
-    local success, err = pcall(function()
-        ws.sock:send(frame)
-    end)
-    
-    if not success then
-        ws.connected = false
-        buffer.add(data)
-        return false
-    end
-    
-    return true
-end
-
-function ws.connect(url)
-    -- Extract host from wss://... URL
-    local host = url:match("wss?://([^/]+)")
-    if not host then return nil end
-    
-    local sock = require("socket").tcp()
-    sock:settimeout(10)
-    
-    local ok, err = pcall(sock.connect, sock, host, 443)
-    if not ok then return nil end
-    
-    ws.sock = sock
-    ws.connected = true
-    return sock
-end
-
-function ws.close()
-    if ws.sock then
-        pcall(ws.sock.close, ws.sock)
-        ws.sock = nil
-    end
-    ws.connected = false
-end
-
--- ============================================================================
--- EVENT BUILDERS
--- ============================================================================
-
-function build_event(event_type, payload)
-    payload = payload or {}
-    payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
-    
-    return json_encode({
-        router_id = cfg.router_id,
-        hostname = cfg.router_id,
-        event_type = event_type,
-        payload = payload,
-    })
-end
-
--- ============================================================================
--- DATA SOURCES
--- ============================================================================
-
--- DHCP Leases
-local dhcp_leases = {}
-
-function check_dhcp()
-    local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
-    if not f then return nil end
-    
-    local current = {}
-    
-    for line in f:lines() do
-        local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
-        if mac then
-            current[mac] = { ip = ip, hostname = name, time = ts }
-            
-            if not dhcp_leases[mac] then
-                -- NEW lease
-                log_info("DHCP new: " .. mac .. " -> " .. ip)
-                return {
-                    event = "dhcp_lease_new",
-                    mac = mac,
-                    ip = ip,
-                    hostname = name,
-                }
-            end
-        end
-    end
-    f:close()
-    
-    -- Check for expired leases
-    for mac in pairs(dhcp_leases) do
-        if not current[mac] then
-            local expired = dhcp_leases[mac]
-            log_info("DHCP expire: " .. mac)
-            dhcp_leases[mac] = nil
-            return {
-                event = "dhcp_lease_expire",
-                mac = mac,
-                old_ip = expired.ip,
-            }
-        end
-    end
-    
-    dhcp_leases = current
-    return nil
-end
-
--- WAN Link State
-local link_last = nil
-local ip_last = nil
-
-function check_wan()
-    -- Physical link
-    local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
-    local link_now = f and (f:read("*a") or ""):find("^1") or false
-    if f then f:close() end
-    
-    -- Link change
-    if link_now ~= link_last then
-        link_last = link_now
-        return {
-            event = link_now and "wan_link_up" or "wan_link_down",
-            device = cfg.wan_device,
-            message = link_now and "Physical link detected" or "Physical link lost",
-        }
-    end
-    
-    -- Check DHCP IP via ubus
-    local info = nil
-    f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
-    if f then
-        local status = f:read("*a")
-        f:close()
-        if status then
-            local ip = status:match('"address"%s*:%s*"([^"]+)"')
-            if ip then info = { ip = ip } end
-        end
-    end
-    
-    -- IP change (connected to new network)
-    if info and info.ip and info.ip ~= ip_last then
-        local old_ip = ip_last
-        ip_last = info.ip
-        
-        return {
-            event = old_ip and "wan_dhcp_changed" or "wan_dhcp_new",
-            device = cfg.wan_interface,
-            old_ip = old_ip,
-            new_ip = info.ip,
-            message = old_ip and ("IP changed: " .. old_ip .. " -> " .. info.ip) or ("New IP: " .. info.ip),
-        }
-    end
-    
-    return nil
-end
-
--- ============================================================================
--- MAIN LOOP
--- ============================================================================
-
-local function main()
-    log_info("Starting client2server-unified...")
-    log_info("Router: " .. cfg.router_id)
-    log_info("Server: " .. cfg.server_url)
-    log_info("WAN: " .. cfg.wan_interface .. " (" .. cfg.wan_device .. ")")
-    
-    -- Load buffer
-    buffer.load()
-    
-    -- Save PID
-    local pf = io.open(cfg.pid_file, "w")
-    if pf then
-        pf:write(tostring(os.getpid()))
-        pf:close()
-    end
-    
-    -- Initial WAN state
-    local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
-    link_last = f and (f:read("*a") or ""):find("^1") or false
-    if f then f:close() end
-    
-    -- Main loop with periodic checks
-    local sock = nil
-    local retries = 0
-    local check_counter = 0
-    
-    while true do
-        -- Attempt connection if not connected
-        if not sock or not ws.connected then
-            log_info("Connecting...")
-            sock = ws.connect(cfg.server_url)
-            
-            if sock then
-                log_info("Connected!")
-                retries = 0
-                buffer.flush(function(d) return ws.send(d) end)
-            else
-                retries = retries + 1
-                if retries >= cfg.max_retries then
-                    log_err("Max retries, resetting")
-                    retries = 0
-                end
-            end
-        end
-        
-        socket.sleep(cfg.check_interval)
-        check_counter = check_counter + 1
-        
-        -- Check every cycle
-        local events = {}
-        
-        -- 1. DHCP
-        local ev = check_dhcp()
-        if ev then table.insert(events, build_event(ev.event, {
-            device = "dhcp",
-            mac = ev.mac,
-            ip = ev.ip,
-            hostname = ev.hostname,
-            old_ip = ev.old_ip,
-        }) end
-        
-        -- 2. WAN (every cycle)
-        ev = check_wan()
-        if ev then table.insert(events, build_event(ev.event, {
-            device = ev.device,
-            old_ip = ev.old_ip,
-            new_ip = ev.new_ip,
-        }) end
-        
-        -- Send buffered + current events
-        for _, event_json in ipairs(events) do
-            log_info("Event: " .. event_json)
-            ws.send(event_json)
-        end
-        
-        -- Also flush any pending buffered
-        if ws.connected then
-            buffer.flush(function(d) return ws.send(d) end)
-        end
-    end
-end
-
-main()

+ 0 - 329
usr/sbin/client2server.lua

@@ -1,329 +0,0 @@
---[[
-    client2server - Lua WebSocket Event Forwarder for OpenWrt
-    
-    Features:
-    - WebSocket connection to central server
-    - Auto-reconnect on disconnect
-    - Local buffer (store-and-forward while offline)
-    - DHCP/WiFi/Interface event tracking
-    
-    Copyright (c) 2026 Luis Rosales - MIT License
-]]
-
--- ============================================================================
--- REQUIREMENTS
--- ============================================================================
-
--- Try to load websocket library, fallback to simple HTTP
-local ws_client = nil
-local has_websocket, websocket = pcall(require, "websocket")
-
-if has_websocket then
-    ws_client = websocket.client.sync()
-end
-
--- ============================================================================
--- CONFIG
--- ============================================================================
-
-local cfg = {
-    url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
-    token = os.getenv("SERVER_TOKEN") or "secret-token",
-    router_id = os.getenv("ROUTER_ID") or "unknown",
-    reconnect_delay = 5,
-    ping_interval = 30,
-    buffer_file = "/tmp/event_buffer",
-    max_buffer = 100,
-}
-
--- Load from UCI if available
-pcall(function()
-    local uci = require("luci.model.uci").cursor()
-    cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url
-    cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token
-    cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id
-end)
-
--- ============================================================================
--- UTILITIES
--- ============================================================================
-
-local function log(level, msg)
-    os.execute(string.format('logger -t "client2server" -p user.%s "%s" 2>/dev/null', level, msg))
-end
-
-local function get_hostname()
-    local f = io.popen("hostname")
-    local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
-    if f then f:close() end
-    return h
-end
-
-cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id
-
-local function json_encode(t)
-    local parts = {}
-    for k, v in pairs(t) do
-        if type(v) == "string" then
-            table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
-        elseif type(v) == "number" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        elseif type(v) == "boolean" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        end
-    end
-    return "{" .. table.concat(parts, ",") .. "}"
-end
-
--- ============================================================================
--- BUFFER (OFFLINE SUPPORT)
--- ============================================================================
-
-local buffer = {
-    events = {},
-    dirty = false,
-}
-
-function buffer.load()
-    local f = io.open(cfg.buffer_file, "r")
-    if not f then return end
-    
-    for line in f:lines() do
-        if line and line ~= "" then
-            table.insert(buffer.events, line)
-        end
-    end
-    f:close()
-    log("info", "Loaded " .. #buffer.events .. " buffered events")
-end
-
-function buffer.save()
-    if not buffer.dirty then return end
-    
-    local f = io.open(cfg.buffer_file, "w")
-    if not f then return end
-    
-    for _, ev in ipairs(buffer.events) do
-        f:write(ev .. "\n")
-    end
-    f:close()
-    buffer.dirty = false
-end
-
-function buffer.add(json_event)
-    table.insert(buffer.events, json_event)
-    
-    -- Trim if too big
-    while #buffer.events > cfg.max_buffer do
-        table.remove(buffer.events, 1)
-    end
-    
-    buffer.dirty = true
-    buffer.save()
-end
-
-function buffer.flush(send_fn)
-    if #buffer.events == 0 then return end
-    
-    log("info", "Flushing " .. #buffer.events .. " buffered events...")
-    
-    local i = 1
-    while i <= #buffer.events do
-        local ok = send_fn(buffer.events[i])
-        
-        if ok then
-            table.remove(buffer.events, i)
-            buffer.dirty = true
-        else
-            i = i + 1
-        end
-    end
-    
-    buffer.save()
-end
-
-function buffer.clear()
-    buffer.events = {}
-    os.execute("rm -f " .. cfg.buffer_file)
-    buffer.dirty = false
-end
-
--- ============================================================================
--- WEBSOCKET CLIENT
--- ============================================================================
-
-local ws = {
-    sock = nil,
-    connected = false,
-}
-
-function ws.connect(url)
-    if not ws_client then
-        log("err", "websocket library not installed")
-        return nil
-    end
-    
-    local ok, sock = pcall(ws_client.connect, ws_client, url)
-    if ok then
-        ws.sock = sock
-        ws.connected = true
-    end
-    
-    return ws.sock
-end
-
-function ws.send(sock, data)
-    if not sock or not ws.connected then
-        buffer.add(data)
-        return false, "not connected"
-    end
-    
-    local ok, err = pcall(sock.send, sock, data)
-    if not ok then
-        ws.connected = false
-        buffer.add(data)
-        return false, err
-    end
-    
-    return true
-end
-
-function ws.close(sock)
-    if sock then
-        pcall(sock.close, sock)
-    end
-    ws.sock = nil
-    ws.connected = false
-end
-
--- ============================================================================
--- EVENT BUILDERS
--- ============================================================================
-
-function build_event(event_type, payload)
-    payload = payload or {}
-    payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
-    
-    return json_encode({
-        router_id = cfg.router_id,
-        hostname = get_hostname(),
-        event_type = event_type,
-        payload = payload,
-    })
-end
-
--- ============================================================================
--- EVENT LISTENERS
--- ============================================================================
-
-local function listen_dhcp()
-    local lease_file = "/var/lib/dnsmasq/dnsmasq.leases"
-    local old_leases = {}
-    
-    while true do
-        local f = io.open(lease_file, "r")
-        if f then
-            local leases = {}
-            
-            for line in f:lines() do
-                local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
-                if mac then
-                    leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
-                    
-                    -- New lease?
-                    if not old_leases[mac] then
-                        local ev = build_event("dhcp_lease", {
-                            mac = mac,
-                            ip = ip,
-                            hostname = name,
-                            action = "new"
-                        })
-                        log("info", "DHCP: " .. mac .. " -> " .. ip)
-                        buffer.add(ev)
-                    end
-                end
-            end
-            
-            old_leases = leases
-            f:close()
-        end
-        
-        os.execute("sleep 5")
-    end
-end
-
-local function poll_network()
-    -- Poll network status
-    local f = io.popen("ubus call network getStatus 2>/dev/null")
-    if f then f:close() end
-end
-
--- ============================================================================
--- MAIN LOOP
--- ============================================================================
-
-local function main()
-    log("info", "client2server starting...")
-    log("info", "Router: " .. cfg.router_id)
-    log("info", "Server: " .. cfg.url)
-    
-    -- Load buffered events
-    buffer.load()
-    
-    -- Save PID
-    local pf = io.open("/var/run/client2server.pid", "w")
-    if pf then
-        pf:write(tostring(os.getpid()))
-        pf:close()
-    end
-    
-    local sock = nil
-    local retries = 0
-    
-    -- Event listening coroutines
-    -- In practice, would fork these or use procd/inotify
-    
-    while true do
-        -- Attempt connection
-        log("info", "Connecting to server...")
-        
-        if ws_client then
-            sock = ws.connect(cfg.url)
-        end
-        
-        if sock then
-            log("info", "Connected!")
-            retries = 0
-            
-            -- Flush buffer
-            buffer.flush(function(data)
-                return ws.send(sock, data)
-            end)
-            
-            -- Keep alive loop
-            local loop_count = 0
-            while ws.connected and loop_count < (cfg.ping_interval / 5) do
-                os.execute("sleep 5")
-                loop_count = loop_count + 1
-                
-                -- Periodic flush
-                buffer.flush(function(data)
-                    return ws.send(sock, data)
-                end)
-            end
-        else
-            log("err", "Connection failed")
-            retries = retries + 1
-        end
-        
-        -- Cleanup and reconnect
-        ws.close(sock)
-        sock = nil
-        
-        log("info", "Reconnecting in " .. cfg.reconnect_delay .. "s...")
-        os.execute("sleep " .. cfg.reconnect_delay)
-    end
-end
-
--- Run
-main()

+ 0 - 212
usr/sbin/event-forwarder

@@ -1,212 +0,0 @@
-#!/bin/sh
-# event-forwarder - Forward OpenWrt events to central server
-# Copyright (c) 2026 Luis Rosales - MIT License
-# Size: ~20KB
-
-set -euo pipefail
-
-# Config
-CONFIG_FILE="/etc/config/event-forwarder"
-LOG_TAG="event-forwarder"
-
-# Load config
-load_config() {
-    # Server URL
-    SERVER_URL=$(uci get event-forwarder.server.url 2>/dev/null || echo "")
-    SERVER_TOKEN=$(uci get event-forwarder.server.token 2>/dev/null || echo "")
-    TIMEOUT=$(uci get event-forwarder.server.timeout 2>/dev/null || echo "10")
-    RETRY=$(uci get event-forwarder.server.retry 2>/dev/null || echo "3")
-    
-    # Router ID (use hostname if not set)
-    ROUTER_ID=$(uci get event-forwarder.router.id 2>/dev/null || hostname)
-    
-    # Event flags
-    EVT_WIFI_CONN=$(uci get event-forwarder.events.wifi_connect 2>/dev/null || echo "1")
-    EVT_WIFI_DISC=$(uci get event-forwarder.events.wifi_disconnect 2>/dev/null || echo "1")
-    EVT_DHCP=$(uci get event-forwarder.events.dhcp_lease 2>/dev/null || echo "1")
-    
-    # Validate
-    if [ -z "$SERVER_URL" ]; then
-        log_err "Server URL not configured"
-        exit 1
-    fi
-}
-
-# Logger
-log_debug() { [ "${DEBUG:-0}" = "1" ] && logger -t "$LOG_TAG" -p user.debug "$@"; }
-log_info()  { logger -t "$LOG_TAG" -p user.info "$@"; }
-log_err()   { logger -t "$LOG_TAG" -p user.err "$@"; }
-
-# Send event to server
-send_event() {
-    local event_json="$1"
-    local attempt=0
-    
-    while [ $attempt -lt $RETRY ]; do
-        attempt=$((attempt + 1))
-        
-        response=$(curl -s -w "%{http_code}" \
-            --max-time "$TIMEOUT" \
-            -X POST "$SERVER_URL" \
-            -H "Authorization: Bearer $SERVER_TOKEN" \
-            -H "Content-Type: application/json" \
-            -d "$event_json" 2>/dev/null) || true
-        
-        http_code="${response: -3}"
-        
-        if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
-            log_debug "Event sent successfully"
-            return 0
-        fi
-        
-        log_debug "Attempt $attempt failed (HTTP $http_code)"
-        sleep 1
-    done
-    
-    log_err "Failed to send event after $RETRY attempts"
-    return 1
-}
-
-# Build JSON payload
-build_json() {
-    local event_type="$1"
-    local mac="$2"
-    local ip="$3"
-    local hostname="$4"
-    local extra="$5"
-    
-    # Escape special chars
-    mac="${mac:-null}"
-    ip="${ip:-null}"
-    hostname="${hostname:-null}"
-    
-    cat <<EOF
-{
-  "router_id": "$ROUTER_ID",
-  "hostname": "$(hostname)",
-  "event_type": "$event_type",
-  "timestamp": "$(date -Iseconds)",
-  "payload": {
-    "mac": "$mac",
-    "ip": "$ip",
-    "hostname": "$hostname"$extra
-  }
-}
-EOF
-}
-
-# ===== EVENT LISTENERS =====
-
-# 1. WiFi events via hostapd
-listen_wifi() {
-    local wlan_iface="$1"
-    
-    # Monitor hostapd control interface
-    local ctrl_path="/var/run/hostapd-$wlan_iface"
-    
-    # We can't use hostapd_cli in monitor mode easily, 
-    # so we'll poll the station list as fallback
-    while true; do
-        # Get associated stations
-        if [ -f "/proc/sys/net/netfilter/nf_conntrack" ]; then
-            # Check for new WiFi clients via conntrack
-            # This is a simplified version
-            :
-        fi
-        
-        sleep 10
-    done
-}
-
-# 2. DHCP events via dnsmasq
-listen_dhcp() {
-    local lease_file="/var/lib/dnsmasq/dnsmasq.leases"
-    local old_leases=""
-    
-    while true; do
-        if [ -f "$lease_file" ]; then
-            current_leases=$(cat "$lease_file")
-            
-            # Compare and detect changes
-            if [ "$current_leases" != "$old_leases" ]; then
-                # Parse new/changed leases
-                echo "$current_leases" | while read line; do
-                    [ -z "$line" ] && continue
-                    
-                    set -- $line
-                    local timestamp="$1"
-                    local mac="$2"
-                    local ip="$3"
-                    local hostname="$4"
-                    
-                    # New lease (timestamp changed = new)
-                    if [[ ! "$old_leases" == *"$mac"* ]]; then
-                        json=$(build_json "dhcp_lease" "$mac" "$ip" "$hostname" ', "action": "new"')
-                        log_info "DHCP lease: $mac -> $ip ($hostname)"
-                        send_event "$json"
-                    fi
-                done
-                
-                old_leases="$current_leases"
-            fi
-        fi
-        
-        sleep 5
-    done
-}
-
-# 3. Interface events via ubus netifd
-listen_interfaces() {
-    ubus -m listen network.interface 2>/dev/null | while read event; do
-        event_type=$(echo "$event" | jsonfilter -e '@.handler')
-        
-        [ -z "$event_type" ] && continue
-        
-        device=$(echo "$event" | jsonfilter -e '@.interface')
-        action=$(echo "$event" | jsonfilter -e '@.action')
-        
-        json=$(build_json "interface_${action}" "null" "null" "null" ", \"device\": \"$device\", \"action\": \"$action\"")
-        log_info "Interface $action: $device"
-        
-        send_event "$json" &
-    done
-}
-
-# 4. Generic ubus events
-listen_ubus() {
-    ubus -m listen 2>/dev/null | while read event; do
-        # Filter network-related events
-        event_json=$(echo "$event" | jsonfilter -e '@')
-        
-        # Check for wireless events
-        if echo "$event" | grep -q "wpa"; then
-            log_debug "WiFi event: $event"
-        fi
-    done
-}
-
-# Main loop - runs all listeners concurrently
-main() {
-    load_config
-    
-    log_info "Starting event forwarder..."
-    log_info "Server: $SERVER_URL"
-    
-    # Trap signals for graceful shutdown
-    trap 'log_info "Shutting down..."; kill 0 2>/dev/null; exit 0' TERM INT
-    
-    # Start listeners in background
-    listen_dhcp &
-    local dhcp_pid=$!
-    
-    listen_interfaces &
-    local if_pid=$!
-    
-    log_info "Event listeners started (DHCP:$dhcp_pid, IFACE:$if_pid)"
-    
-    # Wait for any signal
-    wait
-}
-
-# Run main
-main "$@"

+ 0 - 344
usr/sbin/event-forwarder.lua

@@ -1,344 +0,0 @@
---[[
-    event-forwarder.lua - WebSocket-based event forwarder for OpenWrt
-    Copyright (c) 2026 Luis Rosales - MIT License
-    Size: ~30KB with dependencies
-]]
-
-local socket = require("socket")
-local http = require("socket.http")
-local ltn12 = require("ltn12")
-local json = require("json")
-
--- ============================================================================
--- CONFIGURATION
--- ============================================================================
-
-local config = {
-    server_url = "wss://your-server.com/ws",
-    server_token = "CHANGE_ME",
-    router_id = "",
-    reconnect_delay = 5,
-    max_retries = 10,
-    ping_interval = 30,
-    
-    -- File paths
-    buffer_file = "/tmp/event_buffer",
-    pid_file = "/var/run/event-forwarder.pid",
-    
-    -- Event sources
-    events = {
-        wifi_connect = true,
-        wifi_disconnect = true,
-        dhcp_lease = true,
-        interface_up = true,
-        interface_down = true,
-    }
-}
-
--- ============================================================================
--- LOGGING
--- ============================================================================
-
-local LOG_TAG = "event-forwarder"
-
-local function log(level, msg)
-    io.popen(string.format('logger -t "%s" -p user.%s "%s"', LOG_TAG, level, msg:gsub('"', '\\"'))):close()
-end
-
-local function log_info(msg)  log("info", msg) end
-local function log_err(msg)  log("err", msg) end
-local function log_debug(msg) 
-    if os.getenv("DEBUG") then log("debug", msg) end 
-end
-
--- ============================================================================
--- BUFFER (STORE-AND-FORWARD)
--- ============================================================================
-
-local buffer = {}
-
-function buffer.load()
-    local f = io.open(config.buffer_file, "r")
-    if not f then return end
-    
-    for line in f:lines() do
-        if line and line ~= "" then
-            table.insert(buffer, line)
-        end
-    end
-    f:close()
-    log_info("Buffer loaded: " .. #buffer .. " events")
-end
-
-function buffer.save()
-    local f = io.open(config.buffer_file, "w")
-    if not f then return end
-    
-    for _, line in ipairs(buffer) do
-        f:write(line .. "\n")
-    end
-    f:close()
-end
-
-function buffer.add(event_json)
-    table.insert(buffer, event_json)
-    
-    -- Prevent unbounded growth
-    while #buffer > 500 do
-        table.remove(buffer, 1)
-    end
-    
-    buffer.save()
-    log_debug("Buffered event, total: " .. #buffer)
-end
-
-function buffer.flush(ws)
-    if #buffer == 0 then return end
-    
-    log_info("Flushing " .. #buffer .. " buffered events...")
-    
-    local i = 1
-    while i <= #buffer do
-        local event = buffer[i]
-        local sent = ws:send(event)
-        
-        if sent then
-            table.remove(buffer, i)
-            log_debug("Sent buffered event")
-        else
-            i = i + 1
-        end
-    end
-    
-    buffer.save()
-end
-
-function buffer.clear()
-    buffer = {}
-    os.execute("rm -f " .. config.buffer_file)
-end
-
--- ============================================================================
--- WEBSOCKET CLIENT (Simple implementation)
--- ============================================================================
-
-local ws = {
-    sock = nil,
-    connected = false,
-}
-
-function ws.connect(url)
-    local sock = require("socket").tcp()
-    sock:settimeout(10)
-    
-    -- Parse URL
-    local protocol, host, path = url:match("^(wss?)://([^/]+)(.*)")
-    if not host then
-        log_err("Invalid URL: " .. url)
-        return nil
-    end
-    
-    -- Connect
-    local ok, err = sock:connect(host, 443)
-    if not ok then
-        return nil, "Connection failed: " .. tostring(err)
-    end
-    
-    -- TLS handshake (simplified - use stunnel or openssl for real TLS)
-    -- For now using raw socket - works with stunnel/Proxy
-    ws.sock = sock
-    ws.connected = true
-    
-    return ws
-end
-
-function ws.send(data)
-    if not ws.connected then
-        buffer.add(data)  -- Buffer instead of dropping
-        return false
-    end
-    
-    local frame = string.format(
-        "\x81%s%02x%s",
-        string.char(0x80 + #data),
-        #data,
-        data
-    )
-    
-    local ok, err = ws.sock:send(frame)
-    if not ok then
-        ws.connected = false
-        buffer.add(data)  -- Save to buffer on failure
-        return false
-    end
-    
-    return true
-end
-
-function ws.close()
-    if ws.sock then
-        ws.sock:close()
-        ws.sock = nil
-    end
-    ws.connected = false
-end
-
--- ============================================================================
--- EVENT GENERATORS
--- ============================================================================
-
-local function build_event(event_type, payload)
-    return string.format([[{
-        "router_id": "%s",
-        "hostname": "%s",
-        "event_type": "%s",
-        "timestamp": "%s",
-        "payload": %s
-    }]],
-        config.router_id,
-        get_hostname() or "unknown",
-        event_type,
-        os.date("!%Y-%m-%dT%H:%M:%SZ"),
-        json.encode(payload or {})
-    )
-end
-
-local function get_hostname()
-    local f = io.popen("hostname")
-    if not f then return "unknown" end
-    local name = f:read("*a"):gsub("%s+$", "")
-    f:close()
-    return name
-end
-
--- ============================================================================
--- EVENT LISTENERS
--- ============================================================================
-
--- DHCP Lease Events
-function listen_dhcp()
-    local lease_file = "/var/lib/dnsmasq/dnsmasq.leases"
-    local old_leases = {}
-    
-    while true do
-        local f = io.open(lease_file, "r")
-        if f then
-            local leases = {}
-            for line in f:lines() do
-                local timestamp, mac, ip, hostname = line:match("(%d+) (%S+) (%S+) (%S+)")
-                if mac then
-                    leases[mac] = { ip = ip, hostname = hostname, time = tonumber(timestamp) }
-                    
-                    -- New lease?
-                    if not old_leases[mac] then
-                        local event = build_event("dhcp_lease", {
-                            mac = mac,
-                            ip = ip,
-                            hostname = hostname,
-                            action = "new"
-                        })
-                        log_info("New DHCP: " .. mac .. " -> " .. ip)
-                        buffer.add(event)
-                    end
-                end
-            end
-            old_leases = leases
-            f:close()
-        end
-        socket.sleep(5)
-    end
-end
-
--- Interface Events (via ubus)
-function listen_interfaces()
-    local proc = io.popen("ubus -m listen network.interface 2>/dev/null")
-    if not proc then return end
-    
-    for line in proc:lines() do
-        if line then
-            local event_type = line:match('"action":"([^"]+)')
-            local device = line:match('"interface":"([^"]+)')
-            
-            if event_type and device then
-                local event = build_event("interface_" .. event_type, {
-                    device = device,
-                    action = event_type
-                })
-                log_info("Interface " .. event_type .. ": " .. device)
-                buffer.add(event)
-            end
-        end
-    end
-    
-    proc:close()
-end
-
--- ============================================================================
--- MAIN LOOP WITH RECONNECT
--- ============================================================================
-
-local function main()
-    -- Load config
-    local uci_cursor = require("luci.model.uci").cursor()
-    config.server_url = uci_cursor:get("event-forwarder", "server", "url") or config.server_url
-    config.server_token = uci_cursor:get("event-forwarder", "server", "token") or config.server_token
-    config.router_id = uci_cursor:get("event-forwarder", "router", "id") or get_hostname()
-    
-    log_info("Starting event forwarder...")
-    log_info("Router ID: " .. config.router_id)
-    log_info("Server: " .. config.server_url)
-    
-    -- Load buffered events
-    buffer.load()
-    
-    -- Write PID
-    local pidf = io.open(config.pid_file, "w")
-    if pidf then
-        pidf:write(tostring(os.getpid()))
-        pidf:close()
-    end
-    
-    -- Initial connect
-    local retries = 0
-    
-    while true do
-        -- Try to connect
-        log_info("Connecting to server...")
-        local ws, err = ws.connect(config.server_url)
-        
-        if ws and ws.connected then
-            log_info("Connected!")
-            retries = 0
-            
-            -- Flush buffer on connect
-            buffer.flush(ws)
-            
-            -- Main event loop - in real impl, would use select() for both sockets
-            -- For simplicity, just send buffered events periodically
-            while ws.connected do
-                socket.sleep(config.ping_interval)
-                
-                -- Send ping / keepalive
-                if ws.connected then
-                    buffer.flush(ws)
-                end
-            end
-        else
-            log_err("Connection failed: " .. tostring(err))
-            retries = retries + 1
-            
-            if retries >= config.max_retries then
-                log_err("Max retries reached, resetting")
-                retries = 0
-            end
-        end
-        
-        -- Wait before reconnect
-        ws.close()
-        log_info("Reconnecting in " .. config.reconnect_delay .. "s...")
-        socket.sleep(config.reconnect_delay)
-    end
-end
-
--- Run
-main()

+ 0 - 255
usr/sbin/wan-watcher.lua

@@ -1,255 +0,0 @@
---[[
-    wan-watcher.lua - Monitor WAN link state and DHCP changes
-    Copyright (c) 2026 Luis Rosales - MIT License
-    
-    Use case: Detect when router is moved from one modem to another
-    - Physical link flap (cable unplugged/plugged)
-    - New DHCP lease (connected to new ISP)
-    
-    Runs as daemon, reports events to central server
-]]
-
-local socket = require("socket")
-local json = require("json")
-
--- ============================================================================
--- CONFIG
--- ============================================================================
-
-local cfg = {
-    wan_interface = "wan",        -- UCI interface name
-    physical_device = "eth0",    -- Physical interface (e.g., eth0, wan)
-    check_interval = 5,          -- Seconds between checks
-    
-    -- Server (optional - could just buffer like client2server)
-    server_url = os.getenv("SERVER_URL") or "",
-    server_token = os.getenv("SERVER_TOKEN") or "",
-    
-    -- Paths
-    status_file = "/var/run/wan-watcher.status",
-    pid_file = "/var/run/wan-watcher.pid",
-}
-
--- Load config from UCI
-pcall(function()
-    local uci = require("luci.model.uci").cursor()
-    cfg.wan_interface = uci:get("wan-watcher", "general", "interface") or cfg.wan_interface
-    cfg.physical_device = uci:get("wan-watcher", "general", "device") or cfg.physical_device
-    cfg.server_url = uci:get("wan-watcher", "server", "url") or cfg.server_url
-    cfg.server_token = uci:get("wan-watcher", "server", "token") or cfg.server_token
-end)
-
--- ============================================================================
--- UTILITIES
--- ============================================================================
-
-local function log(msg)
-    os.execute(string.format('logger -t "wan-watcher" -p user.info "%s"', msg:gsub('"', '\\"')))
-end
-
-local function log_err(msg)
-    os.execute(string.format('logger -t "wan-watcher" -p user.err "%s"', msg:gsub('"', '\\"')))
-end
-
-local function get_hostname()
-    local f = io.popen("hostname")
-    local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
-    if f then f:close() end
-    return h
-end
-
--- ============================================================================
--- WAN STATUS CHECKS
--- ============================================================================
-
--- Check physical link state
-function check_link(device)
-    local f = io.popen("cat /sys/class/net/" .. device .. "/operstate 2>/dev/null")
-    if not f then return nil end
-    
-    local state = f:read("*a"):gsub("%s+$", "")
-    f:close()
-    
-    return state  -- "up", "down", "unknown", "dormant", etc.
-end
-
--- Check if interface has carrier
-function has_carrier(device)
-    local f = io.popen("cat /sys/class/net/" .. device .. "/carrier 2>/dev/null")
-    if not f then return false end
-    
-    local carrier = f:read("*a"):gsub("%s+$", "")
-    f:close()
-    
-    return carrier == "1"
-end
-
--- Get current DHCP lease info
-function get_dhcp_info(interface)
-    local uci = require("luci.model.uci").cursor()
-    local cursor = uci.cursor()
-    
-    -- Get interface data from network config
-    local proto = cursor:get("network", interface, "proto")
-    
-    -- Get IP from ubus
-    local f = io.popen("ubus call network.interface." .. interface .. " status 2>/dev/null")
-    if not f then return nil end
-    
-    local status = f:read("*a")
-    f:close()
-    
-    if not status then return nil end
-    
-    -- Parse JSON manually (without json library)
-    local ip = status:match('"address"%s*:%s*"([^"]+)"')
-    local prefix = status:match('"prefix"%s*:%s*(%d+)')
-    
-    return {
-        ip = ip,
-        prefix = tonumber(prefix),
-        proto = proto,
-    }
-end
-
--- ============================================================================
--- REPORTING
--- ============================================================================
-
-function report_event(event_type, data)
-    local event = {
-        router_id = get_hostname(),
-        event_type = event_type,
-        timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ"),
-        payload = data,
-    }
-    
-    local json_str = json.encode(event)
-    
-    log(event_type .. ": " .. (data.message or ""))
-    
-    -- Send to server if configured
-    if cfg.server_url ~= "" and cfg.server_token ~= "" then
-        local cmd = string.format(
-            'curl -s -X POST "%s" -H "Authorization: Bearer %s" -H "Content-Type: application/json" -d "%s" 2>/dev/null',
-            cfg.server_url,
-            cfg.server_token,
-            json_str:gsub('"', '\\"')
-        )
-        os.execute(cmd .. " &")
-    end
-    
-    -- Also could buffer for offline (reuse client2server logic)
-    return event
-end
-
--- ============================================================================
--- SAVE STATE
--- ============================================================================
-
-local function save_state(state)
-    local f = io.open(cfg.status_file, "w")
-    if f then
-        f:write(state .. "\n")
-        f:close()
-    end
-end
-
-local function load_state()
-    local f = io.open(cfg.status_file, "r")
-    if not f then return nil end
-    
-    local state = f:read("*a"):gsub("%s+$", "")
-    f:close()
-    return state
-end
-
--- ============================================================================
--- MAIN LOOP
--- ============================================================================
-
-local function main()
-    log("Starting WAN watcher...")
-    log("Interface: " .. cfg.wan_interface .. " (" .. cfg.physical_device .. ")")
-    
-    -- Save PID
-    local pf = io.open(cfg.pid_file, "w")
-    if pf then
-        pf:write(tostring(os.getpid()))
-        pf:close()
-    end
-    
-    -- Initial states
-    local last_link_state = nil
-    local last_ip = nil
-    
-    -- Check initial link state
-    last_link_state = has_carrier(cfg.physical_device)
-    local info = get_dhcp_info(cfg.wan_interface)
-    if info then last_ip = info.ip end
-    
-    save_state(last_link_state and "up" or "down")
-    
-    log("Initial state: link=" .. tostring(last_link_state) .. ", ip=" .. tostring(last_ip))
-    
-    -- Main monitoring loop
-    while true do
-        socket.sleep(cfg.check_interval)
-        
-        -- Check physical link
-        local current_link = has_carrier(cfg.physical_device)
-        local current_ip_info = get_dhcp_info(cfg.wan_interface)
-        local current_ip = current_ip_info and current_ip_info.ip or nil
-        
-        -- Link state change
-        if current_link ~= last_link_state then
-            if current_link then
-                -- Link came UP
-                report_event("wan_link_up", {
-                    device = cfg.physical_device,
-                    message = "Physical link detected on " .. cfg.physical_device,
-                })
-            else
-                -- Link went DOWN
-                report_event("wan_link_down", {
-                    device = cfg.physical_device,
-                    message = "Physical link lost on " .. cfg.physical_device,
-                })
-            end
-            
-            last_link_state = current_link
-            save_state(current_link and "up" or "down")
-        end
-        
-        -- DHCP lease change (new IP)
-        if current_ip and current_ip ~= last_ip then
-            if last_ip then
-                -- IP changed!
-                report_event("wan_dhcp_changed", {
-                    old_ip = last_ip,
-                    new_ip = current_ip,
-                    message = "New DHCP lease: " .. (last_ip or "none") .. " -> " .. current_ip,
-                })
-            else
-                -- Got first IP
-                report_event("wan_dhcp_renew", {
-                    new_ip = current_ip,
-                    message = "DHCP lease obtained: " .. current_ip,
-                })
-            end
-            
-            last_ip = current_ip
-        end
-        
-        -- No link but had IP before - connection dropped
-        if not current_link and last_ip and current_ip ~= nil then
-            report_event("wan_dropped", {
-                ip = last_ip,
-                message = "WAN connection dropped (still held lease)",
-            })
-        end
-    end
-end
-
--- Run
-main()