Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/lime-system/files/usr/lib/lua/lime/network.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions packages/shared-state-odhcpd_leases/Makefile
Original file line number Diff line number Diff line change
@@ -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)))
37 changes: 37 additions & 0 deletions packages/shared-state-odhcpd_leases/README.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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)