diff --git a/docs/naming.md b/docs/naming.md index b300a66c..d28a682b 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -1083,3 +1083,27 @@ public capability identity. The appliance-level wired view is published under `state/wired/...` and should use stable product surface identifiers such as `cm5-eth0`, `switch-uplink-cm5`, `lan-1` and `lan-2`. + +## GSM uplink state consumed by NET + +GSM publishes curated semantic uplink state under: + +```text +state/gsm/uplink/ +``` + +`` is stable product identity, for example `primary` or `secondary`. It +must not be a volatile Linux device name. The Linux interface name observed from +ModemManager or the kernel is payload data, normally `linux.ifname`, and may +change between boots. + +NET may consume this state as its GSM source contract. NET should not consume +raw modem or HAL provider topics for WAN source identity. + +During migration GSM may also publish the legacy compatibility topic: + +```text +state/gsm/modem//uplink +``` + +New code should use `state/gsm/uplink/`. diff --git a/docs/specs/net.md b/docs/specs/net.md index de091b1f..a6d1cf76 100644 --- a/docs/specs/net.md +++ b/docs/specs/net.md @@ -266,19 +266,21 @@ The OpenWrt provider applies the current Big Box config domains as follows: ```text network + unconditional loopback and globals baseline segment trunk VLAN devices on the configured base interface + bridge-backed LAN/system/user/guest segments by default + direct WAN/uplink segments by default segment logical interfaces explicit interfaces - bridge devices where configured map-shaped and array-shaped static routes dhcp / dnsmasq - per-segment dnsmasq sections where local DNS/DHCP/host files are required + grouped dnsmasq instances by effective DNS policy dns cache size upstream DNS servers top-level DNS records as dnsmasq address entries segment content-filter host files through addnhosts/addnmount - segment-local DHCP pools + segment-local DHCP pools bound to their dnsmasq instance DHCP reservations per-segment DHCP options where configured @@ -302,7 +304,7 @@ vpn OpenWrt provider currently reports configured tunnels as unsupported ``` -Provider apply is fully reconciliatory for the UCI packages it owns. Sections absent from the desired set are removed. UCI application uses the scoped UCI manager transaction path with package snapshots and rollback on partial failure. +Provider apply is a complete rewrite for the UCI packages it owns. Devicecode-generated OpenWrt packages do not carry `devicecode_*` watermark options; the provider instead returns the semantic-to-generated-name map from `plan_op` and `apply_op` under `openwrt_names`. UCI application uses the scoped UCI manager transaction path with package snapshots and rollback on partial failure. Missing package files are created before apply. ## Traffic shaping diff --git a/src/configs/bigbox-v1-cm-2.json b/src/configs/bigbox-v1-cm-2.json index f9817703..eab35485 100644 --- a/src/configs/bigbox-v1-cm-2.json +++ b/src/configs/bigbox-v1-cm-2.json @@ -467,8 +467,8 @@ "dynamic_weight": true, "family": "ipv4", "source": { - "kind": "modem", - "modem_id": "primary" + "kind": "gsm-uplink", + "id": "primary" } }, "mdm1": { @@ -478,8 +478,8 @@ "dynamic_weight": true, "family": "ipv4", "source": { - "kind": "modem", - "modem_id": "secondary" + "kind": "gsm-uplink", + "id": "secondary" } } }, diff --git a/src/services/gsm.lua b/src/services/gsm.lua index 7081a8e3..cc106294 100644 --- a/src/services/gsm.lua +++ b/src/services/gsm.lua @@ -16,7 +16,8 @@ -- obs/v1/gsm/event/ per-modem observability events -- state/gsm/modem//connected retained: true when APN connected, false otherwise -- state/gsm/modem//wwan-iface retained: kernel wwan interface name, false when unknown --- state/gsm/modem//uplink retained: canonical cellular uplink record for NET +-- state/gsm/uplink/ retained: canonical cellular uplink record for NET +-- state/gsm/modem//uplink retained: legacy compatibility cellular uplink record local fibers = require "fibers" local op = require "fibers.op" @@ -74,6 +75,10 @@ local function t_state_gsm_modem(name, field) end local function t_state_gsm_uplink(name) + return { 'state', 'gsm', 'uplink', name } +end + +local function t_legacy_state_gsm_uplink(name) return { 'state', 'gsm', 'modem', name, 'uplink' } end @@ -414,6 +419,7 @@ function GsmModem.new(cap, svc) self.wwan_iface = nil self.last_access = nil self.last_signal = nil + self.uplink_generation = 0 self.scope = nil self.config_pulse = pulse.new() self.svc = svc @@ -475,16 +481,33 @@ end function GsmModem:_publish_uplink_state(connected, iface) if connected ~= nil then self.connected = connected == true end if type(iface) == 'string' and iface ~= '' then self.wwan_iface = iface end + self.uplink_generation = (self.uplink_generation or 0) + 1 + local access_techs = modem_get_field(self.cap, 'access_techs', REQUEST_TIMEOUT) local access_tech = derive_access_tech(access_techs) local operator = modem_get_field(self.cap, 'operator', REQUEST_TIMEOUT) local signal = modem_get_field(self.cap, 'signal', REQUEST_TIMEOUT) + local logical = (self.cfg and (self.cfg.openwrt_interface or self.cfg.network_interface)) or self.name + local ifname = self.wwan_iface local payload = { - modem = self.name, + schema = 'devicecode.gsm.uplink/1', + id = self.name, + role = self.name, + state = self.connected and 'connected' or 'disconnected', connected = self.connected == true, - interface = self.wwan_iface, - openwrt_interface = (self.cfg and (self.cfg.openwrt_interface or self.cfg.network_interface)) or self.name, - device = self.wwan_iface, + available = self.connected == true and type(ifname) == 'string' and ifname ~= '', + generation = self.uplink_generation, + linux = { ifname = ifname }, + network = { logical_interface = logical }, + modem = { + id = tostring(self.id), + role = self.name, + device = self.device, + }, + -- Compatibility fields for existing consumers. + interface = ifname, + openwrt_interface = logical, + device = ifname, access = { tech = access_tech ~= '' and access_tech or nil, family = access_tech ~= '' and get_access_family(access_tech) or nil, @@ -494,6 +517,7 @@ function GsmModem:_publish_uplink_state(connected, iface) at = self.svc and self.svc.wall and self.svc:wall() or nil, } self.conn:retain(t_state_gsm_uplink(self.name), payload) + self.conn:retain(t_legacy_state_gsm_uplink(self.name), payload) end function GsmModem:_emit_metrics_once() diff --git a/src/services/hal/backends/network/providers/openwrt/init.lua b/src/services/hal/backends/network/providers/openwrt/init.lua index cafa61d0..31e7734f 100644 --- a/src/services/hal/backends/network/providers/openwrt/init.lua +++ b/src/services/hal/backends/network/providers/openwrt/init.lua @@ -13,6 +13,7 @@ local observer_mod = require 'services.hal.backends.network.providers.openwrt.ob local mwan3_mod = require 'services.hal.backends.network.providers.openwrt.mwan3' local shaper_mod = require 'services.hal.backends.network.providers.openwrt.tc_u32_shaper' local speedtest_mod = require 'services.hal.backends.network.providers.openwrt.speedtest' +local names_mod = require 'services.hal.backends.network.providers.openwrt.names' local hal_types = require 'services.hal.types.core' local perform = fibers.perform @@ -182,12 +183,6 @@ local function set_section(changes, config, section, stype) changes[#changes + 1] = { op = 'set', config = config, section = section, option = stype } end -local function set_managed_section(changes, config, section, stype) - set_section(changes, config, section, stype) - changes[#changes + 1] = { op = 'set', config = config, section = section, option = 'devicecode_managed', value = '1' } - changes[#changes + 1] = { op = 'set', config = config, section = section, option = 'devicecode_owner', value = 'net' } -end - local function set_option(changes, config, section, option, value) if value == nil then return end changes[#changes + 1] = { op = 'set', config = config, section = section, option = option, value = value } @@ -195,6 +190,8 @@ end local function ensure_pkg_file(confdir, pkg) if type(confdir) ~= 'string' or confdir == '' then return true, nil end + local ok = os.execute("mkdir -p '" .. confdir:gsub("'", "'\\''") .. "'") + if ok ~= true and ok ~= 0 then return nil, 'failed to create UCI confdir ' .. confdir end local path = confdir .. '/' .. pkg local f = io.open(path, 'rb') if f then f:close(); return true, nil end @@ -444,14 +441,14 @@ local function add_segment_trunk_interfaces(changes, known, intent, provider_con known[devsec] = true known[ifsec] = true - set_managed_section(changes, 'network', devsec, 'device') + set_section(changes, 'network', devsec, 'device') set_option(changes, 'network', devsec, 'type', '8021q') set_option(changes, 'network', devsec, 'ifname', base_ifname) set_option(changes, 'network', devsec, 'vid', vid) set_option(changes, 'network', devsec, 'name', devname) local proto, ipv4 = build_segment_interface_proto(seg) - set_managed_section(changes, 'network', ifsec, 'interface') + set_section(changes, 'network', ifsec, 'interface') set_option(changes, 'network', ifsec, 'proto', proto) set_option(changes, 'network', ifsec, 'auto', '1') set_option(changes, 'network', ifsec, 'disabled', '0') @@ -490,7 +487,7 @@ local function add_bridge_vlan_devices(changes, known, intent, iface_id, bridge_ local devname = bridge_name .. '.' .. tostring(vid) local devsec = section_id('device', iface_id .. '_' .. tostring(vid)) known[devsec] = true - set_managed_section(changes, 'network', devsec, 'device') + set_section(changes, 'network', devsec, 'device') set_option(changes, 'network', devsec, 'type', '8021q') set_option(changes, 'network', devsec, 'ifname', bridge_name) set_option(changes, 'network', devsec, 'vid', vid) @@ -512,7 +509,7 @@ local function build_network_changes(intent, provider_config) local known = {} local segment_to_ifaces, iface_to_device = collect_interface_maps(intent) - set_managed_section(changes, 'network', 'globals', 'globals') + set_section(changes, 'network', 'globals', 'globals') known.globals = true add_segment_trunk_interfaces(changes, known, intent, provider_config, segment_to_ifaces) @@ -524,7 +521,7 @@ local function build_network_changes(intent, provider_config) if iface.kind == 'bridge' then local devsec = section_id('device', ifid) known[devsec] = true - set_managed_section(changes, 'network', devsec, 'device') + set_section(changes, 'network', devsec, 'device') set_option(changes, 'network', devsec, 'name', 'br-' .. ifid) set_option(changes, 'network', devsec, 'type', 'bridge') set_option(changes, 'network', devsec, 'ports', iface.members or {}) @@ -539,7 +536,7 @@ local function build_network_changes(intent, provider_config) end if proto == 'manual' then proto = 'none' end - set_managed_section(changes, 'network', ifsec, 'interface') + set_section(changes, 'network', ifsec, 'interface') set_option(changes, 'network', ifsec, 'proto', proto) set_option(changes, 'network', ifsec, 'auto', iface.enabled == false and '0' or '1') set_option(changes, 'network', ifsec, 'disabled', iface.enabled == false and '1' or '0') @@ -563,7 +560,7 @@ local function build_network_changes(intent, provider_config) local r = item.rec local rsec = section_id('route', 'route_' .. tostring(item.id)) known[rsec] = true - set_managed_section(changes, 'network', rsec, 'route') + set_section(changes, 'network', rsec, 'route') set_option(changes, 'network', rsec, 'interface', r.interface or r.net) set_option(changes, 'network', rsec, 'target', r.target) set_option(changes, 'network', rsec, 'gateway', r.gateway or r.via) @@ -590,7 +587,7 @@ local function build_dhcp_changes(intent) if wants_dns then local dnssec = section_id('dns', 'dns_' .. seg_id) known[dnssec] = true - set_managed_section(changes, 'dhcp', dnssec, 'dnsmasq') + set_section(changes, 'dhcp', dnssec, 'dnsmasq') set_option(changes, 'dhcp', dnssec, 'domainneeded', dns.domainneeded ~= nil and bool_uci(dns.domainneeded) or '1') set_option(changes, 'dhcp', dnssec, 'boguspriv', dns.boguspriv ~= nil and bool_uci(dns.boguspriv) or '1') set_option(changes, 'dhcp', dnssec, 'localservice', dns.localservice ~= nil and bool_uci(dns.localservice) or '1') @@ -613,7 +610,7 @@ local function build_dhcp_changes(intent) local sec = section_id('dhcp', seg_id) known[sec] = true - set_managed_section(changes, 'dhcp', sec, 'dhcp') + set_section(changes, 'dhcp', sec, 'dhcp') set_option(changes, 'dhcp', sec, 'interface', seg_id) if dh.enabled == true then set_option(changes, 'dhcp', sec, 'start', dh.start or dh.range_start or defaults.start or 100) @@ -631,7 +628,7 @@ local function build_dhcp_changes(intent) if not is_plain_table(rec) then return end local sec = section_id('dhcp', 'host_' .. tostring(rid)) known[sec] = true - set_managed_section(changes, 'dhcp', sec, 'host') + set_section(changes, 'dhcp', sec, 'host') set_option(changes, 'dhcp', sec, 'name', rec.name or rid) set_option(changes, 'dhcp', sec, 'mac', rec.mac) set_option(changes, 'dhcp', sec, 'ip', rec.ip or rec.address) @@ -740,6 +737,374 @@ local function build_firewall_changes(intent, segment_to_ifaces) return changes, known end + +-- Strict generated-name builders. These supersede the older section-level +-- reconciler path above: Devicecode owns the OpenWrt UCI packages completely, +-- and all OpenWrt-visible names are allocated through names.lua. + + +local function seg_l2_mode(seg) + local l2 = is_plain_table(seg and seg.l2) and seg.l2 or {} + if type(l2.mode) == 'string' and l2.mode ~= '' then return l2.mode end + local kind = seg and seg.kind or 'lan' + if kind == 'wan' or kind == 'uplink' then return 'direct' end + return 'bridge' +end + +local function add_static_or_dhcp_interface(changes, ifsec, devname, proto, ipv4, auto, semantic_id) + set_section(changes, 'network', ifsec, 'interface') + set_option(changes, 'network', ifsec, 'proto', proto) + set_option(changes, 'network', ifsec, 'auto', auto == false and '0' or '1') + set_option(changes, 'network', ifsec, 'disabled', auto == false and '1' or '0') + set_option(changes, 'network', ifsec, 'device', devname) + if proto == 'static' then + local addr, prefix, netmask = cidr_to_addr_prefix(ipv4) + set_option(changes, 'network', ifsec, 'ipaddr', addr) + set_option(changes, 'network', ifsec, 'netmask', netmask or prefix_to_netmask(prefix)) + set_option(changes, 'network', ifsec, 'gateway', ipv4.gateway or ipv4.gw) + set_option(changes, 'network', ifsec, 'dns', ipv4.dns) + else + set_option(changes, 'network', ifsec, 'peerdns', bool_uci(ipv4.peerdns)) + set_option(changes, 'network', ifsec, 'defaultroute', bool_uci(ipv4.defaultroute)) + if ipv4.metric then set_option(changes, 'network', ifsec, 'metric', ipv4.metric) end + end +end + +local function build_network_changes_v2(intent, provider_config, name_ctx) + local changes = {} + local known = {} + local segment_to_ifaces = {} + + set_section(changes, 'network', 'loopback', 'interface') + known.loopback = true + set_option(changes, 'network', 'loopback', 'device', 'lo') + set_option(changes, 'network', 'loopback', 'proto', 'static') + set_option(changes, 'network', 'loopback', 'ipaddr', '127.0.0.1') + set_option(changes, 'network', 'loopback', 'netmask', '255.0.0.0') + + set_section(changes, 'network', 'globals', 'globals') + known.globals = true + set_option(changes, 'network', 'globals', 'ula_prefix', ((intent.addressing or {}).ipv6 or {}).ula_prefix or 'auto') + + local _trunk, base_ifname = platform_segment_trunk(provider_config) + local explicit_segment = {} + for _, ifid in ipairs(sorted_keys(intent.interfaces or {})) do + local iface = intent.interfaces[ifid] + if type(iface.segment) == 'string' then explicit_segment[iface.segment] = true end + if type(iface.segments) == 'table' then + for i = 1, #iface.segments do explicit_segment[iface.segments[i]] = true end + end + end + + if base_ifname then + for _, seg_id in ipairs(sorted_keys(intent.segments or {})) do + local seg = intent.segments[seg_id] + if segment_is_enabled(seg) and not explicit_segment[seg_id] then + local vid = segment_vlan_id(seg) + if vid then + local vlan_name = name_ctx:vlan(seg_id) + local vlan_sec = name_ctx:section('dev_vlan', seg_id) + known[vlan_sec] = true + set_section(changes, 'network', vlan_sec, 'device') + set_option(changes, 'network', vlan_sec, 'type', '8021q') + set_option(changes, 'network', vlan_sec, 'ifname', base_ifname) + set_option(changes, 'network', vlan_sec, 'vid', vid) + set_option(changes, 'network', vlan_sec, 'name', vlan_name) + + local devname = vlan_name + if seg_l2_mode(seg) == 'bridge' then + local br_name = name_ctx:bridge(seg_id) + local br_sec = name_ctx:section('dev_bridge', seg_id) + known[br_sec] = true + set_section(changes, 'network', br_sec, 'device') + set_option(changes, 'network', br_sec, 'name', br_name) + set_option(changes, 'network', br_sec, 'type', 'bridge') + set_option(changes, 'network', br_sec, 'ports', { vlan_name }) + set_option(changes, 'network', br_sec, 'bridge_empty', '1') + devname = br_name + end + + local ifsec = name_ctx:iface(seg_id) + known[ifsec] = true + local proto, ipv4 = build_segment_interface_proto(seg) + if seg.kind == 'wan' and proto == 'dhcp' and ipv4.defaultroute == nil then ipv4.defaultroute = false end + add_static_or_dhcp_interface(changes, ifsec, devname, proto, ipv4, true, seg_id) + segment_to_ifaces[seg_id] = segment_to_ifaces[seg_id] or {} + segment_to_ifaces[seg_id][#segment_to_ifaces[seg_id] + 1] = ifsec + end + end + end + end + + for _, ifid in ipairs(sorted_keys(intent.interfaces or {})) do + local iface = intent.interfaces[ifid] + local ifsec = name_ctx:iface(ifid) + known[ifsec] = true + local devname + if iface.kind == 'bridge' then + devname = name_ctx:bridge(ifid) + local devsec = name_ctx:section('dev_bridge', ifid) + known[devsec] = true + set_section(changes, 'network', devsec, 'device') + set_option(changes, 'network', devsec, 'name', devname) + set_option(changes, 'network', devsec, 'type', 'bridge') + set_option(changes, 'network', devsec, 'ports', iface.members or {}) + set_option(changes, 'network', devsec, 'bridge_empty', '1') + else + local ep = is_plain_table(iface.endpoint) and iface.endpoint or {} + devname = ep.ifname or ep.device or ep.name or iface.device + end + local seg = iface.segment and (intent.segments or {})[iface.segment] or nil + local ipv4 = ipv4_spec(iface.addressing, seg and seg.addressing or {}) + local proto = ipv4.mode or ipv4.proto + if proto == nil then proto = iface.role == 'wan' and 'dhcp' or 'static' end + if proto == 'manual' then proto = 'none' end + if iface.role == 'wan' and proto == 'dhcp' and ipv4.defaultroute == nil then ipv4.defaultroute = false end + add_static_or_dhcp_interface(changes, ifsec, devname, proto, ipv4, iface.enabled ~= false, ifid) + if iface.mtu then set_option(changes, 'network', ifsec, 'mtu', iface.mtu) end + + local segs = {} + if type(iface.segment) == 'string' then segs[#segs + 1] = iface.segment end + if type(iface.segments) == 'table' then for i = 1, #iface.segments do segs[#segs + 1] = iface.segments[i] end end + for i = 1, #segs do + segment_to_ifaces[segs[i]] = segment_to_ifaces[segs[i]] or {} + segment_to_ifaces[segs[i]][#segment_to_ifaces[segs[i]] + 1] = ifsec + end + end + for _, list in pairs(segment_to_ifaces) do table.sort(list) end + + local routes = (is_plain_table(intent.routing) and intent.routing.routes) or {} + for _, item in ipairs(route_entries(routes)) do + local r = item.rec + local rsec = name_ctx:section('route', item.id) + known[rsec] = true + set_section(changes, 'network', rsec, 'route') + set_option(changes, 'network', rsec, 'interface', r.interface and name_ctx:iface(r.interface) or r.net) + set_option(changes, 'network', rsec, 'target', r.target) + set_option(changes, 'network', rsec, 'gateway', r.gateway or r.via) + set_option(changes, 'network', rsec, 'metric', r.metric) + set_option(changes, 'network', rsec, 'table', r.table) + end + + return changes, known, segment_to_ifaces +end + +local function canonical_list(list) + local out = ensure_array(list) + table.sort(out, function(a, b) return tostring(a) < tostring(b) end) + return out +end + +local function list_key(list) + local out = {} + for i = 1, #(list or {}) do out[i] = tostring(list[i]) end + return table.concat(out, ',') +end + +local function dns_effective_for_segment(dns, dhcp, seg_id, seg) + local dh = is_plain_table(seg.dhcp) and seg.dhcp or {} + local seg_dns = is_plain_table(seg.dns) and seg.dns or {} + local host_ids = canonical_list(seg_dns.host_files or seg_dns.host_sources or {}) + local wants_dns = dns.enabled ~= false and seg_dns.enabled ~= false and (dh.enabled == true or seg_dns.local_server == true or seg_dns['local'] == true or #host_ids > 0) + if not wants_dns then return nil end + local addnhosts, addnmounts = resolve_host_file_sources(dns, seg) + local addresses = dns_records_for_segment(dns, seg_id) + local domain = seg_dns.domain or dns.domain + local upstreams = canonical_list(dns.upstreams or {}) + local label = #host_ids > 0 and table.concat(host_ids, '_') or 'standard' + local key = table.concat({ + tostring(domain or ''), + list_key(upstreams), + list_key(addnhosts), + list_key(addnmounts), + list_key(addresses), + tostring(dns.domainneeded ~= false), + tostring(dns.boguspriv ~= false), + tostring(dns.localservice ~= false), + }, '|') + return { + key = key, + label = label, + domain = domain, + upstreams = upstreams, + addnhosts = addnhosts, + addnmounts = addnmounts, + addresses = addresses, + cachesize = is_plain_table(dns.cache) and dns.cache.size or nil, + authoritative = (is_plain_table(dhcp.defaults) and dhcp.defaults.authoritative), + } +end + +local function build_dhcp_changes_v2(intent, name_ctx, segment_to_ifaces) + local changes = {} + local known = {} + local dns = is_plain_table(intent.dns) and intent.dns or {} + local dhcp = is_plain_table(intent.dhcp) and intent.dhcp or {} + local defaults = is_plain_table(dhcp.defaults) and dhcp.defaults or {} + local groups = {} + local seg_instance = {} + + for _, seg_id in ipairs(sorted_keys(intent.segments or {})) do + local seg = intent.segments[seg_id] + local eff = dns_effective_for_segment(dns, dhcp, seg_id, seg) + if eff then + groups[eff.key] = groups[eff.key] or eff + groups[eff.key].segments = groups[eff.key].segments or {} + groups[eff.key].interfaces = groups[eff.key].interfaces or {} + groups[eff.key].segments[#groups[eff.key].segments + 1] = seg_id + local ifaces = segment_to_ifaces[seg_id] or { name_ctx:iface(seg_id) } + for i = 1, #ifaces do groups[eff.key].interfaces[#groups[eff.key].interfaces + 1] = ifaces[i] end + seg_instance[seg_id] = eff.key + end + end + + for _, key in ipairs(sorted_keys(groups)) do + local g = groups[key] + table.sort(g.interfaces) + local dnssec = name_ctx:dns_instance(g.label .. '_' .. key) + known[dnssec] = true + set_section(changes, 'dhcp', dnssec, 'dnsmasq') + set_option(changes, 'dhcp', dnssec, 'domainneeded', dns.domainneeded ~= nil and bool_uci(dns.domainneeded) or '1') + set_option(changes, 'dhcp', dnssec, 'boguspriv', dns.boguspriv ~= nil and bool_uci(dns.boguspriv) or '1') + set_option(changes, 'dhcp', dnssec, 'localise_queries', '1') + set_option(changes, 'dhcp', dnssec, 'rebind_protection', '1') + set_option(changes, 'dhcp', dnssec, 'rebind_localhost', '1') + set_option(changes, 'dhcp', dnssec, 'expandhosts', '1') + set_option(changes, 'dhcp', dnssec, 'nonegcache', '0') + set_option(changes, 'dhcp', dnssec, 'readethers', '1') + set_option(changes, 'dhcp', dnssec, 'nonwildcard', '1') + set_option(changes, 'dhcp', dnssec, 'localservice', dns.localservice ~= nil and bool_uci(dns.localservice) or '1') + set_option(changes, 'dhcp', dnssec, 'authoritative', g.authoritative ~= nil and bool_uci(g.authoritative) or '1') + set_option(changes, 'dhcp', dnssec, 'port', '53') + set_option(changes, 'dhcp', dnssec, 'noresolv', '1') + set_option(changes, 'dhcp', dnssec, 'interface', g.interfaces) + set_option(changes, 'dhcp', dnssec, 'leasefile', '/tmp/dhcp.leases.' .. dnssec) + set_option(changes, 'dhcp', dnssec, 'resolvfile', '/tmp/resolv.conf.d/resolv.conf.auto') + if g.cachesize ~= nil then set_option(changes, 'dhcp', dnssec, 'cachesize', g.cachesize) end + if #g.upstreams > 0 then set_option(changes, 'dhcp', dnssec, 'server', g.upstreams) end + if type(g.domain) == 'string' and g.domain ~= '' then + set_option(changes, 'dhcp', dnssec, 'domain', g.domain) + set_option(changes, 'dhcp', dnssec, 'local', '/' .. g.domain .. '/') + end + if #g.addnhosts > 0 then set_option(changes, 'dhcp', dnssec, 'addnhosts', g.addnhosts) end + if #g.addnmounts > 0 then set_option(changes, 'dhcp', dnssec, 'addnmount', g.addnmounts) end + if #g.addresses > 0 then set_option(changes, 'dhcp', dnssec, 'address', g.addresses) end + g.instance = dnssec + end + + for _, seg_id in ipairs(sorted_keys(intent.segments or {})) do + local seg = intent.segments[seg_id] + local dh = is_plain_table(seg.dhcp) and seg.dhcp or {} + local sec = name_ctx:section('dhcp', seg_id) + known[sec] = true + set_section(changes, 'dhcp', sec, 'dhcp') + set_option(changes, 'dhcp', sec, 'interface', (segment_to_ifaces[seg_id] and segment_to_ifaces[seg_id][1]) or name_ctx:iface(seg_id)) + local g = groups[seg_instance[seg_id]] + if g and g.instance then set_option(changes, 'dhcp', sec, 'instance', g.instance) end + if dh.enabled == true then + set_option(changes, 'dhcp', sec, 'start', dh.start or dh.range_start or defaults.start or 100) + set_option(changes, 'dhcp', sec, 'limit', dh.limit or dh.range_limit or defaults.limit or 150) + set_option(changes, 'dhcp', sec, 'leasetime', dh.leasetime or dh.lease_time or defaults.leasetime or defaults.lease_time or '12h') + local opts = dh.options or (is_plain_table(dhcp.options) and dhcp.options[seg_id]) + if type(opts) == 'table' then set_option(changes, 'dhcp', sec, 'dhcp_option', opts) end + else + set_option(changes, 'dhcp', sec, 'ignore', '1') + end + end + + local reservations = is_plain_table(dhcp.reservations) and dhcp.reservations or {} + local function add_reservation(rid, rec) + if not is_plain_table(rec) then return end + local sec = name_ctx:section('host', rid) + known[sec] = true + set_section(changes, 'dhcp', sec, 'host') + set_option(changes, 'dhcp', sec, 'name', rec.name or rid) + set_option(changes, 'dhcp', sec, 'mac', rec.mac) + set_option(changes, 'dhcp', sec, 'ip', rec.ip or rec.address) + set_option(changes, 'dhcp', sec, 'leasetime', rec.leasetime or rec.lease_time) + set_option(changes, 'dhcp', sec, 'hostid', rec.hostid) + set_option(changes, 'dhcp', sec, 'duid', rec.duid) + end + if #reservations > 0 then for i = 1, #reservations do add_reservation(tostring(i), reservations[i]) end else for _, rid in ipairs(sorted_keys(reservations)) do add_reservation(rid, reservations[rid]) end end + + return changes, known +end + +local function build_firewall_changes_v2(intent, segment_to_ifaces, name_ctx) + local changes = {} + local known = {} + local fw = is_plain_table(intent.firewall) and intent.firewall or {} + local defaults = is_plain_table(fw.defaults) and fw.defaults or {} + set_section(changes, 'firewall', 'defaults', 'defaults') + known.defaults = true + local wrote = {} + for _, key in ipairs({ 'input', 'output', 'forward' }) do + set_option(changes, 'firewall', 'defaults', key, defaults[key] or (key == 'output' and 'ACCEPT' or 'REJECT')) + wrote[key] = true + end + for _, key in ipairs(sorted_keys(defaults)) do if not wrote[key] then set_option(changes, 'firewall', 'defaults', key, defaults[key]) end end + + local zone_to_networks = {} + for _, seg_id in ipairs(sorted_keys(intent.segments or {})) do + local seg = intent.segments[seg_id] + local zname = segment_zone_name(seg_id, seg) + zone_to_networks[zname] = zone_to_networks[zname] or {} + local ifaces = segment_to_ifaces[seg_id] + if ifaces and #ifaces > 0 then for i = 1, #ifaces do zone_to_networks[zname][#zone_to_networks[zname] + 1] = ifaces[i] end end + end + local zone_specs = is_plain_table(fw.zones) and fw.zones or {} + for _, zname in ipairs(sorted_keys(zone_specs)) do zone_to_networks[zname] = zone_to_networks[zname] or {} end + for _, zname in ipairs(sorted_keys(zone_to_networks)) do + local zsec = name_ctx:section('zone', zname) + local zspec = is_plain_table(zone_specs[zname]) and zone_specs[zname] or {} + local nets = zone_to_networks[zname] + table.sort(nets) + local oz = name_ctx:zone(zname) + known[zsec] = true + set_section(changes, 'firewall', zsec, 'zone') + set_option(changes, 'firewall', zsec, 'name', oz) + if #nets > 0 then set_option(changes, 'firewall', zsec, 'network', nets) end + set_option(changes, 'firewall', zsec, 'input', zspec.input or 'ACCEPT') + set_option(changes, 'firewall', zsec, 'output', zspec.output or 'ACCEPT') + set_option(changes, 'firewall', zsec, 'forward', zspec.forward or 'REJECT') + for _, key in ipairs(sorted_keys(zspec)) do + if key ~= 'input' and key ~= 'output' and key ~= 'forward' and key ~= 'network' then set_option(changes, 'firewall', zsec, key, zspec[key]) end + end + end + local policies = is_plain_table(fw.policies) and fw.policies or {} + local n = 0 + for _, pid in ipairs(sorted_keys(policies)) do + local p = policies[pid] + if is_plain_table(p) then + local src = p.src or p.from + local dest = p.dest or p.to + if type(src) == 'string' and type(dest) == 'string' then + n = n + 1 + local sec = name_ctx:section('fwd', pid .. '_' .. tostring(n)) + known[sec] = true + set_section(changes, 'firewall', sec, 'forwarding') + set_option(changes, 'firewall', sec, 'src', name_ctx:zone(src)) + set_option(changes, 'firewall', sec, 'dest', name_ctx:zone(dest)) + end + end + end + local rules = is_plain_table(fw.rules) and fw.rules or {} + local function add_rule(rid, r) + if not is_plain_table(r) then return end + local sec = name_ctx:section('rule', rid) + known[sec] = true + set_section(changes, 'firewall', sec, 'rule') + set_option(changes, 'firewall', sec, 'name', r.name or rid) + for _, key in ipairs({ 'enabled', 'family', 'proto', 'src_ip', 'dest_ip', 'src_port', 'dest_port', 'icmp_type', 'target', 'limit', 'limit_burst', 'extra', 'utc_time', 'weekdays', 'monthdays', 'start_time', 'stop_time', 'start_date', 'stop_date' }) do + set_option(changes, 'firewall', sec, key, r[key]) + end + if r.src then set_option(changes, 'firewall', sec, 'src', name_ctx:zone(r.src)) end + if r.dest then set_option(changes, 'firewall', sec, 'dest', name_ctx:zone(r.dest)) end + end + if #rules > 0 then for i = 1, #rules do add_rule(tostring(i), rules[i]) end else for _, rid in ipairs(sorted_keys(rules)) do add_rule(rid, rules[rid]) end end + return changes, known +end + local function reconcile_package_changes(pkg, changes, known, current_pkg) local out = {} for secname, rec in pairs(current_pkg or {}) do @@ -756,10 +1121,11 @@ local function reconcile_package_changes(pkg, changes, known, current_pkg) return out end -local function transaction_record(pkg, changes, known, current_pkg, restart_cmds) +local function transaction_record(pkg, changes, _known, _current_pkg, restart_cmds) return { config = pkg, - changes = reconcile_package_changes(pkg, changes, known, current_pkg), + replace_package = true, + changes = changes or {}, restart_cmds = restart_cmds or {}, } end @@ -804,7 +1170,7 @@ function Provider:_manager() end end local mgr, err = uci_manager.new({ - confdir = self.config.confdir or self.config.uci_confdir, + confdir = self.config.confdir or self.config.uci_confdir or '/etc/config', savedir = self.config.savedir or self.config.uci_savedir, allow_fake = self.config.allow_fake_uci == true, debounce_s = self.config.debounce_s or 0.02, @@ -825,8 +1191,8 @@ function Provider:_ensure_started() local ok, serr = mgr:start(scope) if ok ~= true then return nil, serr end end - local confdir = self.config.confdir or self.config.uci_confdir - if confdir then + local confdir = self.config.confdir or self.config.uci_confdir or '/etc/config' + if mgr._fake ~= true then for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do local ok, eerr = ensure_pkg_file(confdir, pkg) if ok ~= true then return nil, eerr end @@ -895,10 +1261,11 @@ function Provider:plan_op(req) local intent = req and (req.intent or req.desired or req) local valid = validate_intent(intent) if not valid or valid.ok ~= true then return op.always(valid) end - local n_changes, n_known, segment_to_ifaces = build_network_changes(intent, self.config) - local d_changes, d_known = build_dhcp_changes(intent) - local f_changes, f_known = build_firewall_changes(intent, segment_to_ifaces) - local m_changes, m_known, m_plan = mwan3_mod.build_changes(intent) + local name_ctx = names_mod.allocate(intent, self.config) + local n_changes, n_known, segment_to_ifaces = build_network_changes_v2(intent, self.config, name_ctx) + local d_changes, d_known = build_dhcp_changes_v2(intent, name_ctx, segment_to_ifaces) + local f_changes, f_known = build_firewall_changes_v2(intent, segment_to_ifaces, name_ctx) + local m_changes, m_known, m_plan = mwan3_mod.build_changes(intent, name_ctx) local shaping = build_shaping_request(intent, self.config) local domains = { vlan = { status = 'implemented' }, @@ -917,6 +1284,7 @@ function Provider:plan_op(req) firewall = { changes = #f_changes, sections = count_keys(f_known) }, mwan3 = { changes = #m_changes, sections = count_keys(m_known) }, }, + openwrt_names = name_ctx:snapshot(), }, }) end @@ -930,29 +1298,26 @@ function Provider:apply_op(req) local mgr, merr = self:_ensure_started() if not mgr then return { ok = false, err = merr or 'uci manager unavailable', backend = 'openwrt' } end - local current = {} - local read_current, read_err = read_uci_packages(self.config) - if read_current then current = read_current end - - local n_changes, n_known, segment_to_ifaces = build_network_changes(intent, self.config) - local d_changes, d_known = build_dhcp_changes(intent) - local f_changes, f_known = build_firewall_changes(intent, segment_to_ifaces) - local m_changes, m_known, m_plan = mwan3_mod.build_changes(intent) + local name_ctx = names_mod.allocate(intent, self.config) + local n_changes, n_known, segment_to_ifaces = build_network_changes_v2(intent, self.config, name_ctx) + local d_changes, d_known = build_dhcp_changes_v2(intent, name_ctx, segment_to_ifaces) + local f_changes, f_known = build_firewall_changes_v2(intent, segment_to_ifaces, name_ctx) + local m_changes, m_known, m_plan = mwan3_mod.build_changes(intent, name_ctx) local records = { - transaction_record('network', n_changes, n_known, current.network, { + transaction_record('network', n_changes, n_known, nil, { { kind = 'reload', target = 'network' }, }), - transaction_record('dhcp', d_changes, d_known, current.dhcp, { + transaction_record('dhcp', d_changes, d_known, nil, { { kind = 'restart', target = 'dnsmasq' }, }), - transaction_record('firewall', f_changes, f_known, current.firewall, { + transaction_record('firewall', f_changes, f_known, nil, { { kind = 'restart', target = 'firewall' }, }), -- Always include mwan3 so stale generated multi-WAN state is removed when -- WAN/multi-WAN is disabled or a member disappears. Structural apply still -- does not restart mwan3; live rules are handled separately. - transaction_record('mwan3', m_changes, m_known, current.mwan3, {}), + transaction_record('mwan3', m_changes, m_known, nil, {}), } local packages = { 'network', 'dhcp', 'firewall', 'mwan3' } local tx_result, admitted = fibers.perform(mgr:transaction_op({ @@ -971,10 +1336,12 @@ function Provider:apply_op(req) failed_step = tx_result and tx_result.failed_step or nil, failed_config = tx_result and tx_result.failed_config or nil, changed = tx_result and tx_result.status == 'failed_rollback_failed' or false, - read_current_err = read_err, } end + self._last_name_ctx = name_ctx + self._last_openwrt_names = name_ctx:snapshot() + local shaping_result = nil local shaping_request = build_shaping_request(intent, self.config) if shaping_request and shaping_request.enabled == true then @@ -993,6 +1360,7 @@ function Provider:apply_op(req) packages = packages, transaction = tx_result, multiwan = m_plan, + openwrt_names = name_ctx:snapshot(), shaping = shaping_result, } end):wrap(function(status, _report, result) @@ -1021,11 +1389,16 @@ local function command_capture(argv) return nil, out or '', detail end -local function ubus_call(object, method, payload) +local function ubus_call(config, object, method, payload) payload = payload or {} local encoded = cjson.encode(payload) if type(encoded) ~= 'string' then return nil, 'ubus payload encode failed' end - local ok, out, err = command_capture({ 'ubus', 'call', tostring(object), tostring(method), encoded }) + + local timeout_s = tonumber(config and config.ubus_timeout_s) or 2 + if timeout_s < 1 then timeout_s = 1 end + local argv = { 'ubus', '-t', tostring(timeout_s), 'call', tostring(object), tostring(method), encoded } + + local ok, out, err = command_capture(argv) if not ok then return nil, err end local decoded, derr = cjson.decode(out or '') if type(decoded) ~= 'table' then return nil, derr or 'ubus JSON decode failed' end @@ -1116,10 +1489,11 @@ local function normalise_device_status(name, st) } end -local function normalise_mwan3_status(st) +local function normalise_mwan3_status(st, name_ctx) local out = { available = type(st) == 'table', interfaces = {}, + interfaces_by_semantic = {}, policies = {}, connected = {}, raw = copy_plain(st or {}), @@ -1141,17 +1515,21 @@ local function normalise_mwan3_status(st) end local state = rec.status if rec.enabled == false then state = 'disabled' end - out.interfaces[ifid] = { + local online = state == 'online' or rec.up == true or rec.online == true + local item = { interface = ifid, + semantic_interface = (name_ctx and type(name_ctx.semantic_for) == 'function' and name_ctx:semantic_for('mwan_iface', ifid)) or ifid, state = state, mwan3_status = rec.status, enabled = rec.enabled, running = rec.running, tracking = rec.tracking, up = rec.up, + usable = online, age = tonumber(rec.age), uptime = tonumber(rec.uptime), - online = tonumber(rec.online), + online = online, + online_count = tonumber(rec.online), offline = tonumber(rec.offline), score = tonumber(rec.score), lost = tonumber(rec.lost), @@ -1159,6 +1537,8 @@ local function normalise_mwan3_status(st) probes = probes, raw = copy_plain(rec), } + out.interfaces[ifid] = item + if item.semantic_interface then out.interfaces_by_semantic[item.semantic_interface] = item end end end out.policies = copy_plain(st.policies or {}) @@ -1173,7 +1553,7 @@ local function augment_with_live_snapshot(config, observed, req, subject, trigge local devices, device_seen = {}, {} for i = 1, #ifaces do local ifid = ifaces[i] - local st, err = ubus_call('network.interface.' .. tostring(ifid), 'status', {}) + local st, err = ubus_call(config, 'network.interface.' .. tostring(ifid), 'status', {}) if st then local norm = normalise_interface_status(ifid, st) live.interfaces[ifid] = norm @@ -1191,16 +1571,16 @@ local function augment_with_live_snapshot(config, observed, req, subject, trigge end for i = 1, #devices do local dev = devices[i] - local st, err = ubus_call('network.device', 'status', { name = dev }) + local st, err = ubus_call(config, 'network.device', 'status', { name = dev }) if st then live.devices[dev] = normalise_device_status(dev, st) else append_error(live.errors, 'network.device status ' .. tostring(dev) .. ': ' .. tostring(err)) end end - local mwan, merr = ubus_call('mwan3', 'status', {}) + local mwan, merr = ubus_call(config, 'mwan3', 'status', {}) if mwan then - observed.multiwan = normalise_mwan3_status(mwan) + observed.multiwan = normalise_mwan3_status(mwan, config.name_ctx) else observed.multiwan = observed.multiwan or { available = false, interfaces = {}, policies = {}, connected = {} } observed.multiwan.available = false @@ -1215,8 +1595,11 @@ function build_observed_snapshot(self, req, subject, trigger) local packages, err = read_uci_packages(self.config) if not packages then return nil, err end local observed = snapshot_from_packages(packages) - if req.live ~= false and self.config.enable_live_snapshot ~= false then + if req.live == true and self.config.enable_live_snapshot ~= false then + local old_ctx = self.config.name_ctx + self.config.name_ctx = self._last_name_ctx augment_with_live_snapshot(self.config, observed, req, subject, trigger) + self.config.name_ctx = old_ctx end return observed, packages end @@ -1226,7 +1609,7 @@ function read_uci_packages(config) if not ok or not uci_or_err or type(uci_or_err.cursor) ~= 'function' then return nil, 'uci module unavailable' end - local c = uci_or_err.cursor(config.confdir or config.uci_confdir, config.savedir or config.uci_savedir) + local c = uci_or_err.cursor(config.confdir or config.uci_confdir or '/etc/config', config.savedir or config.uci_savedir) local out = {} for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end @@ -1465,10 +1848,68 @@ function Provider:read_counters_op(_req) end + +local function translate_mwan_policy_for_ctx(name_ctx, policy) + if not name_ctx or type(name_ctx.mwan_policy) ~= 'function' then return policy end + local p = policy or 'balanced' + if type(p) == 'string' and type(name_ctx.semantic_for) == 'function' and name_ctx:semantic_for('mwan_policy', p) then + return p + end + return name_ctx:mwan_policy(p) +end + +local function translate_mwan_iface_for_ctx(name_ctx, iface) + if not name_ctx or type(name_ctx.mwan_iface) ~= 'function' then return iface end + if type(iface) ~= 'string' or iface == '' then return iface end + if type(name_ctx.semantic_for) == 'function' and name_ctx:semantic_for('mwan_iface', iface) then + return iface + end + return name_ctx:mwan_iface(iface) +end + +local function translate_live_weights_req(req, name_ctx) + if not name_ctx then return req or {} end + local out = copy_plain(req or {}) or {} + out.policy = translate_mwan_policy_for_ctx(name_ctx, out.policy or 'balanced') + local members = {} + for i, m in ipairs((req and req.members) or {}) do + if is_plain_table(m) then + local mm = copy_plain(m) or {} + local semantic_iface = m.openwrt_interface or m.interface or m.iface or m.link_id or m.id + local generated_iface = translate_mwan_iface_for_ctx(name_ctx, semantic_iface) + if type(generated_iface) == 'string' and generated_iface ~= '' then + mm.semantic_interface = semantic_iface + mm.openwrt_interface = generated_iface + mm.interface = generated_iface + end + members[#members + 1] = mm + else + members[#members + 1] = m + end + end + out.members = members + return out +end + +local function translate_speedtest_req(req, name_ctx) + if not name_ctx then return req or {} end + local out = copy_plain(req or {}) or {} + local semantic_iface = out.openwrt_interface or out.interface or out.iface + local generated_iface = translate_mwan_iface_for_ctx(name_ctx, semantic_iface) + if type(generated_iface) == 'string' and generated_iface ~= '' then + out.semantic_interface = semantic_iface + out.openwrt_interface = generated_iface + out.interface = generated_iface + end + return out +end + function Provider:apply_live_weights_op(req) return fibers.run_scope_op(function() if self.terminated then return { ok = false, err = 'provider terminated', backend = 'openwrt' } end - local result = mwan3_mod.apply_live_weights(req or {}, { + local original_req = req or {} + local live_req = translate_live_weights_req(original_req, self._last_name_ctx) + local result = mwan3_mod.apply_live_weights(live_req, { apply_mwan_live_weights = self.apply_mwan_live_weights, run_cmd = self.mwan_run_cmd, run_cmd_capture = self.mwan_run_cmd_capture, @@ -1478,7 +1919,7 @@ function Provider:apply_live_weights_op(req) if persist then local mgr, merr = self:_ensure_started() if mgr then - local ok, err, admitted = fibers.perform(mwan3_mod.persist_weights_op(mgr, req or {})) + local ok, err, admitted = fibers.perform(mwan3_mod.persist_weights_op(mgr, original_req, self._last_name_ctx)) result.persisted = ok == true result.persist_err = err result.persist_admitted = admitted @@ -1507,7 +1948,7 @@ function Provider:apply_shaping_op(req) end function Provider:speedtest_op(req) - return speedtest_mod.run_op(req or {}, { run_cmd = self.speedtest_run_cmd }) + return speedtest_mod.run_op(translate_speedtest_req(req or {}, self._last_name_ctx), { run_cmd = self.speedtest_run_cmd }) end function Provider:terminate(reason) diff --git a/src/services/hal/backends/network/providers/openwrt/mwan3.lua b/src/services/hal/backends/network/providers/openwrt/mwan3.lua index 3d61b399..df18d79c 100644 --- a/src/services/hal/backends/network/providers/openwrt/mwan3.lua +++ b/src/services/hal/backends/network/providers/openwrt/mwan3.lua @@ -19,8 +19,6 @@ local function sid(v) end local function set_section(changes, config, section, stype) changes[#changes + 1] = { op = 'set', config = config, section = section, option = stype } - changes[#changes + 1] = { op = 'set', config = config, section = section, option = 'devicecode_managed', value = '1' } - changes[#changes + 1] = { op = 'set', config = config, section = section, option = 'devicecode_owner', value = 'net' } end local function set_option(changes, config, section, option, value) if value == nil then return end @@ -33,13 +31,36 @@ local function member_iface(id, spec) return spec.interface or spec.iface or spec.network_interface or spec.openwrt_interface or id end -function M.build_changes(intent) +function M.build_changes(intent, name_ctx) local wan = is_plain_table(intent and intent.wan) and intent.wan or {} local members = is_plain_table(wan.members) and wan.members or {} local changes, known = {}, {} if wan.enabled == false or next(members) == nil then return changes, known, { enabled = false } end + if not name_ctx then + local ok_names, names_mod = pcall(require, 'services.hal.backends.network.providers.openwrt.names') + if ok_names and names_mod and type(names_mod.allocate) == 'function' then + name_ctx = names_mod.allocate(intent) + end + end + local function mw_iface(id) + if name_ctx and type(name_ctx.mwan_iface) == 'function' then return name_ctx:mwan_iface(id) end + return sid(id) + end + local function mw_member(id) + if name_ctx and type(name_ctx.mwan_member) == 'function' then return name_ctx:mwan_member(id) end + return sid(id) + end + local function mw_policy(id) + if name_ctx and type(name_ctx.mwan_policy) == 'function' then return name_ctx:mwan_policy(id) end + return sid(id) + end + local function mw_rule(id) + if name_ctx and type(name_ctx.mwan_rule) == 'function' then return name_ctx:mwan_rule(id) end + return sid(id) + end + set_section(changes, 'mwan3', 'globals', 'globals') known.globals = true set_option(changes, 'mwan3', 'globals', 'mmx_mask', (wan.runtime and wan.runtime.mmx_mask) or '0x3F00') @@ -49,10 +70,10 @@ function M.build_changes(intent) local member_sections = {} for _, mid in ipairs(sorted_keys(members)) do local m = is_plain_table(members[mid]) and members[mid] or {} - local iface = member_iface(mid, m) + local iface_sem = member_iface(mid, m) local metric = math.floor(tonumber(m.metric or m.priority or 1) or 1) local weight = math.max(1, math.floor(tonumber(m.weight or 1) or 1)) - local ifsec = sid(iface) + local ifsec = mw_iface(iface_sem) known[ifsec] = true set_section(changes, 'mwan3', ifsec, 'interface') set_option(changes, 'mwan3', ifsec, 'enabled', '1') @@ -64,7 +85,7 @@ function M.build_changes(intent) set_option(changes, 'mwan3', ifsec, 'interval', m.interval or health.interval) set_option(changes, 'mwan3', ifsec, 'up', m.up or health.up) set_option(changes, 'mwan3', ifsec, 'down', m.down or health.down) - local member_sec = sid(mid .. '_m' .. tostring(metric) .. '_w' .. tostring(weight)) + local member_sec = mw_member(mid) known[member_sec] = true set_section(changes, 'mwan3', member_sec, 'member') set_option(changes, 'mwan3', member_sec, 'interface', ifsec) @@ -74,21 +95,22 @@ function M.build_changes(intent) end table.sort(member_sections) local policy_name = (wan.load_balancing and wan.load_balancing.policy) or wan.policy_name or 'balanced' - if policy_name == 'failover' or policy_name == 'weighted_failover' then policy_name = 'balanced' end - policy_name = sid(policy_name) + if policy_name == 'failover' or policy_name == 'weighted_failover' or policy_name == 'dynamic_weight' then policy_name = 'balanced' end + policy_name = mw_policy(policy_name) known[policy_name] = true set_section(changes, 'mwan3', policy_name, 'policy') set_option(changes, 'mwan3', policy_name, 'use_member', member_sections) set_option(changes, 'mwan3', policy_name, 'last_resort', wan.last_resort or 'unreachable') - known.default_rule_v4 = true - set_section(changes, 'mwan3', 'default_rule_v4', 'rule') - set_option(changes, 'mwan3', 'default_rule_v4', 'dest_ip', '0.0.0.0/0') - set_option(changes, 'mwan3', 'default_rule_v4', 'family', 'ipv4') - set_option(changes, 'mwan3', 'default_rule_v4', 'use_policy', policy_name) + local rule_name = mw_rule('default_rule_v4') + known[rule_name] = true + set_section(changes, 'mwan3', rule_name, 'rule') + set_option(changes, 'mwan3', rule_name, 'dest_ip', '0.0.0.0/0') + set_option(changes, 'mwan3', rule_name, 'family', 'ipv4') + set_option(changes, 'mwan3', rule_name, 'use_policy', policy_name) return changes, known, { enabled = true, policy = policy_name, members = member_sections } end -function M.persist_weights_op(mgr, req) +function M.persist_weights_op(mgr, req, name_ctx) local members = req and req.members or {} local live = { wan = { enabled = true, members = {} } } for i = 1, #members do @@ -96,7 +118,7 @@ function M.persist_weights_op(mgr, req) local id = m.id or m.link_id or m.interface or ('member' .. tostring(i)) live.wan.members[id] = { interface = m.interface or m.iface or m.link_id, metric = m.metric or 1, weight = m.weight or 1 } end - local changes = M.build_changes(live) + local changes = M.build_changes(live, name_ctx) return mgr:submit_op({ config = 'mwan3', changes = changes, restart_cmds = {} }) end diff --git a/src/services/hal/backends/network/providers/openwrt/names.lua b/src/services/hal/backends/network/providers/openwrt/names.lua new file mode 100644 index 00000000..ebd7801f --- /dev/null +++ b/src/services/hal/backends/network/providers/openwrt/names.lua @@ -0,0 +1,328 @@ +-- services/hal/backends/network/providers/openwrt/names.lua +-- Bounded OpenWrt name allocation. +-- +-- Product ids are semantic names. OpenWrt names are provider artefacts with +-- small hard limits; generate them centrally so long or hostile config ids can +-- never leak into netifd, Linux device, firewall, dnsmasq or mwan3 namespaces. + +local M = {} +local Ctx = {} +Ctx.__index = Ctx + +local MAX = { + logical_interface = 8, + linux_device = 14, + bridge_device = 14, + firewall_zone = 11, + mwan_name = 15, + dnsmasq_instance = 15, + uci_section = 32, +} + +local function is_plain_table(v) + return type(v) == 'table' and getmetatable(v) == nil +end + +local function sorted_keys(t) + local ks = {} + for k in pairs(t or {}) do ks[#ks + 1] = k end + table.sort(ks, function(a, b) return tostring(a) < tostring(b) end) + return ks +end + +local function clean(s) + s = tostring(s or ''):lower():gsub('[^a-z0-9]', '') + return s +end + +local function uci_clean(s) + s = tostring(s or ''):gsub('[^%w_]', '_') + if s == '' then s = 'x' end + if not s:match('^[A-Za-z_]') then s = 'x_' .. s end + return s +end + +local function safe_plain_name(s, max_len) + s = tostring(s or '') + if #s == 0 or #s > max_len then return nil end + if not s:match('^[A-Za-z][A-Za-z0-9_]*$') then return nil end + return s +end + +local function readable_prefix(seed, fallback, n) + local s = clean(seed) + if s == '' then s = clean(fallback) end + if s == '' then s = 'x' end + while #s < n do s = s .. 'x' end + if not s:sub(1, 1):match('%a') then s = 'x' .. s end + return s:sub(1, n) +end + +local function uint32_to_hex(n) + -- Avoid string.format('%x', n). On some Lua 5.3/5.4 builds, values + -- that have passed through floating-point arithmetic are tagged as + -- numbers rather than integers and '%x' raises "integer expected". + -- This pure arithmetic formatter works on Lua 5.1/LuaJIT/5.3+. + local hex = '0123456789abcdef' + n = math.floor(tonumber(n) or 0) % 4294967296 + local out = {} + for i = 8, 1, -1 do + local d = n % 16 + out[i] = hex:sub(d + 1, d + 1) + n = (n - d) / 16 + end + return table.concat(out) +end + +local function hash_hex(seed, len) + -- Small deterministic FNV-1a-like hash using only double-safe arithmetic. + local h = 2166136261 + seed = tostring(seed or '') + for i = 1, #seed do + h = (h + seed:byte(i)) % 4294967296 + h = (h * 16777619) % 4294967296 + end + local s = uint32_to_hex(h) + while #s < len do s = s .. s end + return s:sub(1, len) +end + +local function make_name(seed, fallback, class, max_len, used) + local prefix_len = math.min(2, math.max(1, max_len - 1)) + local p = readable_prefix(seed, fallback or class, prefix_len) + for attempt = 0, 64 do + local material = class .. ':' .. tostring(seed or '') .. ':' .. tostring(attempt) + local hash_len = math.max(1, max_len - #p) + local name = p .. hash_hex(material, hash_len) + if #name > max_len then name = name:sub(1, max_len) end + local prior = used[name] + if prior == nil or prior == material then + used[name] = material + return name + end + end + error('OpenWrt name collision for ' .. tostring(class) .. ':' .. tostring(seed), 2) +end + +local function make_uci_section(kind, seed, used) + local base = uci_clean(kind .. '_' .. tostring(seed or 'x')) + if #base > MAX.uci_section then + base = base:sub(1, 22) .. '_' .. hash_hex(kind .. ':' .. tostring(seed), 8) + end + local name = base + local i = 0 + while used[name] and used[name] ~= kind .. ':' .. tostring(seed) do + i = i + 1 + local suffix = '_' .. tostring(i) + name = base:sub(1, MAX.uci_section - #suffix) .. suffix + end + used[name] = kind .. ':' .. tostring(seed) + return name +end + +local function remember(self, class, key, name) + key = tostring(key) + self._cache[class] = self._cache[class] or {} + self._cache[class][key] = self._cache[class][key] or name + self._reverse[class] = self._reverse[class] or {} + self._reverse[class][name] = key + return name +end + +local function memo(self, class, key, max_len, fallback) + self._cache[class] = self._cache[class] or {} + if self._cache[class][key] then return self._cache[class][key] end + self._used[class] = self._used[class] or {} + local name = make_name(key, fallback, class, max_len, self._used[class]) + self._cache[class][key] = name + self._reverse[class] = self._reverse[class] or {} + self._reverse[class][name] = key + return name +end + +local function reserve(self, class, name, reason) + self._used[class] = self._used[class] or {} + if self._used[class][name] and self._used[class][name] ~= 'reserved:' .. tostring(reason) then + error('OpenWrt reserved-name collision for ' .. tostring(class) .. ':' .. tostring(name), 2) + end + self._used[class][name] = 'reserved:' .. tostring(reason) +end + +local function remember_if_available(self, class, key, name) + key = tostring(key) + local material = class .. ':' .. key + self._used[class] = self._used[class] or {} + local prior = self._used[class][name] + if prior ~= nil and prior ~= material then return nil end + self._used[class][name] = material + return remember(self, class, key, name) +end + +local function safe_or_memo(self, class, key, max_len, fallback) + local safe = safe_plain_name(key, max_len) + if safe then + local name = remember_if_available(self, class, key, safe) + if name then return name end + end + return memo(self, class, tostring(key), max_len, fallback) +end + +local function remember_prefixed_if_available(self, class, key, prefix, safe_stem) + if not safe_stem then return nil end + local name = prefix .. safe_stem + return remember_if_available(self, class, key, name) +end + +local function memo_prefixed(self, class, key, max_len, prefix, fallback) + key = tostring(key) + self._cache[class] = self._cache[class] or {} + if self._cache[class][key] then return self._cache[class][key] end + self._used[class] = self._used[class] or {} + local stem_len = max_len - #prefix + if stem_len < 1 then error('prefix too long for OpenWrt name class ' .. tostring(class), 2) end + local stem_prefix_len = math.min(2, stem_len) + local stem_prefix = readable_prefix(key, fallback or prefix, stem_prefix_len) + for attempt = 0, 64 do + local material = class .. ':' .. key .. ':' .. tostring(attempt) + local hash_len = math.max(1, stem_len - #stem_prefix) + local name = prefix .. stem_prefix .. hash_hex(material, hash_len) + if #name > max_len then name = name:sub(1, max_len) end + local prior = self._used[class][name] + if prior == nil or prior == material then + self._used[class][name] = material + self._cache[class][key] = name + self._reverse[class] = self._reverse[class] or {} + self._reverse[class][name] = key + return name + end + end + error('OpenWrt name collision for ' .. tostring(class) .. ':' .. tostring(key), 2) +end + +local function memo_mwan(self, role, id) + -- All MWAN3 UCI section names share one package namespace. Prefer the + -- semantic name when it is valid and unused, so conventional names such as + -- "balanced" and "default_rule_v4" remain readable. If another MWAN3 + -- section already owns that name, generate a bounded role-prefixed name. + local prefix = ({ iface = 'mi', member = 'mm', policy = 'mp', rule = 'mr' })[role] + local raw_id = tostring(id or (role == 'policy' and 'balanced' or '')) + local key = role .. ':' .. raw_id + self._cache.mwan = self._cache.mwan or {} + local name = self._cache.mwan[key] + if not name then + local safe = safe_plain_name(raw_id, MAX.mwan_name) + if safe then + name = remember_if_available(self, 'mwan', key, safe) + end + if not name then + name = memo_prefixed(self, 'mwan', key, MAX.mwan_name, prefix, role) + end + end + -- Preserve role-specific diagnostic maps in snapshots while the allocator + -- itself enforces one shared MWAN3 package namespace. + local cache_class = 'mwan_' .. role + self._cache[cache_class] = self._cache[cache_class] or {} + self._cache[cache_class][raw_id] = name + self._reverse[cache_class] = self._reverse[cache_class] or {} + self._reverse[cache_class][name] = raw_id + return name +end + +function Ctx:iface(id) + return safe_or_memo(self, 'logical_interface', tostring(id), MAX.logical_interface, 'if') +end + +function Ctx:bridge(id) + local safe = safe_plain_name(id, MAX.bridge_device - 3) + local name = remember_prefixed_if_available(self, 'bridge_device', id, 'br-', safe) + if name then return name end + return memo_prefixed(self, 'bridge_device', tostring(id), MAX.bridge_device, 'br', 'br') +end + +function Ctx:vlan(id) + local safe = safe_plain_name(id, MAX.linux_device - 3) + local name = remember_prefixed_if_available(self, 'linux_device', id, 'vl-', safe) + if name then return name end + return memo_prefixed(self, 'linux_device', tostring(id), MAX.linux_device, 'vl', 'vl') +end + +function Ctx:zone(id) + return safe_or_memo(self, 'firewall_zone', tostring(id), MAX.firewall_zone, 'zn') +end + +function Ctx:mwan_iface(id) + return memo_mwan(self, 'iface', id) +end + +function Ctx:mwan_member(id) + return memo_mwan(self, 'member', id) +end + +function Ctx:mwan_policy(id) + return memo_mwan(self, 'policy', id or 'balanced') +end + +function Ctx:mwan_rule(id) + return memo_mwan(self, 'rule', id) +end + +function Ctx:dns_instance(id) + return safe_or_memo(self, 'dnsmasq_instance', tostring(id), MAX.dnsmasq_instance, 'dn') +end + +function Ctx:section(kind, id) + self._used.uci_section = self._used.uci_section or {} + return make_uci_section(kind, tostring(id), self._used.uci_section) +end + +function Ctx:semantic_for(class, generated) + return self._reverse[class] and self._reverse[class][generated] or nil +end + +function Ctx:snapshot() + local out = { max = {}, names = {} } + for k, v in pairs(MAX) do out.max[k] = v end + for class, map in pairs(self._cache) do + out.names[class] = {} + for semantic, generated in pairs(map) do out.names[class][semantic] = generated end + end + return out +end + +function Ctx:limits() + local out = {} + for k, v in pairs(MAX) do out[k] = v end + return out +end + +function M.allocate(intent, _provider_config) + local ctx = setmetatable({ _cache = {}, _used = {}, _reverse = {} }, Ctx) + -- Reserve baseline UCI names produced by the provider. Product ids that + -- would otherwise map directly to these names must be generated instead. + reserve(ctx, 'logical_interface', 'loopback', 'network.loopback') + reserve(ctx, 'logical_interface', 'globals', 'network.globals') + reserve(ctx, 'firewall_zone', 'defaults', 'firewall.defaults') + reserve(ctx, 'mwan', 'globals', 'mwan3.globals') + -- Pre-allocate common names to catch deterministic collisions before apply. + for _, seg_id in ipairs(sorted_keys((intent or {}).segments)) do + ctx:iface(seg_id); ctx:bridge(seg_id); ctx:vlan(seg_id) + local seg = intent.segments[seg_id] + local fw = is_plain_table(seg and seg.firewall) and seg.firewall or {} + ctx:zone(fw.zone or seg_id) + end + for _, if_id in ipairs(sorted_keys((intent or {}).interfaces)) do + ctx:iface(if_id); ctx:bridge(if_id); ctx:vlan(if_id) + end + local fw = is_plain_table((intent or {}).firewall) and intent.firewall or {} + for _, z in ipairs(sorted_keys(fw.zones or {})) do ctx:zone(z) end + local wan = is_plain_table((intent or {}).wan) and intent.wan or {} + local members = is_plain_table(wan.members) and wan.members or {} + for _, mid in ipairs(sorted_keys(members)) do + ctx:mwan_iface((members[mid] and (members[mid].interface or members[mid].iface)) or mid) + ctx:mwan_member(mid) + end + return ctx, nil +end + +M.MAX = MAX +return M diff --git a/src/services/hal/backends/openwrt/uci_manager.lua b/src/services/hal/backends/openwrt/uci_manager.lua index ad610e69..0b66de12 100644 --- a/src/services/hal/backends/openwrt/uci_manager.lua +++ b/src/services/hal/backends/openwrt/uci_manager.lua @@ -74,6 +74,20 @@ local function array_copy(t) return out end +local function ensure_pkg_file(confdir, pkg) + if type(confdir) ~= 'string' or confdir == '' or type(pkg) ~= 'string' or pkg == '' then return true, nil end + local ok = os.execute("mkdir -p '" .. confdir:gsub("'", "'\\''") .. "'") + if ok ~= true and ok ~= 0 then return nil, 'failed to create UCI confdir ' .. confdir end + local path = confdir .. '/' .. pkg + local f = io.open(path, 'rb') + if f then f:close(); return true, nil end + local nf, err = io.open(path, 'wb') + if not nf then return nil, tostring(err) end + nf:write('') + nf:close() + return true, nil +end + local function is_uci_identifier(s) return type(s) == 'string' and s ~= '' and s:match('^[A-Za-z0-9_]+$') ~= nil end @@ -256,6 +270,7 @@ local function normalise_record(record) config = record.config, changes = copy_changes(record.changes), reply_tx = record.reply_tx, + replace_package = record.replace_package == true, } for i, ch in ipairs(out.changes) do local ok, err = validate_change(out, ch, i) @@ -465,6 +480,11 @@ local function apply_with_cursor(cursor, record) if not cursor then return true, nil -- explicit fake/no-uci mode for tests and non-OpenWrt hosts. end + if type(cursor.load) == 'function' then pcall(function () cursor:load(record.config) end) end + if record.replace_package == true then + local ok, err = delete_all_sections(cursor, record.config) + if ok ~= true then return nil, err end + end local aliases = {} for _, change in ipairs(record.changes) do local op = change.op @@ -582,6 +602,8 @@ function M.new(opts) if cursor == nil and opts.allow_fake == false then return nil, cursor_note or 'uci unavailable' end + local confdir = opts.confdir + if confdir == nil and opts.cursor == nil and cursor ~= nil then confdir = '/etc/config' end return setmetatable({ _tx = tx, _rx = rx, @@ -590,6 +612,8 @@ function M.new(opts) _cursor = cursor, _cursor_note = cursor_note, _fake = cursor == nil, + _confdir = confdir, + _savedir = opts.savedir, _debounce_s = tonumber(opts.debounce_s) or 0.1, _run_cmd = opts.run_cmd or default_run_cmd, _run_cmd_explicit = type(opts.run_cmd) == 'function', @@ -633,8 +657,26 @@ function Manager:_collect_batch(first_item, owner_scope) return batch end +function Manager:_ensure_packages(packages) + if self._fake == true then return true, nil end + for _, pkg in ipairs(packages or {}) do + local ok, err = ensure_pkg_file(self._confdir, pkg) + if ok ~= true then return nil, err end + if self._cursor and type(self._cursor.load) == 'function' then pcall(function () self._cursor:load(pkg) end) end + end + return true, nil +end + function Manager:_apply_batch(batch) local results = {} + local pkgs = record_packages((function () local rs = {}; for _, item in ipairs(batch or {}) do rs[#rs + 1] = item.record end; return rs end)()) + local eok, eerr = self:_ensure_packages(pkgs) + if eok ~= true then + for _, item in ipairs(batch or {}) do + if item.reply_tx then queue.try_admit_now(item.reply_tx, { ok = false, err = tostring(eerr) }) end + end + return + end for _, item in ipairs(batch) do local record = item.record local ok, err @@ -673,6 +715,12 @@ function Manager:_apply_transaction(item) local tx = item.transaction or {} local records = tx.records or {} local packages = tx.packages or record_packages(records) + local eok, eerr = self:_ensure_packages(packages) + if eok ~= true then + if item.reply_tx then queue.try_admit_now(item.reply_tx, { ok = false, status = 'failed_no_change', err = tostring(eerr), packages = packages }) end + return + end + local result = { ok = true, status = 'ok', diff --git a/src/services/net/backpressure.lua b/src/services/net/backpressure.lua index 61db1413..40857335 100644 --- a/src/services/net/backpressure.lua +++ b/src/services/net/backpressure.lua @@ -19,6 +19,11 @@ M.policy = { full = 'drop_oldest', -- latest observed state wins; terminal apply completions never use this queue. }, + gsm_uplinks = { + queue_len = 8, + full = 'reject_newest', + }, + requests = { queue_len = 16, full = 'reject_newest', diff --git a/src/services/net/domain/multiwan.lua b/src/services/net/domain/multiwan.lua index a78564d2..f29b1323 100644 --- a/src/services/net/domain/multiwan.lua +++ b/src/services/net/domain/multiwan.lua @@ -6,10 +6,32 @@ local schema = require 'services.net.schema' local M = {} local ALLOWED = { - 'enabled', 'policy', 'members', 'health', 'runtime', 'failover', + 'enabled', 'policy', 'members', 'uplinks', 'health', 'runtime', 'failover', 'load_balancing', 'metadata', 'extensions', } +local function normalise_member(m) + m = schema.copy(m or {}) + local src = m.source + if type(src) == 'table' and src.kind == 'modem' then + m.source = { + kind = 'gsm-uplink', + id = src.modem_id or src.id or src.role, + compat = schema.copy(src), + } + end + return m +end + +local function normalise_members(t) + local src = t.uplinks or t.members or {} + local out = {} + for id, rec in pairs(src) do + out[id] = normalise_member(rec) + end + return out +end + function M.normalise(v) local t, err = schema.optional_plain_table(v, { 'net', 'wan' }) if not t then return nil, err end @@ -18,7 +40,7 @@ function M.normalise(v) local out = { enabled = t.enabled ~= false, policy = t.policy or 'failover', - members = schema.copy(t.members or {}), + members = normalise_members(t), health = schema.copy(t.health or {}), runtime = schema.copy(t.runtime or {}), failover = schema.copy(t.failover or {}), diff --git a/src/services/net/domain/segments.lua b/src/services/net/domain/segments.lua index 26107a56..9ed063e4 100644 --- a/src/services/net/domain/segments.lua +++ b/src/services/net/domain/segments.lua @@ -7,7 +7,7 @@ local M = {} local ALLOWED = { 'id', 'name', 'description', 'kind', 'enabled', 'protected', 'user_editable', 'purpose', 'vlan', 'addressing', - 'dhcp', 'dns', 'firewall', 'routing', 'shaping', 'vpn', 'policy', + 'dhcp', 'dns', 'firewall', 'routing', 'shaping', 'vpn', 'policy', 'l2', 'tags', 'metadata', 'extensions', } @@ -62,6 +62,7 @@ function M.normalise_record(id, rec, path) purpose = t.purpose, vlan = vlan, addressing = schema.copy(t.addressing or {}), + l2 = schema.copy(t.l2 or {}), dhcp = schema.copy(t.dhcp or {}), dns = schema.copy(t.dns or {}), firewall = schema.copy(t.firewall or {}), diff --git a/src/services/net/events.lua b/src/services/net/events.lua index 83ee34a9..d7b74fb5 100644 --- a/src/services/net/events.lua +++ b/src/services/net/events.lua @@ -78,6 +78,11 @@ local function try_observed_now(state) return M.map_observed_event(ev) end +local function try_gsm_uplink_now(state) + if not state.gsm_uplink_watch then return nil end + return state.gsm_uplink_watch:try_recv_now() +end + local function add_capability_sources(state, sources) if state.cap_deps and type(state.cap_deps.event_source) == 'function' then sources[#sources + 1] = state.cap_deps:event_source({ name = 'capability_dependencies' }) @@ -112,6 +117,12 @@ function M.next_service_event_op(state) try_now = function () return try_config_now(state) end, recv_op = function () return state.config_watch:recv_op():wrap(M.map_config_event) end, }, + { + name = 'gsm_uplinks', + enabled = function () return state.gsm_uplink_watch ~= nil end, + try_now = function () return try_gsm_uplink_now(state) end, + recv_op = function () return state.gsm_uplink_watch:recv_op() end, + }, { name = 'observed', enabled = function () return state.observed_sub ~= nil end, diff --git a/src/services/net/gsm_uplink_watch.lua b/src/services/net/gsm_uplink_watch.lua new file mode 100644 index 00000000..325242be --- /dev/null +++ b/src/services/net/gsm_uplink_watch.lua @@ -0,0 +1,99 @@ +-- services/net/gsm_uplink_watch.lua +-- Retained GSM uplink-state adapter for the NET coordinator. + +local bus_cleanup = require 'devicecode.support.bus_cleanup' +local queue = require 'devicecode.support.queue' +local tablex = require 'shared.table' +local topics = require 'services.net.topics' + +local M = {} +local Watch = {} +Watch.__index = Watch + +local NOT_READY = {} + +local function copy(v) return tablex.deep_copy(v) end + +local function role_from_topic(topic) + if type(topic) ~= 'table' then return nil end + if topic[1] == 'state' and topic[2] == 'gsm' and topic[3] == 'uplink' then + local role = topic[4] + if type(role) == 'string' and role ~= '' then return role end + end + return nil +end + +local function map_event(ev) + if ev == nil then return { kind = 'gsm_uplink_watch_closed' } end + if type(ev) == 'table' and ev.op == 'replay_done' then return { kind = 'gsm_uplink_replay_done', origin = ev.origin } end + + local role = type(ev) == 'table' and role_from_topic(ev.topic) or nil + if not role then return { kind = 'gsm_uplink_unknown', event = ev } end + + if ev.op == 'unretain' then + return { + kind = 'gsm_uplink_changed', + role = role, + op = ev.op, + topic = ev.topic, + origin = ev.origin, + payload = { + schema = 'devicecode.gsm.uplink/1', + id = role, + role = role, + state = 'unavailable', + connected = false, + available = false, + reason = 'unretained', + }, + } + end + + local payload = copy(ev.payload or {}) + payload.schema = payload.schema or 'devicecode.gsm.uplink/1' + payload.id = payload.id or role + payload.role = payload.role or role + return { + kind = 'gsm_uplink_changed', + role = role, + op = ev.op, + topic = ev.topic, + origin = ev.origin, + payload = payload, + } +end + +function Watch:recv_op() + return self._watch:recv_op():wrap(map_event) +end + +function Watch:try_recv_now() + local ev = queue.try_now(self._watch:recv_op(), NOT_READY) + if ev == NOT_READY then return nil end + return map_event(ev) +end + +function Watch:close() + if self._closed then return true, nil end + self._closed = true + return bus_cleanup.unwatch_retained(self._conn, self._watch) +end + +function Watch:terminate(_reason) + return self:close() +end + +function M.open(conn, opts) + opts = opts or {} + local watch, err = bus_cleanup.watch_retained(conn, topics.gsm_uplink_pattern(), { + replay = true, + queue_len = opts.queue_len or 8, + full = opts.full or 'reject_newest', + }) + if not watch then return nil, err end + return setmetatable({ _conn = conn, _watch = watch, _closed = false }, Watch), nil +end + +M._test = { map_event = map_event, role_from_topic = role_from_topic } + +return M diff --git a/src/services/net/model.lua b/src/services/net/model.lua index 9124a39f..00c94571 100644 --- a/src/services/net/model.lua +++ b/src/services/net/model.lua @@ -69,6 +69,7 @@ function M.initial(service_id) routing = {}, wan = {}, wan_runtime = { uplinks = {}, speedtests = {}, live_weights = {}, last_weight_apply = nil }, + sources = { gsm_uplinks = {} }, shaping = {}, vpn = {}, diagnostics = {}, @@ -82,6 +83,7 @@ function M.initial(service_id) speedtests_started = 0, speedtests_completed = 0, live_weight_applies = 0, + gsm_uplink_updates = 0, }, } end diff --git a/src/services/net/projection.lua b/src/services/net/projection.lua index 44bebbd3..5a4aeac2 100644 --- a/src/services/net/projection.lua +++ b/src/services/net/projection.lua @@ -21,6 +21,7 @@ function M.segments_topic() return topics.segments() end function M.vlan_policy_topic() return topics.vlan_policy() end function M.interface_topic(id) return topics.interface(id) end function M.domain_topic(name) return topics.domain(name) end +function M.sources_topic() return topics.sources() end function M.summary(snapshot) snapshot = snapshot or {} @@ -38,6 +39,7 @@ function M.summary(snapshot) segments = count_map(snapshot.segments), interfaces = count_map(snapshot.interfaces), wan_members = count_map(snapshot.wan and snapshot.wan.members), + gsm_uplinks = count_map(snapshot.sources and snapshot.sources.gsm_uplinks), vpn_tunnels = count_map(snapshot.vpn and snapshot.vpn.tunnels), shaping_profiles = count_map(snapshot.shaping and snapshot.shaping.profiles), }, diff --git a/src/services/net/publisher.lua b/src/services/net/publisher.lua index e8fc5591..3c08eca3 100644 --- a/src/services/net/publisher.lua +++ b/src/services/net/publisher.lua @@ -8,7 +8,7 @@ local M = {} local DOMAIN_TOPICS = { 'addressing', 'dns', 'dhcp', 'firewall', 'routing', - 'wan', 'wan_runtime', 'shaping', 'vpn', 'diagnostics', 'observed', 'drift', + 'wan', 'wan_runtime', 'sources', 'shaping', 'vpn', 'diagnostics', 'observed', 'drift', } local function domain_set() diff --git a/src/services/net/service.lua b/src/services/net/service.lua index b5568d03..fbe32e37 100644 --- a/src/services/net/service.lua +++ b/src/services/net/service.lua @@ -24,6 +24,7 @@ local observer_manager = require 'services.net.observer_manager' local wan_manager = require 'services.net.wan_manager' local drift = require 'services.net.drift' local backpressure = require 'services.net.backpressure' +local gsm_uplink_watch = require 'services.net.gsm_uplink_watch' local perform = fibers.perform @@ -157,6 +158,7 @@ local function copy_intent_to_model(s, intent) s.routing = intent.routing or {} s.wan = intent.wan or {} s.wan_runtime = { generation = generation, uplinks = {}, speedtests = {}, live_weights = { state = 'idle', generation = generation } } + s.sources = s.sources or { gsm_uplinks = {} } s.shaping = intent.shaping or {} s.vpn = intent.vpn or {} s.diagnostics = intent.diagnostics or {} @@ -412,7 +414,7 @@ local function handle_apply_done(state, ev) local ok_pub, pub_err = publish_snapshot(state) if ok_pub ~= true then return nil, pub_err end if apply_ok then - local ok, err = wan_manager.start_speedtests(state) + local ok, err = wan_manager.reconcile_speedtests(state, 'apply_done') mark_domain_dirty(state, 'wan_runtime') publish_snapshot(state) return ok, err @@ -448,7 +450,46 @@ local function handle_observed_state(state, ev) mark_domain_dirty(state, 'observed') mark_domain_dirty(state, 'drift') obs_event(state.svc, 'network_observed', { subject = observed_event.subject, source = observed_event.source, kind = observed_event.kind }) - return publish_snapshot(state) + local ok, err = wan_manager.reconcile_speedtests(state, 'observed_state') + mark_domain_dirty(state, 'wan_runtime') + local pub_ok, pub_err = publish_snapshot(state) + if pub_ok ~= true then return nil, pub_err end + return ok, err +end + +local function handle_gsm_uplink_changed(state, ev) + if ev.kind == 'gsm_uplink_replay_done' then return true, nil end + if ev.kind == 'gsm_uplink_unknown' then + obs_log(state.svc, 'debug', { what = 'gsm_uplink_unknown', event = ev.event }) + return true, nil + end + local role = ev.role + if type(role) ~= 'string' or role == '' then return true, nil end + local payload = model_mod.deep_copy(ev.payload or {}) + payload.schema = payload.schema or 'devicecode.gsm.uplink/1' + payload.id = payload.id or role + payload.role = payload.role or role + payload.updated_at = now() + state.model:update(function (s) + s.sources = s.sources or { gsm_uplinks = {} } + s.sources.gsm_uplinks = s.sources.gsm_uplinks or {} + s.sources.gsm_uplinks[role] = payload + s.stats.gsm_uplink_updates = (s.stats.gsm_uplink_updates or 0) + 1 + return project_dependencies(state, s) + end) + mark_domain_dirty(state, 'sources') + mark_summary_dirty(state) + obs_event(state.svc, 'gsm_uplink_changed', { + role = role, + state = payload.state, + connected = payload.connected == true, + ifname = payload.linux and payload.linux.ifname or payload.interface, + }) + local ok, err = wan_manager.reconcile_speedtests(state, 'gsm_uplink_changed') + mark_domain_dirty(state, 'wan_runtime') + local pub_ok, pub_err = publish_snapshot(state) + if pub_ok ~= true then return nil, pub_err end + return ok, err end local function ensure_observer_started(state, reason) @@ -557,6 +598,11 @@ local function handle_event(state, ev) return handle_apply_done(state, ev) elseif ev.kind == 'observed_state' then return handle_observed_state(state, ev) + elseif ev.kind == 'gsm_uplink_changed' or ev.kind == 'gsm_uplink_replay_done' or ev.kind == 'gsm_uplink_unknown' then + return handle_gsm_uplink_changed(state, ev) + elseif ev.kind == 'gsm_uplink_watch_closed' then + state.gsm_uplink_watch = nil + return true, nil elseif ev.kind == 'net_speedtest_done' then return handle_speedtest_done(state, ev) elseif ev.kind == 'net_live_weights_done' then @@ -601,6 +647,7 @@ function M.run(scope, params) local published = publisher.new_state() local dirty = publisher.mark_all(publisher.new_dirty_state()) local cfg_watch + local gsm_watch if conn then local werr @@ -611,6 +658,15 @@ function M.run(scope, params) closed_kind = 'config_closed', }) if not cfg_watch then error(werr or 'net config watch failed', 2) end + + if params.gsm_uplink_watch ~= false then + local gerr + gsm_watch, gerr = gsm_uplink_watch.open(conn, { + queue_len = params.gsm_uplink_queue_len or ((backpressure.policy.gsm_uplinks and backpressure.policy.gsm_uplinks.queue_len) or 8), + full = params.gsm_uplink_full or ((backpressure.policy.gsm_uplinks and backpressure.policy.gsm_uplinks.full) or 'reject_newest'), + }) + if not gsm_watch then error(gerr or 'net gsm uplink watch failed', 2) end + end end local cap_deps = assert(cap_deps_mod.open(conn, { @@ -634,6 +690,7 @@ function M.run(scope, params) published = published, dirty = dirty, config_watch = cfg_watch, + gsm_uplink_watch = gsm_watch, observed_sub = nil, observer = nil, observe = params.observe ~= false, @@ -670,6 +727,7 @@ function M.run(scope, params) cancel_active_generation(state, reason) stop_observer(state, reason) if cfg_watch then cfg_watch:close(); cfg_watch = nil end + if gsm_watch then gsm_watch:close(); gsm_watch = nil end cap_deps:terminate(reason) done_tx:close(reason) publisher.cleanup_now(conn, published) diff --git a/src/services/net/topics.lua b/src/services/net/topics.lua index 1e54c593..e78d71fa 100644 --- a/src/services/net/topics.lua +++ b/src/services/net/topics.lua @@ -18,6 +18,9 @@ function M.vlan_policy() return { 'state', 'net', 'vlan-policy' } end function M.segment(id) return { 'state', 'net', 'segment', token(id) } end function M.interface(id) return { 'state', 'net', 'interface', token(id) } end function M.domain(name) return { 'state', 'net', token(name) } end +function M.sources() return { 'state', 'net', 'sources' } end +function M.gsm_uplink_pattern() return { 'state', 'gsm', 'uplink', '+' } end +function M.gsm_uplink(role) return { 'state', 'gsm', 'uplink', token(role) } end function M.event(name) return { 'event', 'net', token(name) } end function M.rpc(method) return { 'net', 'rpc', token(method) } end diff --git a/src/services/net/wan_manager.lua b/src/services/net/wan_manager.lua index 83e60b7e..006cf5b3 100644 --- a/src/services/net/wan_manager.lua +++ b/src/services/net/wan_manager.lua @@ -97,12 +97,13 @@ local function start_speedtest_for_uplink(state, uplink) return true, nil end -function M.start_speedtests(state) +function M.reconcile_speedtests(state, reason) local snap = state.model:snapshot() if not wan_policy.speedtest_enabled(snap) then return true, nil end local generation = state.current_generation and state.current_generation.generation or snap.generation - mark_runtime_replaced(state, generation) - local uplinks = wan_policy.collect_uplinks(state.model:snapshot()) + if type(generation) ~= 'number' or generation <= 0 then return true, nil end + + local uplinks = wan_policy.collect_uplinks(snap) if #uplinks == 0 then state.model:update(function(s) s.wan_runtime = s.wan_runtime or { uplinks = {}, speedtests = {}, live_weights = {} } @@ -111,13 +112,37 @@ function M.start_speedtests(state) end) return true, nil end + + state.model:update(function(s) + s.wan_runtime = s.wan_runtime or { uplinks = {}, speedtests = {}, live_weights = {} } + s.wan_runtime.generation = s.wan_runtime.generation or generation + s.wan_runtime.last_reconcile_reason = reason + s.wan_runtime.last_reconcile_at = now(state) + return s + end) + for i = 1, #uplinks do - local ok, err = start_speedtest_for_uplink(state, uplinks[i]) - if ok ~= true then return nil, err end + local uplink = uplinks[i] + local online = wan_policy.uplink_online(snap, uplink) + if not online then + local active = state.active_speedtests[uplink.uplink_id] + if active and active.handle and type(active.handle.cancel) == 'function' then + active.handle:cancel('uplink_offline') + end + state.active_speedtests[uplink.uplink_id] = nil + else + local due = wan_policy.speedtest_due(state.model:snapshot(), uplink, { generation = generation, now = now(state) }) + if due then + local ok, err = start_speedtest_for_uplink(state, uplink) + if ok ~= true then return nil, err end + end + end end return true, nil end +M.start_speedtests = M.reconcile_speedtests + local function start_live_weight_apply(state, members) if not members or #members == 0 or not state.hal or type(state.hal.apply_live_weights_op) ~= 'function' then return true, nil end local snap = state.model:snapshot() @@ -184,16 +209,22 @@ function M.handle_speedtest_done(state, ev) rec.interface = result.interface or (work_result.request and work_result.request.interface) or rec.interface rec.device = result.device or (work_result.request and work_result.request.device) or rec.device rec.metric = rec.metric or (work_result.request and work_result.request.metric) or 1 - rec.peak_mbps = result.peak_mbps + if result.ok == true and result.peak_mbps ~= nil then + rec.peak_mbps = result.peak_mbps + rec.last_success_mbps = result.peak_mbps + rec.last_success_at = now(state) + elseif rec.last_success_mbps ~= nil then + rec.peak_mbps = rec.last_success_mbps + end rec.data_mib = result.data_mib rec.duration_s = result.duration_s rec.completed_at = now(state) + if rec.ok ~= true then rec.retry_after = now(state) + 60 end s.wan_runtime.speedtests[ev.uplink_id] = rec s.stats.speedtests_completed = (s.stats.speedtests_completed or 0) + 1 return s end) local snap = state.model:snapshot() - if not wan_policy.all_speedtests_done(snap, ev.generation) then return true, nil end local weights, werr = wan_policy.compute_weights(snap, ev.generation) if not weights then state.model:update(function(s) @@ -203,6 +234,8 @@ function M.handle_speedtest_done(state, ev) end) return true, nil end + local previous = snap.wan_runtime and snap.wan_runtime.last_weight_apply and snap.wan_runtime.last_weight_apply.members + if wan_policy.weights_equal(previous, weights) then return true, nil end return start_live_weight_apply(state, weights) end @@ -224,6 +257,9 @@ function M.handle_live_weights_done(state, ev) result = model_mod.deep_copy(result), updated_at = now(state), } + if result.ok == true then + s.wan_runtime.last_weight_apply = { generation = ev.generation, id = ev.weight_apply_id, members = model_mod.deep_copy(work_result.members or (work_result.request and work_result.request.members) or {}), updated_at = now(state) } + end s.stats.live_weight_applies = (s.stats.live_weight_applies or 0) + 1 return s end) diff --git a/src/services/net/wan_policy.lua b/src/services/net/wan_policy.lua index 4d6386a9..435a7ba8 100644 --- a/src/services/net/wan_policy.lua +++ b/src/services/net/wan_policy.lua @@ -1,5 +1,5 @@ -- services/net/wan_policy.lua --- Pure WAN runtime policy for speedtests and live weights. +-- Pure WAN runtime policy for event-led speedtests and live weights. local tablex = require 'shared.table' @@ -20,6 +20,18 @@ local function flag_enabled(v) return false end +local function runtime_opts(snapshot) + local wan = snapshot and snapshot.wan or {} + local lb = type(wan.load_balancing) == 'table' and wan.load_balancing or {} + local rt = type(wan.runtime) == 'table' and wan.runtime or {} + local sp = type(lb.speedtests) == 'table' and lb.speedtests + or type(lb.speedtest) == 'table' and lb.speedtest + or type(rt.speedtests) == 'table' and rt.speedtests + or type(rt.speedtest) == 'table' and rt.speedtest + or {} + return sp +end + function M.speedtest_enabled(snapshot) local wan = snapshot and snapshot.wan or {} local lb = type(wan.load_balancing) == 'table' and wan.load_balancing or {} @@ -47,18 +59,35 @@ local function member_interface(member, uplink_id) or uplink_id end +local function gsm_source(member) + local src = member and member.source or nil + if type(src) ~= 'table' then return nil end + if src.kind == 'gsm-uplink' then return src.id or src.role end + if src.kind == 'modem' then return src.modem_id or src.id or src.role end + return nil +end + +function M.gsm_uplink_state(snapshot, member) + local id = gsm_source(member) + if not id then return nil end + return snapshot and snapshot.sources and snapshot.sources.gsm_uplinks and snapshot.sources.gsm_uplinks[id] or nil +end + function M.build_speedtest_request(snapshot, uplink_id, member) member = member or {} local iface_id = member_interface(member, uplink_id) local iface = snapshot.interfaces and snapshot.interfaces[iface_id] or nil local endpoint = type(iface) == 'table' and type(iface.endpoint) == 'table' and iface.endpoint or {} + local gsm = M.gsm_uplink_state(snapshot, member) + local gsm_linux = type(gsm) == 'table' and type(gsm.linux) == 'table' and gsm.linux or {} + local metric = tonumber(member.metric or member.priority) or 1 return { interface = iface_id, - device = member.device or member.linux_interface or member.ifname or endpoint.ifname or endpoint.device or endpoint.name or (iface and iface.device), + device = member.device or member.linux_interface or member.ifname or endpoint.ifname or endpoint.device or endpoint.name or (iface and iface.device) or gsm_linux.ifname, url = member.speedtest_url, max_duration_s = member.speedtest_duration_s, uplink_id = tostring(uplink_id), - metric = member.metric or member.priority or 1, + metric = metric, } end @@ -79,46 +108,113 @@ function M.collect_uplinks(snapshot) return out end -function M.all_speedtests_done(snapshot, generation) - local uplinks = M.collect_uplinks(snapshot) - if #uplinks == 0 then return false end - local tests = snapshot.wan_runtime and snapshot.wan_runtime.speedtests or {} - for i = 1, #uplinks do - local id = uplinks[i].uplink_id - local rec = tests[id] - if not rec or rec.generation ~= generation or rec.state == 'running' then return false end +local function status_by_interface(snapshot, iface) + local mw = snapshot and snapshot.observed and snapshot.observed.snapshot and snapshot.observed.snapshot.multiwan or nil + if type(mw) ~= 'table' then mw = snapshot and snapshot.observed and snapshot.observed.multiwan or nil end + if type(mw) ~= 'table' then return nil end + local by_sem = mw.interfaces_by_semantic + if type(by_sem) == 'table' and by_sem[iface] then return by_sem[iface] end + local ifaces = mw.interfaces + if type(ifaces) == 'table' then return ifaces[iface] end + return nil +end + +local function status_online(st) + if type(st) ~= 'table' then return false end + if st.usable == true or st.online == true or st.up == true then return true end + local state = tostring(st.state or st.mwan3_status or ''):lower() + return state == 'online' or state == 'up' or state == 'connected' +end + +function M.uplink_observed_status(snapshot, uplink) + return status_by_interface(snapshot, uplink and uplink.request and uplink.request.interface) +end + +function M.uplink_online(snapshot, uplink) + return status_online(M.uplink_observed_status(snapshot, uplink)) +end + +local function now_from_opts(opts) + return opts and opts.now or nil +end + +function M.speedtest_due(snapshot, uplink, opts) + opts = opts or {} + if not M.speedtest_enabled(snapshot) then return false, 'speedtests_disabled' end + if not M.uplink_online(snapshot, uplink) then return false, 'not_online' end + local generation = opts.generation or snapshot.generation + local id = uplink.uplink_id + local runtime = snapshot.wan_runtime or {} + local rec = runtime.speedtests and runtime.speedtests[id] + if rec and rec.state == 'running' and rec.generation == generation then return false, 'running' end + local now = now_from_opts(opts) + if rec and rec.retry_after and now and now < rec.retry_after then return false, 'retry_later' end + local cfg = runtime_opts(snapshot) + local ttl = tonumber(cfg.interval_s or cfg.refresh_s or cfg.ttl_s or cfg.max_age_s) + if rec and rec.state == 'done' and rec.ok == true then + if not ttl or ttl <= 0 then return false, 'fresh' end + local completed = tonumber(rec.completed_at) or 0 + if now and completed > 0 and now - completed < ttl then return false, 'fresh' end end + return true, 'due' +end + +local function member_fingerprint(m) + if type(m) ~= 'table' then return '' end + return table.concat({ tostring(m.id), tostring(m.interface), tostring(m.metric), tostring(m.weight) }, ':') +end + +function M.weights_equal(a, b) + a, b = a or {}, b or {} + if #a ~= #b then return false end + local aa, bb = {}, {} + for i = 1, #a do aa[i] = member_fingerprint(a[i]) end + for i = 1, #b do bb[i] = member_fingerprint(b[i]) end + table.sort(aa); table.sort(bb) + for i = 1, #aa do if aa[i] ~= bb[i] then return false end end return true end function M.compute_weights(snapshot, generation) local runtime = snapshot.wan_runtime or {} local tests = runtime.speedtests or {} - local members, total = {}, 0 - local current = {} - for _, uplink in ipairs(M.collect_uplinks(snapshot)) do current[uplink.uplink_id] = true end - - for uplink_id, rec in pairs(tests) do - if current[uplink_id] and rec.generation == generation and rec.state == 'done' and rec.ok == true then - local mbps = tonumber(rec.peak_mbps) or 0 - if mbps > 0 then - members[#members + 1] = { uplink_id = uplink_id, interface = rec.interface, metric = rec.metric or 1, mbps = mbps } - total = total + mbps - end + local measured, total = {}, 0 + local uplinks = M.collect_uplinks(snapshot) + local cfg = runtime_opts(snapshot) + local probe_weight = math.max(1, math.floor(tonumber(cfg.probe_weight) or 1)) + local weight_scale = math.max(1, math.floor(tonumber(cfg.weight_scale) or 100)) + + for _, uplink in ipairs(uplinks) do + local id = uplink.uplink_id + local rec = tests[id] + local mbps = nil + if rec and rec.generation == generation and rec.state == 'done' then + if rec.ok == true then mbps = tonumber(rec.peak_mbps) or tonumber(rec.last_success_mbps) + else mbps = tonumber(rec.last_success_mbps) end end + if mbps and mbps > 0 then total = total + mbps end + measured[id] = mbps end - if total <= 0 or #members == 0 then return nil, 'no_successful_speedtests' end - table.sort(members, function(a, b) return tostring(a.uplink_id) < tostring(b.uplink_id) end) + if total <= 0 then return nil, 'no_successful_speedtests' end + local out = {} - for i = 1, #members do - local m = members[i] + for _, uplink in ipairs(uplinks) do + local id = uplink.uplink_id + local mbps = measured[id] + local metric = math.max(1, math.floor(tonumber(uplink.request.metric) or 1)) + local weight, probe = probe_weight, true + if mbps and mbps > 0 then + weight = math.max(1, math.floor((mbps / total) * weight_scale + 0.5)) + probe = false + end out[#out + 1] = { - id = m.uplink_id, - link_id = m.uplink_id, - interface = m.interface, - metric = math.max(1, math.floor(tonumber(m.metric or 1) or 1)), - weight = math.max(1, math.floor((m.mbps / total) * 100 + 0.5)), - measured_mbps = m.mbps, + id = id, + link_id = id, + interface = uplink.request.interface, + metric = metric, + weight = weight, + measured_mbps = mbps, + probe = probe, } end return out, nil diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_apply.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_apply.sh index a92724e1..1ebb16b4 100755 --- a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_apply.sh +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_apply.sh @@ -29,6 +29,7 @@ local bus = require 'bus' local trie = require 'trie' local uci = require 'uci' local provider_loader = require 'services.hal.backends.network.provider' +local names_mod = require 'services.hal.backends.network.providers.openwrt.names' assert(type(bus.new) == 'function', 'vendored bus did not load') assert(type(trie.new_pubsub) == 'function', 'vendored trie did not load') @@ -131,6 +132,8 @@ local intent = { diagnostics = {}, } +local name_ctx = assert(names_mod.allocate(intent)) + fibers.run(function(_scope) local provider, perr = provider_loader.new({ provider = 'openwrt', @@ -171,53 +174,114 @@ for _, pkg in ipairs({ 'network', 'dhcp', 'firewall' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end end -eq(c:get('network', 'dev_lan'), 'device', 'network.dev_lan type') -eq(c:get('network', 'dev_lan', 'name'), 'br-lan', 'bridge name') -eq(c:get('network', 'dev_lan', 'type'), 'bridge', 'bridge device type') -assert_list(c:get('network', 'dev_lan', 'ports'), { 'eth0', 'eth1' }, 'bridge ports') - -eq(c:get('network', 'lan'), 'interface', 'network.lan type') -eq(c:get('network', 'lan', 'proto'), 'static', 'lan proto') -eq(c:get('network', 'lan', 'device'), 'br-lan', 'lan device') -eq(c:get('network', 'lan', 'ipaddr'), '192.168.10.1', 'lan ipaddr') -eq(c:get('network', 'lan', 'netmask'), '255.255.255.0', 'lan netmask') - -eq(c:get('network', 'wan'), 'interface', 'network.wan type') -eq(c:get('network', 'wan', 'proto'), 'dhcp', 'wan proto') -eq(c:get('network', 'wan', 'device'), 'eth2', 'wan device') -eq(c:get('network', 'wan', 'peerdns'), '0', 'wan peerdns') -eq(c:get('network', 'wan', 'metric'), '10', 'wan metric') - -eq(c:get('network', 'route_1'), 'route', 'route type') -eq(c:get('network', 'route_1', 'interface'), 'lan', 'route interface') -eq(c:get('network', 'route_1', 'target'), '10.0.0.0/8', 'route target') -eq(c:get('network', 'route_1', 'gateway'), '192.168.10.254', 'route gateway') - -eq(c:get('dhcp', 'dns_lan'), 'dnsmasq', 'dns_lan type') -assert_list(c:get('dhcp', 'dns_lan', 'server'), { '1.1.1.1', '8.8.8.8' }, 'dns upstreams') -eq(c:get('dhcp', 'dns_lan', 'cachesize'), '1000', 'dns cache size') -local addnhosts = c:get('dhcp', 'dns_lan', 'addnhosts') +local function all(pkg) + if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end + return c:get_all(pkg) or {} +end + +local function list_contains(v, item) + if type(v) == 'table' then + for i = 1, #v do if v[i] == item then return true end end + return false + end + return v == item +end + +local function section_by_name(pkg, section, stype) + local sec = all(pkg)[section] + if type(sec) ~= 'table' then fail('section not found in ' .. pkg .. ': ' .. tostring(section)) end + if stype ~= nil and sec['.type'] ~= stype then fail(pkg .. '.' .. tostring(section) .. ' expected type ' .. tostring(stype) .. ', got ' .. tostring(sec['.type'])) end + return section, sec +end + +local function assert_no_devicecode_metadata() + for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do + for name, sec in pairs(all(pkg)) do + if type(sec) == 'table' then + for _, opt in ipairs({ 'devicecode_managed', 'devicecode_owner', 'devicecode_semantic_id', 'devicecode_role' }) do + if sec[opt] ~= nil then fail('unexpected UCI metadata ' .. pkg .. '.' .. tostring(name) .. '.' .. opt) end + end + end + end + end +end + +local function find_firewall_zone(zone_name) + for name, sec in pairs(all('firewall')) do + if type(sec) == 'table' and sec['.type'] == 'zone' and sec.name == zone_name then + return name, sec + end + end + fail('firewall zone not found: ' .. tostring(zone_name)) +end + +local function find_firewall_forwarding(src, dest) + for name, sec in pairs(all('firewall')) do + if type(sec) == 'table' and sec['.type'] == 'forwarding' and sec.src == src and sec.dest == dest then + return name, sec + end + end + fail('firewall forwarding not found: ' .. tostring(src) .. ' -> ' .. tostring(dest)) +end + +local lan_bridge_sec, lan_bridge = section_by_name('network', name_ctx:section('dev_bridge', 'lan'), 'device') +eq(lan_bridge.name, 'br-lan', 'bridge name') +eq(lan_bridge.type, 'bridge', 'bridge device type') +assert_list(lan_bridge.ports, { 'eth0', 'eth1' }, 'bridge ports') + +local _lan_if_sec, lan_if = section_by_name('network', name_ctx:iface('lan'), 'interface') +eq(_lan_if_sec, 'lan', 'lan generated interface remains readable') +eq(lan_if.proto, 'static', 'lan proto') +eq(lan_if.device, 'br-lan', 'lan device') +eq(lan_if.ipaddr, '192.168.10.1', 'lan ipaddr') +eq(lan_if.netmask, '255.255.255.0', 'lan netmask') + +local _wan_if_sec, wan_if = section_by_name('network', name_ctx:iface('wan'), 'interface') +eq(_wan_if_sec, 'wan', 'wan generated interface remains readable') +eq(wan_if.proto, 'dhcp', 'wan proto') +eq(wan_if.device, 'eth2', 'wan device') +eq(wan_if.peerdns, '0', 'wan peerdns') +eq(wan_if.defaultroute, '0', 'wan defaultroute disabled before admission') +eq(wan_if.metric, '10', 'wan metric') + +local _route_sec, route = section_by_name('network', name_ctx:section('route', '1'), 'route') +eq(route.interface, 'lan', 'route interface') +eq(route.target, '10.0.0.0/8', 'route target') +eq(route.gateway, '192.168.10.254', 'route gateway') + +local dns_sec, dns = nil, nil +for name, sec in pairs(all('dhcp')) do + if type(sec) == 'table' and sec['.type'] == 'dnsmasq' and list_contains(sec.addnhosts, '/tmp/devicecode-dns-hosts/ads.hosts') then dns_sec, dns = name, sec; break end +end +if not dns then fail('ads dnsmasq instance not found') end +assert_list(dns.server, { '1.1.1.1', '8.8.8.8' }, 'dns upstreams') +eq(dns.cachesize, '1000', 'dns cache size') +local addnhosts = dns.addnhosts if type(addnhosts) == 'table' then eq(addnhosts[1], '/tmp/devicecode-dns-hosts/ads.hosts', 'dns host file') else eq(addnhosts, '/tmp/devicecode-dns-hosts/ads.hosts', 'dns host file') end -local addresses = c:get('dhcp', 'dns_lan', 'address') +local addresses = dns.address local address_s = type(addresses) == 'table' and table.concat(addresses, ' ') or tostring(addresses) if not address_s:find('/config.bigbox.home/192.168.10.1', 1, true) then fail('dns record missing: ' .. address_s) end -eq(c:get('dhcp', 'lan'), 'dhcp', 'dhcp.lan type') -eq(c:get('dhcp', 'lan', 'interface'), 'lan', 'dhcp interface') -eq(c:get('dhcp', 'lan', 'start'), '20', 'dhcp start') -eq(c:get('dhcp', 'lan', 'limit'), '50', 'dhcp limit') -eq(c:get('dhcp', 'lan', 'leasetime'), '6h', 'dhcp leasetime') - -eq(c:get('firewall', 'defaults'), 'defaults', 'firewall defaults type') -eq(c:get('firewall', 'defaults', 'input'), 'REJECT', 'firewall defaults input') -eq(c:get('firewall', 'zone_lan'), 'zone', 'lan zone type') -eq(c:get('firewall', 'zone_lan', 'name'), 'lan', 'lan zone name') -assert_list(c:get('firewall', 'zone_lan', 'network'), { 'lan' }, 'lan zone networks') -eq(c:get('firewall', 'zone_wan', 'masq'), '1', 'wan zone masq') -eq(c:get('firewall', 'zone_wan', 'mtu_fix'), '1', 'wan zone mtu_fix') -eq(c:get('firewall', 'fwd_lan_to_wan_1'), 'forwarding', 'forwarding type') -eq(c:get('firewall', 'fwd_lan_to_wan_1', 'src'), 'lan', 'forwarding src') -eq(c:get('firewall', 'fwd_lan_to_wan_1', 'dest'), 'wan', 'forwarding dest') +local _dhcp_sec, dhcp_lan = section_by_name('dhcp', name_ctx:section('dhcp', 'lan'), 'dhcp') +eq(dhcp_lan.interface, 'lan', 'dhcp interface') +eq(dhcp_lan.instance, dns_sec, 'dhcp instance') +eq(dhcp_lan.start, '20', 'dhcp start') +eq(dhcp_lan.limit, '50', 'dhcp limit') +eq(dhcp_lan.leasetime, '6h', 'dhcp leasetime') + +local _fw_defaults, fw_defaults = section_by_name('firewall', 'defaults', 'defaults') +eq(fw_defaults.input, 'REJECT', 'firewall defaults input') +local _zone_lan_sec, zone_lan = find_firewall_zone('lan') +eq(zone_lan.name, 'lan', 'lan zone name') +assert_list(zone_lan.network, { 'lan' }, 'lan zone networks') +local _zone_wan_sec, zone_wan = find_firewall_zone('wan') +eq(zone_wan.masq, '1', 'wan zone masq') +eq(zone_wan.mtu_fix, '1', 'wan zone mtu_fix') +local _fwd_sec, fwd = find_firewall_forwarding('lan', 'wan') +eq(fwd.src, 'lan', 'forwarding src') +eq(fwd.dest, 'wan', 'forwarding dest') + +assert_no_devicecode_metadata() print('openwrt network provider minimal apply: ok') LUA diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_complete_rewrite.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_complete_rewrite.sh new file mode 100755 index 00000000..ba870a0d --- /dev/null +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_complete_rewrite.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VM_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(CDPATH= cd -- "$VM_DIR/../../.." && pwd)" +SSH="$VM_DIR/scripts/ssh" +SCP_TO="$VM_DIR/scripts/scp-to" +REMOTE="/tmp/devicecode-network-provider-complete-rewrite-test" +WORK="$VM_DIR/work/network-provider-complete-rewrite-test" + +mkdir -p "$WORK" +cat > "$WORK/run_openwrt_network_provider_complete_rewrite.lua" <<'LUA' +package.path = table.concat({ + './src/?.lua', './src/?/init.lua', + './vendor/lua-fibers/src/?.lua', './vendor/lua-fibers/src/?/init.lua', + './vendor/lua-bus/src/?.lua', './vendor/lua-bus/src/?/init.lua', + './vendor/lua-trie/src/?.lua', './vendor/lua-trie/src/?/init.lua', + package.path, +}, ';') + +local fibers = require 'fibers' +local uci = require 'uci' +local provider_loader = require 'services.hal.backends.network.provider' +local perform = fibers.perform + +local function fail(msg) error(msg, 2) end +local function eq(a, b, msg) if a ~= b then fail((msg or 'values differ') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a)) end end +local function mkdir_p(path) local ok = os.execute("mkdir -p '" .. path .. "'"); if ok ~= true and ok ~= 0 then fail('mkdir failed for ' .. path) end end + +local tmp = '/tmp/dc-network-provider-complete-rewrite' +os.execute("rm -rf '" .. tmp .. "'") +local conf, save = tmp .. '/conf', tmp .. '/save' +mkdir_p(conf); mkdir_p(save) + +-- Deliberately create only two stale files. The provider/manager must create +-- missing packages and must completely replace stale package contents. +local f = assert(io.open(conf .. '/network', 'w')) +f:write([[config interface 'lan' + option proto 'dhcp' + option stale_option 'must_disappear' + +config interface 'oldwan' + option proto 'dhcp' +]]) +f:close() +f = assert(io.open(conf .. '/dhcp', 'w')) +f:write([[config dhcp 'lan' + option interface 'lan' + option instance 'old_dnsmasq' + option stale_option 'must_disappear' + +config dnsmasq 'old_dnsmasq' + option domainneeded '1' +]]) +f:close() + +local intent = { + schema = 'devicecode.net.intent/1', rev = 1, + segments = { + lan = { kind = 'lan', vlan = { id = 10 }, addressing = { ipv4 = { mode = 'static', cidr = '192.168.10.1/24' } }, dhcp = { enabled = true }, dns = { local_server = true }, firewall = { zone = 'lan' } }, + }, + interfaces = {}, + dns = { enabled = true, domain = 'bigbox.home', upstreams = { '1.1.1.1' } }, + dhcp = {}, firewall = { zones = { lan = {} } }, routing = {}, wan = {}, shaping = {}, vpn = {}, diagnostics = {}, +} + +fibers.run(function() + local provider = assert(provider_loader.new({ provider = 'openwrt', confdir = conf, savedir = save, debounce_s = 0.01, platform = { segment_trunk = { ifname = 'eth0' } }, run_cmd = function() return true, nil end }, {})) + local result = perform(provider:apply_op({ intent = intent })) + assert(result and result.ok == true, 'apply failed: ' .. tostring(result and result.err)) + provider:terminate('test complete') +end) + +for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do + local fh = io.open(conf .. '/' .. pkg, 'rb') + if not fh then fail('missing generated package file ' .. pkg) end + fh:close() +end + +local c = assert(uci.cursor(conf, save)) +for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end end + +eq(c:get('network', 'oldwan'), nil, 'stale network section removed') +eq(c:get('network', 'lan', 'stale_option'), nil, 'stale network option removed from recreated section') +eq(c:get('dhcp', 'old_dnsmasq'), nil, 'stale dnsmasq removed') +local dhcp_sec = nil +for name, sec in pairs(c:get_all('dhcp') or {}) do if type(sec) == 'table' and sec['.type'] == 'dhcp' and sec.interface == 'lan' then dhcp_sec = sec end end +assert(dhcp_sec, 'lan dhcp section expected') +eq(dhcp_sec.stale_option, nil, 'stale dhcp option removed') +if dhcp_sec.instance == 'old_dnsmasq' then fail('dhcp instance should not point to stale dnsmasq') end + +eq(c:get('network', 'loopback'), 'interface', 'loopback generated') +eq(c:get('network', 'loopback', 'device'), 'lo', 'loopback device') + +for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do + for name, sec in pairs(c:get_all(pkg) or {}) do + if type(sec) == 'table' then + for _, opt in ipairs({ 'devicecode_managed', 'devicecode_owner', 'devicecode_semantic_id', 'devicecode_role' }) do + if sec[opt] ~= nil then fail('unexpected UCI metadata ' .. pkg .. '.' .. tostring(name) .. '.' .. opt) end + end + end + end +end +print('openwrt complete rewrite and missing package creation: ok') +LUA + +"$SSH" "rm -rf '$REMOTE'; mkdir -p '$REMOTE'" +"$SCP_TO" "$ROOT_DIR/src" "$REMOTE/src" +"$SCP_TO" "$ROOT_DIR/vendor" "$REMOTE/vendor" +"$SCP_TO" "$WORK/run_openwrt_network_provider_complete_rewrite.lua" "$REMOTE/run_openwrt_network_provider_complete_rewrite.lua" +"$SSH" "cd '$REMOTE' && lua ./run_openwrt_network_provider_complete_rewrite.lua" diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_dnsmasq_groups.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_dnsmasq_groups.sh new file mode 100755 index 00000000..267f357c --- /dev/null +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_dnsmasq_groups.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VM_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(CDPATH= cd -- "$VM_DIR/../../.." && pwd)" +SSH="$VM_DIR/scripts/ssh" +SCP_TO="$VM_DIR/scripts/scp-to" +REMOTE="/tmp/devicecode-network-provider-dnsmasq-groups-test" +WORK="$VM_DIR/work/network-provider-dnsmasq-groups-test" + +mkdir -p "$WORK" +cat > "$WORK/run_openwrt_network_provider_dnsmasq_groups.lua" <<'LUA' +package.path = table.concat({ './src/?.lua', './src/?/init.lua', './vendor/lua-fibers/src/?.lua', './vendor/lua-fibers/src/?/init.lua', './vendor/lua-bus/src/?.lua', './vendor/lua-bus/src/?/init.lua', './vendor/lua-trie/src/?.lua', './vendor/lua-trie/src/?/init.lua', package.path }, ';') +local fibers = require 'fibers' +local uci = require 'uci' +local provider_loader = require 'services.hal.backends.network.provider' +local names_mod = require 'services.hal.backends.network.providers.openwrt.names' +local perform = fibers.perform +local function fail(msg) error(msg, 2) end +local function eq(a,b,msg) if a ~= b then fail((msg or 'values differ') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a)) end end +local function mkdir_p(path) local ok=os.execute("mkdir -p '"..path.."'"); if ok ~= true and ok ~= 0 then fail('mkdir failed') end end +local function contains(list, value) if type(list)=='table' then for i=1,#list do if list[i]==value then return true end end; return false end return list==value end + +local tmp='/tmp/dc-network-provider-dnsmasq-groups'; os.execute("rm -rf '"..tmp.."'") +local conf, save=tmp..'/conf', tmp..'/save'; mkdir_p(conf); mkdir_p(save) +for _,pkg in ipairs({'network','dhcp','firewall','mwan3'}) do local f=assert(io.open(conf..'/'..pkg,'w')); f:close() end + +local function seg(kind, vid, host_files) + return { kind=kind or 'lan', vlan={id=vid}, addressing={ipv4={mode='static', cidr='192.168.'..tostring(vid)..'.1/24'}}, dhcp={enabled=true}, dns={local_server=true, host_files=host_files or {}, domain='bigbox.home'}, firewall={zone='lan'} } +end +local intent={ schema='devicecode.net.intent/1', rev=1, segments={ adm=seg('system',8,{'ads'}), ops=seg('system',9,{'ads'}), jan=seg('user',32,{'ads','adult'}), int=seg('system',100,{}) }, interfaces={}, dns={enabled=true, domain='bigbox.home', upstreams={'1.1.1.1'}, cache={size=1000}, host_files={base_dir='/data/devicecode/dns/hosts', addnmount=true, sources={ads={file='ads.hosts'}, adult={file='adult.hosts'}}}}, dhcp={}, firewall={zones={lan={}}}, routing={}, wan={}, shaping={}, vpn={}, diagnostics={} } +local name_ctx = assert(names_mod.allocate(intent)) + +fibers.run(function() + local provider=assert(provider_loader.new({provider='openwrt', confdir=conf, savedir=save, debounce_s=0.01, platform={segment_trunk={ifname='eth0'}}, run_cmd=function() return true,nil end}, {})) + local r=perform(provider:apply_op({intent=intent})); assert(r and r.ok==true, 'apply failed: '..tostring(r and r.err)); provider:terminate('done') +end) +local c=assert(uci.cursor(conf,save)); for _,pkg in ipairs({'dhcp'}) do if type(c.load)=='function' then pcall(function() c:load(pkg) end) end end +local dns_count=0; local ads_instance; local adult_instance; local standard_instance +for name,sec in pairs(c:get_all('dhcp') or {}) do + if type(sec)=='table' and sec['.type']=='dnsmasq' then + dns_count=dns_count+1 + assert(#name <= 15, 'dnsmasq instance name too long: '..name) + if contains(sec.addnhosts, '/data/devicecode/dns/hosts/ads.hosts') and not contains(sec.addnhosts, '/data/devicecode/dns/hosts/adult.hosts') then ads_instance=name end + if contains(sec.addnhosts, '/data/devicecode/dns/hosts/adult.hosts') then adult_instance=name end + if sec.addnhosts == nil then standard_instance=name end + end +end +eq(dns_count, 3, 'identical DNS policy should be grouped') +assert(ads_instance, 'ads instance expected'); assert(adult_instance, 'ads+adult instance expected'); assert(standard_instance, 'standard instance expected') +local function dhcp_for(seg) + local wanted = name_ctx:iface(seg) + for _,sec in pairs(c:get_all('dhcp') or {}) do if type(sec)=='table' and sec['.type']=='dhcp' and sec.interface==wanted then return sec end end + fail('dhcp section not found for '..seg) +end +eq(dhcp_for('adm').instance, ads_instance, 'adm shares ads instance') +eq(dhcp_for('ops').instance, ads_instance, 'ops shares ads instance') +eq(dhcp_for('jan').instance, adult_instance, 'jan uses adult instance') +eq(dhcp_for('int').instance, standard_instance, 'int uses standard instance') +print('openwrt dnsmasq grouping: ok') +LUA + +"$SSH" "rm -rf '$REMOTE'; mkdir -p '$REMOTE'" +"$SCP_TO" "$ROOT_DIR/src" "$REMOTE/src" +"$SCP_TO" "$ROOT_DIR/vendor" "$REMOTE/vendor" +"$SCP_TO" "$WORK/run_openwrt_network_provider_dnsmasq_groups.lua" "$REMOTE/run_openwrt_network_provider_dnsmasq_groups.lua" +"$SSH" "cd '$REMOTE' && lua ./run_openwrt_network_provider_dnsmasq_groups.lua" diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_fw4_schema.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_fw4_schema.sh index 90a945ec..bb05db06 100755 --- a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_fw4_schema.sh +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_fw4_schema.sh @@ -236,6 +236,8 @@ table.sort(sections) for _, section in ipairs(sections) do absent(c:get('firewall', section, 'devicecode_managed'), 'firewall.' .. section .. '.devicecode_managed') absent(c:get('firewall', section, 'devicecode_owner'), 'firewall.' .. section .. '.devicecode_owner') + absent(c:get('firewall', section, 'devicecode_semantic_id'), 'firewall.' .. section .. '.devicecode_semantic_id') + absent(c:get('firewall', section, 'devicecode_role'), 'firewall.' .. section .. '.devicecode_role') end -- The higher-level product config no longer models disable_ipv6 as a firewall diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_segment_trunk.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_segment_trunk.sh index 4922164b..dd082c22 100755 --- a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_segment_trunk.sh +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_segment_trunk.sh @@ -22,6 +22,7 @@ package.path = table.concat({ local fibers = require 'fibers' local uci = require 'uci' local provider_loader = require 'services.hal.backends.network.provider' +local names_mod = require 'services.hal.backends.network.providers.openwrt.names' local perform = fibers.perform local function fail(msg) error(msg, 2) end @@ -117,6 +118,8 @@ local intent = { routing = {}, wan = {}, shaping = {}, vpn = {}, diagnostics = {}, } +local name_ctx = assert(names_mod.allocate(intent)) + fibers.run(function() local valid = perform(provider:validate_op({ intent = intent })) assert(valid and valid.ok == true, 'validate failed: ' .. tostring(valid and valid.err)) @@ -131,6 +134,28 @@ end) local c = assert(uci.cursor(conf, save)) for _, pkg in ipairs({ 'network', 'dhcp', 'firewall' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end end +local function all(pkg) + if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end + return c:get_all(pkg) or {} +end +local function section_by_name(pkg, section, stype) + local sec = all(pkg)[section] + if type(sec) ~= 'table' then fail('section not found: ' .. pkg .. '.' .. tostring(section)) end + if stype ~= nil and sec['.type'] ~= stype then fail(pkg .. '.' .. tostring(section) .. ' expected type ' .. tostring(stype) .. ', got ' .. tostring(sec['.type'])) end + return section, sec +end + +local function find_firewall_zone(zone_name) + for name, sec in pairs(all('firewall')) do + if type(sec) == 'table' and sec['.type'] == 'zone' and sec.name == zone_name then return name, sec end + end + fail('firewall zone not found: ' .. tostring(zone_name)) +end +local function contains(list, value) + if type(list) == 'table' then for i = 1, #list do if list[i] == value then return true end end end + return list == value +end + local expected = { mgmt = { vid = '10', cidr_ip = '192.168.8.1', netmask = '255.255.255.0' }, switch_control = { vid = '11', cidr_ip = '192.168.11.1', netmask = '255.255.255.0' }, @@ -139,27 +164,39 @@ local expected = { guest = { vid = '101', cidr_ip = '192.168.101.1', netmask = '255.255.255.0' }, } +local iface_by_seg = {} for seg, e in pairs(expected) do - local devsec = 'dev_seg_' .. seg - eq(c:get('network', devsec), 'device', devsec .. ' type') - eq(c:get('network', devsec, 'type'), '8021q', devsec .. ' device type') - eq(c:get('network', devsec, 'ifname'), 'eth0', devsec .. ' ifname') - eq(c:get('network', devsec, 'vid'), e.vid, devsec .. ' vid') - eq(c:get('network', devsec, 'name'), 'eth0.' .. e.vid, devsec .. ' name') - eq(c:get('network', seg), 'interface', seg .. ' interface type') - eq(c:get('network', seg, 'device'), 'eth0.' .. e.vid, seg .. ' device') - eq(c:get('network', seg, 'ipaddr'), e.cidr_ip, seg .. ' ipaddr') - eq(c:get('network', seg, 'netmask'), e.netmask, seg .. ' netmask') + local _vlan_sec, vlan = section_by_name('network', name_ctx:section('dev_vlan', seg), 'device') + eq(vlan.type, '8021q', seg .. ' vlan device type') + eq(vlan.ifname, 'eth0', seg .. ' vlan ifname') + eq(vlan.vid, e.vid, seg .. ' vid') + assert(#vlan.name <= 14, seg .. ' vlan name length') + local _br_sec, br = section_by_name('network', name_ctx:section('dev_bridge', seg), 'device') + eq(br.type, 'bridge', seg .. ' bridge device type') + assert_list_contains(br.ports, vlan.name, seg .. ' bridge ports') + assert(#br.name <= 14, seg .. ' bridge name length') + local ifsec, iface = section_by_name('network', name_ctx:iface(seg), 'interface') + iface_by_seg[seg] = ifsec + eq(iface.device, br.name, seg .. ' interface bridge device') + eq(iface.ipaddr, e.cidr_ip, seg .. ' ipaddr') + eq(iface.netmask, e.netmask, seg .. ' netmask') + assert(#ifsec <= 8, seg .. ' logical interface length') end -eq(c:get('dhcp', 'lan'), 'dhcp', 'lan dhcp type') -eq(c:get('dhcp', 'guest'), 'dhcp', 'guest dhcp type') -eq(c:get('dhcp', 'mgmt', 'ignore'), '1', 'mgmt dhcp ignored') +local _dhcp_lan_sec, dhcp_lan = section_by_name('dhcp', name_ctx:section('dhcp', 'lan'), 'dhcp') +local _dhcp_guest_sec, dhcp_guest = section_by_name('dhcp', name_ctx:section('dhcp', 'guest'), 'dhcp') +local _dhcp_mgmt_sec, dhcp_mgmt = section_by_name('dhcp', name_ctx:section('dhcp', 'mgmt'), 'dhcp') +eq(dhcp_lan.interface, iface_by_seg.lan, 'lan dhcp interface') +eq(dhcp_guest.interface, iface_by_seg.guest, 'guest dhcp interface') +eq(dhcp_mgmt.ignore, '1', 'mgmt dhcp ignored') -assert_list_contains(c:get('firewall', 'zone_lan', 'network'), 'lan', 'lan zone network') -assert_list_contains(c:get('firewall', 'zone_guest', 'network'), 'guest', 'guest zone network') -assert_list_contains(c:get('firewall', 'zone_system', 'network'), 'switch_control', 'system zone network') -assert_list_contains(c:get('firewall', 'zone_system', 'network'), 'fabric', 'system zone network') +local _zone_lan_sec, zone_lan = find_firewall_zone('lan') +local _zone_guest_sec, zone_guest = find_firewall_zone('guest') +local _zone_system_sec, zone_system = find_firewall_zone('system') +assert_list_contains(zone_lan.network, iface_by_seg.lan, 'lan zone network') +assert_list_contains(zone_guest.network, iface_by_seg.guest, 'guest zone network') +assert_list_contains(zone_system.network, iface_by_seg.switch_control, 'system zone network') +assert_list_contains(zone_system.network, iface_by_seg.fabric, 'system zone network') print('openwrt network provider segment trunk: ok') LUA diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_snapshot.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_snapshot.sh index 37dcc94b..414104d7 100755 --- a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_snapshot.sh +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_snapshot.sh @@ -141,10 +141,11 @@ fibers.run(function(_scope) local result = perform(provider:apply_op({ intent = intent })) assert(result and result.ok == true, 'apply failed: ' .. tostring(result and result.err)) - local snapshot = perform(provider:snapshot_op({})) + local snapshot = perform(provider:snapshot_op({ live = false })) assert(snapshot and snapshot.ok == true, 'snapshot failed: ' .. tostring(snapshot and snapshot.err)) eq(snapshot.backend, 'openwrt', 'snapshot backend') assert(type(snapshot.packages) == 'table', 'snapshot should retain raw packages for diagnostics') + assert(snapshot.observed.live == nil, 'non-live snapshot should not query ubus') local observed = snapshot.observed assert(type(observed) == 'table', 'snapshot.observed should be a table') diff --git a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_vlan_mwan_shaping.sh b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_vlan_mwan_shaping.sh index ca4822dd..5a1c0ee7 100755 --- a/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_vlan_mwan_shaping.sh +++ b/tests/integration/openwrt_vm/tests/test_openwrt_network_provider_vlan_mwan_shaping.sh @@ -112,17 +112,26 @@ local joined = table.concat(restarts, '\n') if joined:find('mwan3', 1, true) then fail('mwan3 restart must not be used') end local c = assert(uci.cursor(conf, save)) -for _, pkg in ipairs({ 'network', 'mwan3' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end end +for _, pkg in ipairs({ 'network', 'dhcp', 'firewall', 'mwan3' }) do if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end end if c:get('network', 'dev_lan_10') then eq(c:get('network', 'dev_lan_10'), 'device', 'vlan device type') eq(c:get('network', 'dev_lan_10', 'vid'), '10', 'vlan vid') end eq(c:get('network', 'route_default_lab'), 'route', 'map-shaped route') -eq(c:get('dhcp', 'dns_lan'), 'dnsmasq', 'per-segment dnsmasq') -eq(c:get('dhcp', 'dns_lan', 'cachesize'), '1000', 'dns cache size') -local addnhosts = c:get('dhcp', 'dns_lan', 'addnhosts') +local dns_sec, dns = nil, nil +for name, sec in pairs(c:get_all('dhcp') or {}) do + if type(sec) == 'table' and sec['.type'] == 'dnsmasq' then + local ah = sec.addnhosts + local has_ads = false + if type(ah) == 'table' then for i = 1, #ah do if ah[i] == '/tmp/devicecode-dns-hosts/ads.hosts' then has_ads = true end end else has_ads = (ah == '/tmp/devicecode-dns-hosts/ads.hosts') end + if has_ads then dns_sec, dns = name, sec; break end + end +end +if not dns then fail('per-segment dnsmasq for ads not found') end +eq(dns.cachesize, '1000', 'dns cache size') +local addnhosts = dns.addnhosts if type(addnhosts) == 'table' then eq(addnhosts[1], '/tmp/devicecode-dns-hosts/ads.hosts', 'segment host file') else eq(addnhosts, '/tmp/devicecode-dns-hosts/ads.hosts', 'segment host file') end -local addresses = c:get('dhcp', 'dns_lan', 'address') +local addresses = dns.address local address_s = type(addresses) == 'table' and table.concat(addresses, ' ') or tostring(addresses) if not address_s:find('/config.bigbox.home/192.168.10.1', 1, true) then fail('dns address record not applied: '..address_s) end eq(c:get('dhcp', 'host_unifi'), 'host', 'dhcp reservation') diff --git a/tests/run.lua b/tests/run.lua index 83800197..86a1bae2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -47,6 +47,7 @@ local files = { "unit.hal.openwrt_network_observer_spec", "unit.hal.openwrt_network_provider_advanced_spec", "unit.hal.common_uci_compat_spec", + "unit.hal.openwrt_names_spec", 'unit.fabric.test_model', 'unit.fabric.test_config', 'unit.fabric.test_dependencies', diff --git a/tests/unit/hal/openwrt_names_spec.lua b/tests/unit/hal/openwrt_names_spec.lua new file mode 100644 index 00000000..98c67df4 --- /dev/null +++ b/tests/unit/hal/openwrt_names_spec.lua @@ -0,0 +1,93 @@ +-- tests/unit/hal/openwrt_names_spec.lua + +local names = require 'services.hal.backends.network.providers.openwrt.names' + +local tests = {} + +local function fail(msg) error(msg or 'assertion failed', 2) end +local function ok(v, msg) if not v then fail(msg) end return v end +local function eq(a, b, msg) if a ~= b then fail((msg or 'assertion failed') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a), 2) end end + +local function starts(s, p, msg) + if type(s) ~= 'string' or s:sub(1, #p) ~= p then fail(msg or ('expected ' .. tostring(s) .. ' to start with ' .. tostring(p))) end +end + +function tests.test_generated_names_are_bounded_and_readable() + local ctx = ok(names.allocate({ + segments = { + ['administration-network-with-a-very-long-user-visible-name'] = { kind = 'system', firewall = { zone = 'restricted-admin-firewall-zone' } }, + ['123-invalid-prefix-and-extremely-long'] = { kind = 'lan' }, + }, + interfaces = { + ['cellular-primary-uplink-with-long-name'] = {}, + }, + firewall = { zones = { ['restricted-admin-firewall-zone'] = {} } }, + wan = { members = { ['cellular-primary-uplink-with-long-name'] = { interface = 'cellular-primary-uplink-with-long-name' } } }, + })) + local lim = ctx:limits() + local iface = ctx:iface('administration-network-with-a-very-long-user-visible-name') + local bridge = ctx:bridge('administration-network-with-a-very-long-user-visible-name') + local vlan = ctx:vlan('administration-network-with-a-very-long-user-visible-name') + local zone = ctx:zone('restricted-admin-firewall-zone') + local mwan = ctx:mwan_iface('cellular-primary-uplink-with-long-name') + local dns = ctx:dns_instance('ads-adult-host-policy-with-long-name') + ok(#iface <= lim.logical_interface, 'logical interface length') + ok(#bridge <= lim.bridge_device, 'bridge length') + ok(#vlan <= lim.linux_device, 'vlan length') + ok(#zone <= lim.firewall_zone, 'zone length') + ok(#mwan <= lim.mwan_name, 'mwan length') + ok(#dns <= lim.dnsmasq_instance, 'dnsmasq length') + starts(iface, 'ad', 'semantic prefix retained') + starts(bridge, 'brad', 'bridge semantic prefix retained') + starts(zone, 're', 'zone semantic prefix retained') + starts(ctx:iface('123-invalid-prefix-and-extremely-long'), 'x1', 'numeric leading prefix made safe') +end + +function tests.test_name_snapshot_is_stable() + local intent = { segments = { adm = {}, jan = {} }, interfaces = {}, firewall = { zones = {} }, wan = { members = {} } } + local a = ok(names.allocate(intent)):snapshot() + local b = ok(names.allocate(intent)):snapshot() + eq(a.names.logical_interface.adm, b.names.logical_interface.adm) + eq(a.names.bridge_device.jan, b.names.bridge_device.jan) +end + + +function tests.test_mwan3_section_names_share_one_namespace() + local ctx = ok(names.allocate({ + segments = {}, interfaces = {}, firewall = { zones = {} }, + wan = { + members = { + wan = { interface = 'wan' }, + balanced = { interface = 'balanced' }, + }, + }, + })) + local lim = ctx:limits() + local iface_wan = ctx:mwan_iface('wan') + local member_wan = ctx:mwan_member('wan') + local iface_balanced = ctx:mwan_iface('balanced') + local member_balanced = ctx:mwan_member('balanced') + local policy_balanced = ctx:mwan_policy('balanced') + local rule_balanced = ctx:mwan_rule('balanced') + local seen = {} + for _, n in ipairs({ iface_wan, member_wan, iface_balanced, member_balanced, policy_balanced, rule_balanced }) do + ok(#n <= lim.mwan_name, 'mwan3 name length') + if seen[n] then fail('duplicate mwan3 UCI section name: ' .. tostring(n)) end + seen[n] = true + end +end + +function tests.test_baseline_names_are_reserved() + local ctx = ok(names.allocate({ + segments = { + loopback = { kind = 'lan', firewall = { zone = 'defaults' } }, + globals = { kind = 'lan' }, + }, + interfaces = {}, firewall = { zones = { defaults = {} } }, wan = { members = {} }, + })) + eq(ctx:iface('loopback') == 'loopback', false, 'segment id must not collide with network.loopback') + eq(ctx:iface('globals') == 'globals', false, 'segment id must not collide with network.globals') + eq(ctx:zone('defaults') == 'defaults', false, 'zone name must not collide with firewall defaults') +end + +return tests diff --git a/tests/unit/hal/openwrt_network_provider_advanced_spec.lua b/tests/unit/hal/openwrt_network_provider_advanced_spec.lua index ed6563f4..c473332d 100644 --- a/tests/unit/hal/openwrt_network_provider_advanced_spec.lua +++ b/tests/unit/hal/openwrt_network_provider_advanced_spec.lua @@ -260,4 +260,33 @@ function tests.test_bigbox_clean_config_plans_dns_rules_routes_and_segment_shapi end) end + +function tests.test_mwan3_builder_uses_distinct_section_names_for_same_interface_and_member_ids() + local names = require 'services.hal.backends.network.providers.openwrt.names' + local mwan3 = require 'services.hal.backends.network.providers.openwrt.mwan3' + local intent_doc = { + wan = { + enabled = true, + health = { track_ip = { '1.1.1.1' } }, + members = { + wan = { interface = 'wan', metric = 1, weight = 1 }, + mdm0 = { interface = 'mdm0', metric = 1, weight = 1 }, + mdm1 = { interface = 'mdm1', metric = 1, weight = 1 }, + }, + }, + } + local ctx = ok(names.allocate(intent_doc)) + local changes = ok(mwan3.build_changes(intent_doc, ctx)) + local sections = {} + for _, ch in ipairs(changes) do + if ch.op == 'set' and ch.config == 'mwan3' and ch.value == nil then + if sections[ch.section] then fail('duplicate mwan3 section name generated: ' .. tostring(ch.section)) end + sections[ch.section] = ch.option + end + end + ok(sections[ctx:mwan_iface('wan')], 'wan interface section expected') + ok(sections[ctx:mwan_member('wan')], 'wan member section expected') + eq(ctx:mwan_iface('wan') == ctx:mwan_member('wan'), false, 'interface/member names must differ') +end + return tests diff --git a/tests/unit/hal/openwrt_uci_manager_spec.lua b/tests/unit/hal/openwrt_uci_manager_spec.lua index 4694e947..2bbafe1e 100644 --- a/tests/unit/hal/openwrt_uci_manager_spec.lua +++ b/tests/unit/hal/openwrt_uci_manager_spec.lua @@ -258,4 +258,66 @@ function tests.test_transaction_rolls_back_touched_packages_on_partial_failure() end) end + +function tests.test_replace_package_deletes_existing_sections_before_writing_desired_state() + fibers.run(function (scope) + local calls = {} + local cursor = fake_cursor(calls) + cursor.get_all = function (_, pkg) + calls[#calls + 1] = { op = 'get_all', pkg } + return { + old = { ['.type'] = 'interface', proto = 'dhcp' }, + lan = { ['.type'] = 'interface', proto = 'static', stale = 'yes' }, + } + end + local mgr = ok(uci_manager.new({ cursor = cursor, run_cmd = function () return true end })) + ok(mgr:start(scope)) + local ok_commit, err = fibers.perform(mgr:submit_op({ + config = 'network', + replace_package = true, + changes = { + { op = 'set', config = 'network', section = 'lan', option = 'interface' }, + { op = 'set', config = 'network', section = 'lan', option = 'proto', value = 'static' }, + }, + })) + ok(ok_commit, err) + local seen_delete_old, seen_delete_lan, seen_set_lan = false, false, false + for _, c in ipairs(calls) do + if c.op == 'delete' and c[1] == 'network' and c[2] == 'old' then seen_delete_old = true end + if c.op == 'delete' and c[1] == 'network' and c[2] == 'lan' then seen_delete_lan = true end + if c.op == 'set' and c[1] == 'network' and c[2] == 'lan' and c[3] == 'interface' then seen_set_lan = true end + end + ok(seen_delete_old, 'old section should be removed') + ok(seen_delete_lan, 'surviving section should be recreated to drop stale options') + ok(seen_set_lan, 'desired section should be written after delete') + end) +end + +function tests.test_manager_creates_missing_package_files_before_transaction() + fibers.run(function (scope) + local tmp = os.tmpname() + os.remove(tmp) + local calls = {} + local cursor = fake_cursor(calls) + cursor.get_all = function (_, _pkg) return {} end + local mgr = ok(uci_manager.new({ confdir = tmp, cursor = cursor, run_cmd = function () return true end })) + ok(mgr:start(scope)) + local result = fibers.perform(mgr:transaction_op({ + packages = { 'network', 'dhcp' }, + records = { + { config = 'network', replace_package = true, changes = {} }, + { config = 'dhcp', replace_package = true, changes = {} }, + }, + })) + ok(result and result.ok == true, result and result.err) + local f = io.open(tmp .. '/network', 'rb') + ok(f, 'network file should be created') + f:close() + f = io.open(tmp .. '/dhcp', 'rb') + ok(f, 'dhcp file should be created') + f:close() + os.remove(tmp .. '/network'); os.remove(tmp .. '/dhcp'); os.remove(tmp) + end) +end + return tests diff --git a/tests/unit/net/test_gsm_uplink_watch.lua b/tests/unit/net/test_gsm_uplink_watch.lua new file mode 100644 index 00000000..cfd3953e --- /dev/null +++ b/tests/unit/net/test_gsm_uplink_watch.lua @@ -0,0 +1,35 @@ +-- tests/unit/net/test_gsm_uplink_watch.lua + +local watch = require 'services.net.gsm_uplink_watch' + +local tests = {} +local function ok(v, msg) if not v then error(msg or 'assertion failed', 2) end return v end +local function eq(a, b, msg) if a ~= b then error((msg or 'assertion failed') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a), 2) end end + +function tests.test_maps_canonical_retained_event() + local ev = watch._test.map_event({ op = 'retain', topic = { 'state', 'gsm', 'uplink', 'primary' }, payload = { connected = true, linux = { ifname = 'wwan1' } } }) + eq(ev.kind, 'gsm_uplink_changed') + eq(ev.role, 'primary') + eq(ev.payload.schema, 'devicecode.gsm.uplink/1') + eq(ev.payload.linux.ifname, 'wwan1') +end + +function tests.test_maps_unretain_to_unavailable_state() + local ev = watch._test.map_event({ op = 'unretain', topic = { 'state', 'gsm', 'uplink', 'secondary' } }) + eq(ev.kind, 'gsm_uplink_changed') + eq(ev.role, 'secondary') + eq(ev.payload.state, 'unavailable') + eq(ev.payload.connected, false) +end + +function tests.test_maps_replay_done() + local ev = watch._test.map_event({ op = 'replay_done' }) + eq(ev.kind, 'gsm_uplink_replay_done') +end + +function tests.test_malformed_topic_is_unknown() + local ev = watch._test.map_event({ op = 'retain', topic = { 'state', 'gsm', 'modem', 'primary', 'uplink' }, payload = {} }) + eq(ev.kind, 'gsm_uplink_unknown') +end + +return tests diff --git a/tests/unit/net/test_service_behaviour.lua b/tests/unit/net/test_service_behaviour.lua index a0221c6e..ba3864b6 100644 --- a/tests/unit/net/test_service_behaviour.lua +++ b/tests/unit/net/test_service_behaviour.lua @@ -589,12 +589,14 @@ end function tests.test_wan_members_trigger_speedtests_and_live_weights() fibers.run(function (scope) + local mailbox = require 'fibers.mailbox' local b = busmod.new() local conn = b:connect() local reader = b:connect() local calls = {} local speedtests = {} local weights = {} + local obs_tx, obs_rx = mailbox.new(8, { full = 'drop_oldest' }) local hal = success_hal(calls) hal.speedtest_op = function (_, req) speedtests[#speedtests + 1] = req @@ -605,6 +607,8 @@ function tests.test_wan_members_trigger_speedtests_and_live_weights() weights[#weights + 1] = req return op.always({ ok = true, changed = true, applied = true }) end + hal.open_observed_subscription = function () return obs_rx end + hal.start_observation_op = function () return op.always({ ok = true, backend = 'test', watching = true }) end local c = cfg() c.wan = { @@ -620,8 +624,27 @@ function tests.test_wan_members_trigger_speedtests_and_live_weights() c.segments.wan = { kind = 'wan', firewall = { zone = 'wan' } } retain_network_config_status(conn, 'available') + retain_network_state_status_payload(conn, { + schema = 'devicecode.cap.status/1', + state = 'running', + available = true, + }) local child = start_service(scope, conn, { conn = conn, config = c, rev = 20, hal = hal }) + obs_tx:send({ payload = { + schema = 'devicecode.net.observation/1', + kind = 'snapshot_done', + source = 'test', + subject = 'network', + observed = { + multiwan = { + interfaces_by_semantic = { + wan_a = { interface = 'wan_a', state = 'online', online = true, usable = true }, + wan_b = { interface = 'wan_b', state = 'online', online = true, usable = true }, + }, + }, + }, + } }) local view = reader:retained_view(topics.domain('wan_runtime')) local runtime = probe.wait_versioned_until('net wan runtime weights', diff --git a/tests/unit/net/test_wan_policy_event_led.lua b/tests/unit/net/test_wan_policy_event_led.lua new file mode 100644 index 00000000..930b9d72 --- /dev/null +++ b/tests/unit/net/test_wan_policy_event_led.lua @@ -0,0 +1,48 @@ +-- tests/unit/net/test_wan_policy_event_led.lua + +local policy = require 'services.net.wan_policy' + +local tests = {} +local function ok(v, msg) if not v then error(msg or 'assertion failed', 2) end return v end +local function eq(a, b, msg) if a ~= b then error((msg or 'assertion failed') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a), 2) end end + +local function snapshot() + return { + generation = 1, + wan = { + members = { + wan = { interface = 'wan', metric = 1 }, + mdm0 = { interface = 'mdm0', metric = 1, source = { kind = 'gsm-uplink', id = 'primary' } }, + }, + load_balancing = { speedtests = { enabled = true, probe_weight = 1, weight_scale = 100 } }, + }, + observed = { snapshot = { multiwan = { interfaces_by_semantic = { wan = { online = true }, mdm0 = { online = false } } } } }, + wan_runtime = { speedtests = {} }, + sources = { gsm_uplinks = { primary = { linux = { ifname = 'wwan1' } } } }, + } +end + +function tests.test_only_observed_online_uplink_is_due() + local s = snapshot() + local uplinks = policy.collect_uplinks(s) + local by_id = {} + for _, u in ipairs(uplinks) do by_id[u.uplink_id] = u end + ok(policy.speedtest_due(s, by_id.wan, { generation = 1, now = 10 })) + local due, reason = policy.speedtest_due(s, by_id.mdm0, { generation = 1, now = 10 }) + eq(due, false) + eq(reason, 'not_online') +end + +function tests.test_weights_include_probe_members_after_one_measurement() + local s = snapshot() + s.wan_runtime.speedtests.wan = { state = 'done', generation = 1, ok = true, peak_mbps = 80, interface = 'wan' } + local weights = assert(policy.compute_weights(s, 1)) + eq(#weights, 2) + local by_id = {} + for _, m in ipairs(weights) do by_id[m.id] = m end + eq(by_id.wan.weight, 100) + eq(by_id.mdm0.weight, 1) + eq(by_id.mdm0.probe, true) +end + +return tests