diff --git a/packages/lime-system/files/usr/lib/lua/lime/network.lua b/packages/lime-system/files/usr/lib/lua/lime/network.lua index 2c5078fd8..5367b6767 100644 --- a/packages/lime-system/files/usr/lib/lua/lime/network.lua +++ b/packages/lime-system/files/usr/lib/lua/lime/network.lua @@ -205,11 +205,11 @@ function network.clean() if config.get_bool("network", "use_odhcpd", false) then utils.log("Use odhcpd as dhcp server") - uci:set("dhcp", "odchpd", "maindhcp", 1) + uci:set("dhcp", "odhcpd", "maindhcp", 1) os.execute("[ -e /etc/init.d/odhcpd ] && /etc/init.d/odhcpd enable") else utils.log("Disabling odhcpd") - uci:set("dhcp", "odchpd", "maindhcp", 0) + uci:set("dhcp", "odhcpd", "maindhcp", 0) os.execute("[ -e /etc/init.d/odhcpd ] && /etc/init.d/odhcpd disable") end diff --git a/packages/shared-state-odhcpd_leases/Makefile b/packages/shared-state-odhcpd_leases/Makefile new file mode 100644 index 000000000..3c3da5389 --- /dev/null +++ b/packages/shared-state-odhcpd_leases/Makefile @@ -0,0 +1,17 @@ +include ../../libremesh.mk + +define Package/$(PKG_NAME) + SECTION:=lime + CATEGORY:=LibreMesh + TITLE:=odhcpd leases module for shared-state + DEPENDS:=+lua +luci-lib-jsonc +shared-state-async +odhcpd + PKGARCH:=all +endef + +define Package/$(PKG_NAME)/description +Synchronize external DHCP leases between LibreMesh nodes by +watching odhcpd’s lease file, publishing updates over shared-state-async +and injecting remote leases locally. +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/packages/shared-state-odhcpd_leases/README.md b/packages/shared-state-odhcpd_leases/README.md new file mode 100644 index 000000000..2b907a21d --- /dev/null +++ b/packages/shared-state-odhcpd_leases/README.md @@ -0,0 +1,37 @@ + +# shared-state-odhcpd\_leases + +`shared-state-odhcpd_leases` replaces **dnsmasq** for DHCP in LibreMesh-based firmware. Each node’s **odhcpd** publishes its IPv4 lease table to the Shared‑State CRDT `odhcpd-leases`; peers pull that map, convert remote entries into `/tmp/ethers.mesh`, reload odhcpd and thereby reserve the same addresses mesh‑wide. + +## Prerequisites + +LibreMesh / OpenWrt with `odhcpd`, `shared-state-async`, `lua`, `luci-lib-jsonc` installed. + +Once the package is installed, it will: + +1. register the datatype `odhcpd-leases` in Shared‑State; +2. set `option leasetrigger '/usr/bin/odhcpd-lease-share.sh'` and `option leasefile_static '/tmp/ethers.mesh'` in `/etc/config/dhcp`; +3. symlink `/etc/ethers` → `/tmp/ethers.mesh` when `leasefile_static` is not supported; +4. restarts **odhcpd** and **cron**. + +No manual edits are required. + +## 1 Quick test (single node) + +```sh +# connect a client +ubus call dhcp ipv4leases +shared-state-async dump odhcpd-leases # lease visible +cat /tmp/ethers.mesh # should see "MAC IP" +``` + +## 2 Mesh test (two nodes) + +1. Connect client to **Node A** and ensure its lease appears in `dump` on **Node B**. +2. Roam client to **Node B**; `shared-state-async dump odhcpd-leases` and `cat /tmp/ethers.mesh` should show the **same IP**. +3. Connect a second client; confirm addresses never collide. + +## Contributing + +Open issues or pull requests in the LibreMesh *lime‑packages* repository, including router model, OpenWrt release, logs (`ubus`, `dump`, `/tmp/ethers.mesh`) and reproduction steps. + diff --git a/packages/shared-state-odhcpd_leases/files/etc/uci-defaults/90_odhcpd-lease-share b/packages/shared-state-odhcpd_leases/files/etc/uci-defaults/90_odhcpd-lease-share new file mode 100755 index 000000000..b390472c5 --- /dev/null +++ b/packages/shared-state-odhcpd_leases/files/etc/uci-defaults/90_odhcpd-lease-share @@ -0,0 +1,20 @@ +#!/bin/sh +CRDT='odhcpd-leases' +LEASEFILE='/tmp/ethers.mesh' +TRIGGERFILE='/usr/share/shared-state/publishers/shared-state-publish_odhcpd_leases' +mSc='odhcpd_leases' + +uci -q set shared-state.$mSc=dataType +uci -q set shared-state.$mSc.name="$CRDT" +uci -q set shared-state.$mSc.scope='community' +uci -q set shared-state.$mSc.update_interval='120' +uci -q set shared-state.$mSc.ttl='1200' +uci commit shared-state + +uci -q set dhcp.odhcpd.leasetrigger="$TRIGGERFILE" +uci -q set dhcp.odhcpd.maindhcp='1' +uci commit dhcp + +[ -L /etc/ethers ] || ln -sf "$LEASEFILE" /etc/ethers + +/etc/init.d/odhcpd reload diff --git a/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/hooks/odhcpd-leases/shared-state-generate_odhcpd_leases b/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/hooks/odhcpd-leases/shared-state-generate_odhcpd_leases new file mode 100755 index 000000000..3e5110736 --- /dev/null +++ b/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/hooks/odhcpd-leases/shared-state-generate_odhcpd_leases @@ -0,0 +1,25 @@ +#!/usr/bin/lua + +local JSON = require('luci.jsonc') + +local OUTPUT_FILE = '/tmp/ethers.mesh' +local TMP_FILE = OUTPUT_FILE .. '.new' + +local leases = JSON.parse(io.stdin:read('*a')) or {} + +local hostname_file = io.open('/proc/sys/kernel/hostname') +local node_hostname = hostname_file:read('*l') +hostname_file:close() + +local out_handle = io.open(TMP_FILE, 'w') + +for ip, data in pairs(leases) do + + if data and data.mData and data.mData.mac and data.mAuthor ~= node_hostname then + out_handle:write(string.format('%s %s\n', data.mData.mac, ip)) -- Format: MAC IP + end +end +out_handle:close() +os.execute('mv "' .. TMP_FILE .. '" "' .. OUTPUT_FILE .. '"') + +os.execute('/etc/init.d/odhcpd reload') diff --git a/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/publishers/shared-state-publish_odhcpd_leases b/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/publishers/shared-state-publish_odhcpd_leases new file mode 100755 index 000000000..e4cb5ae07 --- /dev/null +++ b/packages/shared-state-odhcpd_leases/files/usr/share/shared-state/publishers/shared-state-publish_odhcpd_leases @@ -0,0 +1,47 @@ +#!/usr/bin/lua + +local JSON = require("luci.jsonc") +local CRDT = "odhcpd-leases" + + +local handle = io.popen("ubus call dhcp ipv4leases '{}' 2>/dev/null") +local ubus_output = handle:read("*a") +handle:close() + + +local ubus_data = JSON.parse(ubus_output or "{}") + +local output_table = {} + + +if ubus_data and ubus_data.device then + + for device_name, device_data in pairs(ubus_data.device) do + if device_data and device_data.leases then + + for _, lease in ipairs(device_data.leases) do + + if lease.address and lease.mac then + + output_table[lease.address] = { + hostname = lease.hostname or "", + mac = lease.mac + } + end + end + end + end +end + + +local final_json_string = JSON.stringify(output_table) + + +local pipe = io.popen("shared-state-async insert " .. CRDT, "w") +if pipe then + pipe:write(final_json_string) + pipe:close() +end + + +os.execute("/usr/sbin/odhcpd-update >/dev/null 2>&1") diff --git a/packages/shared-state-odhcpd_leases/tests/test_publish_odhcpd_leases.lua b/packages/shared-state-odhcpd_leases/tests/test_publish_odhcpd_leases.lua new file mode 100644 index 000000000..ddb0c5e01 --- /dev/null +++ b/packages/shared-state-odhcpd_leases/tests/test_publish_odhcpd_leases.lua @@ -0,0 +1,101 @@ +local testUtils = require "tests.utils" +local stub = require "luassert.stub" + +local publisher_file = + "packages/shared-state-odhcpd_leases/files/usr/share/shared-state/publishers/" .. + "shared-state-publish_odhcpd_leases" +local run_publisher = testUtils.load_lua_file_as_function(publisher_file) + +local captured_json +local ubus_reply + +local popen_stub, execute_stub + +local function stub_system_calls() + popen_stub = stub(io, "popen", function(cmd, _) + if cmd:match("^ubus call dhcp ipv4leases") then + return { read = function() return "" end, close = function() end } + + elseif cmd:match("^shared%-state%-async insert") then + return { + write = function(_, s) captured_json = s end, + close = function() end + } + + else + return { read = function() return "" end, close = function() end } + end + end) + + execute_stub = stub(os, "execute", function() return true end) +end + +local function revert_system_stubs() + if popen_stub then popen_stub:revert() end + if execute_stub then execute_stub:revert() end +end + +describe("shared-state-odhcpd_leases publisher #odhcpd-leases", function() + + before_each(function() + captured_json = nil + ubus_reply = nil + + package.loaded["luci.jsonc"] = nil + package.preload["luci.jsonc"] = function() + return { + parse = function() return ubus_reply end, + stringify = function(tbl) + if next(tbl) == nil then return "[]" end + local parts = {} + for ip, info in pairs(tbl) do + parts[#parts + 1] = string.format( + '"%s":{"mac":"%s","hostname":"%s"}', + ip, info.mac or "", info.hostname or "") + end + return "{" .. table.concat(parts, ",") .. "}" + end + } + end + + stub_system_calls() + end) + + after_each(function() + revert_system_stubs() + package.preload["luci.jsonc"] = nil + end) + + it("#happy_path publishes every lease", function() + ubus_reply = { + device = { + eth0 = { + leases = { + { address = "10.0.0.5", mac = "aa:bb", hostname = "h1" }, + { address = "10.0.0.6", mac = "cc:dd", hostname = "h2" } + } + } + } + } + + run_publisher() + + assert.is_string(captured_json, "Expected JSON") + assert.matches('"10%.0%.0%.5"%s*:%s*{[^}]-"mac"%s*:%s*"aa:bb"', captured_json) + assert.matches('"10%.0%.0%.6"%s*:%s*{[^}]-"mac"%s*:%s*"cc:dd"', captured_json) + assert.matches('"hostname"%s*:%s*"h1"', captured_json) + assert.matches('"hostname"%s*:%s*"h2"', captured_json) + end) + + it("#empty with zero leases publishes '[]'", function() + ubus_reply = {} + run_publisher() + assert.equals("[]", captured_json) + end) + + it("#malformed when parse returns nil publishes '[]'", function() + ubus_reply = nil + run_publisher() + assert.equals("[]", captured_json) + end) +end)