mini-mwan: add new package#28118
Conversation
|
Previous PR #27704 |
|
@alex-schwartzman you need to squash commits, and use you real name and email both for author and sign-off. |
148a9d4 to
9b47941
Compare
Yes, indeed. I should have turned off private emails in GitHub in the first place. Now I fixed it - should be better in one single squashed commit. |
|
If you have a repo with for the project, why aren't you pulling the source from there? |
As far as I understood the guidelines, the only solid reason to pull from external repo would be if there is already an existing project, which works on other platforms, and I would like to port it to OpenWRT. Good examples would be, vim, tailscale, nebula. Not mini-mwan, though, which is supposed to be OpenWRT-specific. |
fa4a704 to
92c4943
Compare
|
Addressed review notes. |
|
I think it makes sense to test this either on snapshot or 25.12. |
What shall I do to achieve that? Perhaps rebase to https://github.com/openwrt/packages/tree/openwrt-25.12 ? |
|
Build this against 25.12 or snapshot and test the functionality either on device or in an emulator if that applies. |
Oh, got it. Actually I can get away even without building it manually - CI/CD has already built it for me: https://github.com/openwrt/packages/actions/runs/20459973155/artifacts/4952664312 So I upgraded my router to 25.12-rc1, and installed that artifact there. Quick testing has indicated that both failover and multiuplink scenarios behaved correctly. |
dd61691 to
08de78a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| local function load_config() | ||
| deps.uci_cursor():load("mini-mwan") | ||
|
|
||
| local config = { | ||
| enabled = deps.uci_cursor():get("mini-mwan", "settings", "enabled") == "1", | ||
| mode = deps.uci_cursor():get("mini-mwan", "settings", "mode") or "failover", | ||
| check_interval = tonumber(deps.uci_cursor():get("mini-mwan", "settings", "check_interval")) or 30, | ||
| log_level = deps.uci_cursor():get("mini-mwan", "settings", "audit") or "emerg", | ||
| interfaces = {} | ||
| } | ||
|
|
||
| -- Load all interface configurations (config only, no state) | ||
| -- Section name is the device name (e.g., config interface 'eth0') | ||
| deps.uci_cursor():foreach("mini-mwan", "interface", function(section) | ||
| local iface_cfg = { | ||
| device = section.device, | ||
| metric = tonumber(section.metric) or 10, | ||
| weight = tonumber(section.weight) or 3, | ||
| ping_target = section.ping_target, | ||
| ping_count = tonumber(section.ping_count) or 3, | ||
| ping_timeout = tonumber(section.ping_timeout) or 2 | ||
| } | ||
|
|
||
| table.insert(config.interfaces, iface_cfg) | ||
| end) |
There was a problem hiding this comment.
deps.uci_cursor() is called repeatedly, and the :load() happens on a different cursor instance than the subsequent :get()/:foreach(). This can lead to reading unloaded/empty config (and also creates multiple cursors per cycle). Create one cursor (local c = deps.uci_cursor()) and reuse it for load/get/foreach.
| -- Use libubus directly instead of shelling out to ubus binary | ||
| -- conn is either initialized by register_ubus() or we get it from deps | ||
| if not deps.ubus_conn then | ||
| deps.ubus_conn = deps.ubus_connect() | ||
| end | ||
|
|
||
| log("Probe: ubus call network.interface dump", "debug") | ||
| local data = deps.ubus_conn:call("network.interface", "dump", {}) | ||
|
|
||
| local gateway_map = {} | ||
|
|
There was a problem hiding this comment.
If deps.ubus_connect() fails (returns nil), deps.ubus_conn:call(...) will throw. After attempting to connect, guard against a nil connection (log + return {}) to avoid crashing the daemon when ubus is temporarily unavailable.
| -- Use libubus directly instead of shelling out to ubus binary | |
| -- conn is either initialized by register_ubus() or we get it from deps | |
| if not deps.ubus_conn then | |
| deps.ubus_conn = deps.ubus_connect() | |
| end | |
| log("Probe: ubus call network.interface dump", "debug") | |
| local data = deps.ubus_conn:call("network.interface", "dump", {}) | |
| local gateway_map = {} | |
| local gateway_map = {} | |
| -- Use libubus directly instead of shelling out to ubus binary | |
| -- conn is either initialized by register_ubus() or we get it from deps | |
| if not deps.ubus_conn then | |
| deps.ubus_conn = deps.ubus_connect() | |
| end | |
| if not deps.ubus_conn then | |
| log("Failed to connect to ubus for network.interface dump", "err") | |
| return gateway_map | |
| end | |
| log("Probe: ubus call network.interface dump", "debug") | |
| local data = deps.ubus_conn:call("network.interface", "dump", {}) |
| local wan1_configured = config.interfaces[1] and config.interfaces[1].device and config.interfaces[1].device ~= "" | ||
| local wan2_configured = config.interfaces[2] and config.interfaces[2].device and config.interfaces[2].device ~= "" | ||
| return wan1_configured and wan2_configured |
There was a problem hiding this comment.
This only checks the first two UCI interface sections. If the first section is empty/disabled but two later sections are configured, the daemon will incorrectly refuse to work. Consider counting configured devices across all config.interfaces and returning true when the count >= 2.
| local wan1_configured = config.interfaces[1] and config.interfaces[1].device and config.interfaces[1].device ~= "" | |
| local wan2_configured = config.interfaces[2] and config.interfaces[2].device and config.interfaces[2].device ~= "" | |
| return wan1_configured and wan2_configured | |
| local configured_count = 0 | |
| for _, iface in ipairs(config.interfaces or {}) do | |
| if iface and iface.device and iface.device ~= "" then | |
| configured_count = configured_count + 1 | |
| if configured_count >= 2 then | |
| return true | |
| end | |
| end | |
| end | |
| return false |
There was a problem hiding this comment.
Kinda yes. However, config comes straight from load_config(), which builds config.interfaces via table.insert with no gaps. So a sparse/holey table can't actually happen here.
On the other hand - yes, better to cover that case here AND in the place where that incorrect configuration may originate from. Will change.
There was a problem hiding this comment.
yes, and I'll add a unit test for that as well.
| local cmd = string.format("ping -I %s -c %d -W %d -w %d %s 2>&1", device, count, timeout, deadline, target) | ||
| local output = system_probe(cmd) |
There was a problem hiding this comment.
device and target come from configuration and are interpolated into a shell command executed via io.popen. This allows shell metacharacters to change command behavior (command injection) and can also break on unusual interface names. Prefer executing commands without a shell (argv-style exec) or strictly validate/escape device and target to a safe character set before building the command.
There was a problem hiding this comment.
yes, it is a technical debt. I shall rather use lib-ip and some lua ping libraries instead of io.popen
| echo "Mini-MWAN is disabled" | ||
| return 1 |
There was a problem hiding this comment.
Returning a non-zero exit code when the service is intentionally disabled can make procd/init treat this as a start failure. Prefer returning success (0) when disabled (and log via logger), so service management doesn’t report an error state for a deliberate configuration.
| echo "Mini-MWAN is disabled" | |
| return 1 | |
| logger -t mini-mwan "Mini-MWAN is disabled" | |
| return 0 |
| -- Interface is up and has connectivity (ping succeeded) | ||
| table.insert(usable, {cfg = iface_cfg, state = iface_state}) | ||
| else | ||
| -- Interface is up but no connectivity (ping failed) |
There was a problem hiding this comment.
The comment in the else branch is inaccurate: that branch is reached when iface_state.is_up is false (interface down), not when it is up but has no connectivity. Update the comment to match the actual condition to avoid confusion during maintenance.
| -- Interface is up but no connectivity (ping failed) | |
| -- Interface is down |
Mini-MWAN is a minimalistic multi-WAN load balancing and failover daemon for OpenWrt. Unlike mwan3, it does not depend on nft or iptables, instead using IP routes and metrics for traffic management. Ideal for use-cases where mwan3 is too heavy or its dependencies cannot be satisfied (e.g., OpenWrt 23.05+). Features: - Failover mode: automatic switching to backup WAN on primary failure - Multi-uplink mode: load balancing across multiple WAN connections - Configurable ping-based health monitoring - UCI configuration interface - LuCI web interface coming soon as a separate package - Pure Lua implementation with minimal dependencies - Status reporting and logging Tested on OpenWrt 24.10 (ramips/mt7621 ASUS RT-AX53U and mediatek/filogic GL.iNet Flint 2 MT6000) and on OpenWrt 25.12-rc1 (mediatek/filogic GL.iNet Flint 2 MT6000). Contributions and feedback are welcome at: https://github.com/alex-schwartzman/mini-mwan Signed-off-by: Alex Schwartzman <aleks.schwartzman@gmail.com>
08de78a to
a638a6b
Compare
Mini-MWAN is a minimalistic multi-WAN load balancing and failover daemon for OpenWrt. Unlike mwan3, it is orthogonal to nft and iptables, instead using IP routes and metrics for traffic management.
Ideal for use-cases where mwan3 is too heavy or its dependencies cannot be satisfied (e.g., OpenWrt 23.05+).
Features:
Tested on OpenWrt 24.10 (ramips/mt7621 ASUS RT-AX53U and mediatek/filogic GL.iNet Flint 2 MT6000) and on OpenWrt 25.12-rc1 (mediatek/filogic GL.iNet Flint 2 MT6000).
Contributions and feedback are welcome at:
https://github.com/alex-schwartzman/mini-mwan
📦 Package Details
Maintainer: @alex-schwartzman
(You can find this by checking the history of the package
Makefile.)Description:
Minimalistic multi-WAN load balancing and failover daemon that uses IP routes and metrics instead of nft/iptables. Lighter alternative to mwan3, but not a full replacement.
🧪 Run Testing Details
OpenWrt Version: 24.10
OpenWrt Target/Subtarget: all (architecture-independent Lua package)
OpenWrt Device: ASUS RT-AX53U (ramips/mt7621)
OpenWrt Device: GL.iNet Flint 2 MT6000 (mediatek/filogic)
OpenWrt Version: 25.12-rc1
OpenWrt Device: GL.iNet Flint 2 MT6000 (mediatek/filogic)
✅ Formalities
If your PR contains a patch:
git am(e.g., subject line, commit description, etc.)
We must try to upstream patches to reduce maintenance burden.