Selaa lähdekoodia

Add OpenWrt package structure for IPK build

- package/Makefile: Package definition
- package/files/: Init script, UCI config
- package/src/: Main Lua script

To build IPK with OpenWrt SDK:
  ./scripts/feeds update -a
  ./scripts/feeds install client2server-unified
  make package/client2server-unified/{clean,compile}
  make package/client2server-unified/ipk

Output: bin/packages/*/client2server-unified_*_*.ipk
Luis Rosales 2 kuukautta sitten
vanhempi
sitoutus
8b5d56320d

+ 60 - 0
package/Makefile

@@ -0,0 +1,60 @@
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=client2server-unified
+PKG_VERSION:=2.0.0
+PKG_RELEASE:=1
+PKG_LICENSE:=MIT
+PKG_MAINTAINER:=Luis Rosales <lrosales@techno-world.net>
+
+PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
+PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
+PKG_SOURCE_URL:=https://git3.techno-world.net/lrosales/client2server
+
+include $(INCLUDE_BASE)/package
+
+define Package/$(PKG_NAME)
+  SECTION:=utils
+  CATEGORY:=Utilities
+  TITLE:=Event forwarder for OpenWrt to central server
+  DESCRIPTION:=All-in-one Lua event forwarder that monitors DHCP, WAN link, and forwards to central server via WebSocket with offline buffering
+  DEPENDS:=+lua +luasocket
+  TITLE:=Client2Server Unified
+endef
+
+define Package/$(PKG_NAME)/description
+  All-in-one Lua event forwarder for OpenWrt routers.
+  
+  Features:
+  - WebSocket connection with auto-reconnect
+  - Local buffer (store-and-forward while offline)
+  - DHCP lease events (new/expire)
+  - WAN link state monitoring
+  - DHCP IP changes (new ISP detection)
+endef
+
+define Build/Prepare
+	mkdir -p $(PKG_BUILD_DIR)
+	$(CP) ./src/* $(PKG_BUILD_DIR)/
+	chmod +x $(PKG_BUILD_DIR)/*
+endef
+
+define Build/Configure
+endef
+
+define Build/Compile
+endef
+
+define Package/$(PKG_NAME)/install
+	$(CP) $(PKG_BUILD_DIR)/* $(1)/usr/sbin/
+	chmod +x $(1)/usr/sbin/*.lua
+
+	# Init script
+	$(INSTALL_DIR) $(1)/etc/init.d
+	$(INSTALL_BIN) ./files/etc/init.d/client2server $(1)/etc/init.d/client2server
+
+	# Config
+	$(INSTALL_DIR) $(1)/etc/config
+	$(INSTALL_DATA) ./files/etc/config/client2server $(1)/etc/config/client2server
+endef
+
+$(eval $(call BuildPackage,$(PKG_NAME)))

+ 16 - 0
package/files/etc/config/client2server

@@ -0,0 +1,16 @@
+config client2server 'general'
+    option enabled '1'
+    option check_interval '5'
+
+config server
+    option url 'wss://your-server.com/ws'
+    option token 'CHANGE_ME_SECRET_TOKEN'
+    option reconnect_delay '5'
+    option ping_interval '30'
+
+config router
+    option id ''
+
+config wan
+    option interface 'wan'
+    option device 'eth0'

+ 50 - 0
package/files/etc/init.d/client2server

@@ -0,0 +1,50 @@
+#!/bin/sh /etc/rc.common
+# Copyright (c) 2026 Luis Rosales - MIT License
+
+START=95
+STOP=10
+NAME=client2server
+PIDFILE="/var/run/${NAME}.pid"
+
+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
+    
+    # Load config values
+    . /etc/functions.sh
+    config_load client2server
+    
+    local enabled
+    config_get_bool enabled general enabled 0
+    
+    if [ "$enabled" = "0" ]; then
+        logger -t "$NAME" -p user.info "Disabled in config"
+        exit 0
+    fi
+    
+    # Start the Lua script
+    /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
+        rm -f $PIDFILE
+        logger -t "$NAME" -p user.info "Stopped"
+    fi
+}
+
+reload() {
+    stop
+    sleep 1
+    start
+}

+ 410 - 0
package/src/client2server-unified.lua

@@ -0,0 +1,410 @@
+--[[
+    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()