💋 Sync 2026-10-11 00:16:14
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-10-11 00:16:14 +08:00
1 parent c7bafc635d
commit c71da84035
264 files changed
+8169 -3944

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# ab-stats.sh: ststistic collect for mwan3-autobalancer. One CSV-line per probe/.
# cron: * * * * * /root/ab-stats.sh >/dev/null 2>&1
# run: sh ab-stats.sh (one check) sh ab-stats.sh loop (check in INTERVAL s)
# Result in /tmp (RAM), up to MAXLINES lines.
OUT=${OUT:-/tmp/ab-stats.csv}
INTERVAL=${INTERVAL:-60}
MAXLINES=${MAXLINES:-8000} # approximately 5 days шт 60s step
SLACK=${SLACK:-200} # truncate, if lines above MAXLINES + SLACK
CHAIN=${CHAIN:-mwan3_policy_balanced}
PAIRS=${PAIRS:-"modem1:wwan0 modem2:wwan1"} # mwan3 logical_iface:physical_iface
chain_state() { # "mod/width:width", e..g 20/8:12; "-" if vmap not exist
nft list chain inet mwan3 "$CHAIN" 2>/dev/null | awk '
/vmap/ {
m = ""; for (i = 1; i <= NF; i++) if ($i == "mod") m = $(i + 1)
s = $0; sub(/.*\{/, "", s); n = split(s, p, ","); w = ""
for (i = 1; i <= n; i++) {
split(p[i], r, " "); t = r[1]
if (t ~ /^[0-9]+-[0-9]+$/) { split(t, q, "-"); x = q[2] - q[1] + 1 } else x = 1
w = w (w == "" ? "" : ":") x
}
print m "/" w; found = 1
}
END { if (!found) print "-" }'
}
ct_marks() { # connections per id iface (bits 8..13 tags): "2=130;3=118"
awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^mark=/) {
id = int((substr($i, 6) + 0) / 256) % 64; if (id > 0) c[id]++ } }
END { o = ""; for (k = 1; k <= 63; k++) if (c[k]) o = o (o == "" ? "" : ";") k "=" c[k]; print (o == "" ? "-" : o) }' \
/proc/net/nf_conntrack 2>/dev/null
}
daemon_cost() { # "RSS_КБ,tics_CPU" mwan3-autobalancer daemon
pid=
for p in /proc/[0-9]*; do
case "$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null)" in
*mwan3-autobalancer*run*) pid=${p#/proc/}; break ;;
esac
done
[ -n "$pid" ] || { echo ","; return; }
rss=$(awk '/VmRSS/ { print $2 }' /proc/$pid/status 2>/dev/null)
cpu=$(awk '{ print $14 + $15 }' /proc/$pid/stat 2>/dev/null)
echo "$rss,$cpu"
}
header() {
h="epoch,time,chain,conns,rss_kb,cpu_ticks"
for pr in $PAIRS; do i=${pr%%:*}; h="$h,$i.status,$i.latency,$i.loss,$i.rx,$i.tx"; done
echo "$h"
}
row() {
J=$(ubus call mwan3 status '{"section":"interfaces"}' 2>/dev/null)
r="$(date +%s),$(date '+%Y-%m-%dT%H:%M:%S'),$(chain_state),$(ct_marks),$(daemon_cost)"
for pr in $PAIRS; do
i=${pr%%:*}; d=${pr##*:}
st=$(jsonfilter -s "$J" -e "@.interfaces.$i.status" 2>/dev/null)
la=$(jsonfilter -s "$J" -e "@.interfaces.$i.track_ip[0].latency" 2>/dev/null)
lo=$(jsonfilter -s "$J" -e "@.interfaces.$i.track_ip[0].packetloss" 2>/dev/null)
rx=$(cat /sys/class/net/$d/statistics/rx_bytes 2>/dev/null)
tx=$(cat /sys/class/net/$d/statistics/tx_bytes 2>/dev/null)
r="$r,$st,$la,$lo,$rx,$tx"
done
echo "$r" >> "$OUT"
}
trim() {
[ "$(wc -l < "$OUT")" -gt $((MAXLINES + SLACK)) ] || return 0
tail -n "$MAXLINES" "$OUT" > "$OUT.tmp" && cat "$OUT.tmp" > "$OUT"
rm -f "$OUT.tmp"
}
sample() {
[ -s "$OUT" ] || header > "$OUT"
row
trim
}
if [ "$1" = loop ]; then
while :; do sample; sleep "$INTERVAL"; done
else
sample
fi
@@ -0,0 +1,115 @@
'use strict';
// Чистая работа с JSON цепочки mwan3_policy_<name> (вывод `nft -j list chain`).
// Никакого I/O: на вход разобранный JSON, на выход структуры или готовый batch.
// Если форма правила не узнана, vmap_rules() вернёт [] и демон ничего не тронет.
// Обратная к mwan3_id2mask: метка + маска -> номер интерфейса
export function mark_to_id(mark, mask) {
let id = 0, bit_val = 0;
for (let bit = 0; bit < 32; bit++) {
if (((mask >> bit) & 1) == 1) {
if (((mark >> bit) & 1) == 1)
id |= (1 << bit_val);
bit_val++;
}
}
return id;
};
// id интерфейса = номер секции interface в /etc/config/mwan3 (с 1), как в mwan3.sh
export function name_by_id(order, id) {
return order[id - 1];
};
function clone(x) {
return json(sprintf('%J', x));
}
// Все правила `numgen inc ... vmap` цепочки.
// -> [{ rule, vi, mod, mask, entries: [{ lo, hi, target, mark, id }] }]
export function vmap_rules(doc) {
let out = [];
for (let item in (doc?.nftables ?? [])) {
let rule = item.rule;
if (rule == null || type(rule.expr) != 'array')
continue;
let vi = -1, mask = null;
for (let i = 0; i < length(rule.expr); i++) {
let e = rule.expr[i];
if ((e.vmap?.key?.numgen?.mode == 'inc' || e.vmap?.key?.numgen?.mode == 'random') && type(e.vmap?.data?.set) == 'array')
vi = i;
let l = e.match?.left;
if (type(l) == 'object' && type(l['&']) == 'array')
mask = l['&'][1];
}
if (vi < 0 || mask == null)
continue;
let entries = [], ok = true;
for (let pair in rule.expr[vi].vmap.data.set) {
let k = pair[0], v = pair[1];
let rg = (type(k) == 'object') ? k.range : [k, k];
let m = match(v?.jump?.target ?? '', /^mwan3_or_meta_0x([0-9a-fA-F]+)$/);
if (type(rg) != 'array' || m == null) { ok = false; break; }
let mark = hex(m[1]);
push(entries, { lo: rg[0], hi: rg[1], target: v.jump.target, mark, id: mark_to_id(mark, mask) });
}
if (!ok || length(entries) < 2)
continue;
push(out, { rule, vi, mod: rule.expr[vi].vmap.key.numgen.mod, mask, entries });
}
return out;
};
// Текущие ширины диапазонов (вес каждого члена в живой цепочке)
export function widths(vr) {
return map(vr.entries, e => e.hi - e.lo + 1);
};
// Новый batch: flush + add всех правил цепочки по порядку, у vmap-правил подменены
// диапазоны и mod. weights: { <индекс vmap-правила>: [w1, w2, ...] } в порядке entries.
// Возвращает строку JSON для `nft -j -f -` или null при несоответствии.
export function rebuild_batch(doc, chain, weights) {
let vrs = vmap_rules(doc), batch = [], fam, tbl;
for (let item in doc.nftables) {
if (item.chain?.name == chain) { fam = item.chain.family; tbl = item.chain.table; }
}
if (fam == null)
return null;
push(batch, { flush: { chain: { family: fam, table: tbl, name: chain } } });
let ri = 0;
for (let item in doc.nftables) {
if (item.rule == null)
continue;
let r = clone(item.rule);
delete r.handle;
for (let n = 0; n < length(vrs); n++) {
if (sprintf('%J', vrs[n].rule) != sprintf('%J', item.rule))
continue;
let w = weights[n];
if (w == null)
break;
if (length(w) != length(vrs[n].entries))
return null;
let set = [], run = 0;
for (let i = 0; i < length(w); i++) {
if (w[i] < 1)
return null;
push(set, [{ range: [run, run + w[i] - 1] }, { jump: { target: vrs[n].entries[i].target } }]);
run += w[i];
}
r.expr[vrs[n].vi].vmap.data.set = set;
r.expr[vrs[n].vi].vmap.key.numgen.mod = run;
break;
}
push(batch, { add: { rule: r } });
}
return sprintf('%J', { nftables: batch });
};
@@ -0,0 +1,82 @@
'use strict';
// Весь I/O в одном месте: uci, ubus, nft. Логика живёт в plan.uc / chain.uc.
import { cursor } from 'uci';
import { connect } from 'ubus';
import * as fs from 'fs';
import { DEFAULTS } from 'mwan3_autobalancer.model';
const BATCH = '/var/run/mwan3-autobalancer.batch';
function as_list(v) {
return (type(v) == 'array') ? v : (v == null ? [] : [v]);
}
export function load_config() {
let c = cursor(), s = c.get_all('mwan3_autobalancer', 'main') ?? {};
let interval = int(s.interval ?? 30), total = int(s.total ?? DEFAULTS.total);
return {
...DEFAULTS,
enabled: s.enabled == '1',
observe: s.observe != '0',
policy: s.policy ?? 'balanced',
interval: interval < 5 ? 5 : interval,
total: total < 2 ? DEFAULTS.total : total,
hyst: (s.hyst != null) ? (s.hyst * 1.0) : DEFAULTS.hyst
};
};
// порядок секций interface задаёт id интерфейсов (как в mwan3_update_iface_to_table)
function read_mwan3(policy) {
let c = cursor(), order = [], members = {};
c.load('mwan3');
c.foreach('mwan3', 'interface', s => { push(order, s['.name']); });
let pol = c.get_all('mwan3', policy);
for (let m in as_list(pol?.use_member)) {
let s = c.get_all('mwan3', m);
if (s?.interface)
members[s.interface] = int(s.weight ?? 1);
}
return { order, members };
}
function read_status() {
let conn = connect();
let r = conn ? conn.call('mwan3', 'status', { section: 'interfaces' }) : null;
return r?.interfaces;
}
function read_chain(chain) {
let p = fs.popen(`nft -j list chain inet mwan3 ${chain} 2>/dev/null`, 'r');
if (!p)
return null;
let out = p.read('all');
p.close();
return length(out) ? json(out) : null;
}
// -> { order, members, status, doc, chain } или { error }
export function collect(cfg) {
if (!match(cfg.policy, /^[A-Za-z0-9_]+$/))
return { error: `wrong policy name: ${cfg.policy}` };
let m = read_mwan3(cfg.policy);
if (!length(m.members))
return { error: `not members in ${cfg.policy}` };
let status = read_status();
if (status == null)
return { error: 'mwan3 nor respone ubus (status)' };
let chain = 'mwan3_policy_' + cfg.policy;
let doc = read_chain(chain);
if (doc == null)
return { error: `${chain} not exist` };
return { order: m.order, members: m.members, status, doc, chain };
};
export function nft_apply(batch) {
fs.writefile(BATCH, batch);
let rc = system(['nft', '-j', '-f', BATCH], 5000);
fs.unlink(BATCH);
return rc == 0;
};
@@ -0,0 +1,94 @@
'use strict';
// Чистая логика без I/O: качество линка -> веса. Тестируется на хосте.
// Внимание: в ucode int / int делится нацело, поэтому везде умножаем на 1.0.
export const DEFAULTS = {
total: 20, // сумма целых весов в цепочке (шаг 5%)
alpha: 0.3, // EWMA для задержки
drift: 0.01, // как быстро «обычная» задержка ползёт вверх
lat_free: 1.5, // до x1.5 от обычной задержки штрафа нет
loss_alpha: 0.1, // EWMA потерь: одиночный потерянный пинг почти не виден
loss_free: 5, // сглаженные потери до 5% штрафа не дают
loss_div: 50, // каждые 50% сверх порога снимают весь вес (с запасом 0.9)
hyst: 1.0 // применять, когда цель ушла от живого веса на >= 1 шаг
};
export function new_state() {
return { lat: null, base: null, loss: null };
};
// sample: { latency (мс), loss (%) } из mwan3track; st: состояние линка (меняется)
// Возвращает q в (0..1] или null, если данных нет.
export function quality(sample, st, cfg) {
cfg = cfg ?? DEFAULTS;
if (sample == null || sample.latency == null || sample.latency <= 0)
return null;
let lat = sample.latency;
st.lat = (st.lat == null) ? lat : st.lat + (lat - st.lat) * cfg.alpha;
st.base = (st.base == null || st.lat < st.base)
? st.lat
: st.base + (st.lat - st.base) * cfg.drift;
let r = (st.lat * 1.0) / st.base;
let q_lat = (r <= cfg.lat_free) ? 1 : cfg.lat_free / r;
let loss = (sample.loss ?? 0) * 1.0;
st.loss = (st.loss == null) ? loss : st.loss + (loss - st.loss) * cfg.loss_alpha;
let l = (st.loss - cfg.loss_free) / cfg.loss_div;
if (l < 0) l = 0;
let q_loss = 1 - (l > 0.9 ? 0.9 : l);
return q_lat * q_loss;
};
// members: [{ base (вес из uci), q (или null) }] -> вещественные цели в масштабе total
export function targets(members, total) {
let sum = 0, t = [];
for (let m in members)
sum += m.base * (m.q == null ? 1 : m.q);
for (let m in members)
push(t, sum > 0 ? (total * 1.0) * m.base * (m.q == null ? 1 : m.q) / sum : (total * 1.0) / length(members));
return t;
};
// Метод наибольших остатков, минимум 1 на участника
export function integerize(t, total) {
let n = length(t), w = [], r = [], s = 0;
for (let i = 0; i < n; i++) {
let f = int(t[i]);
push(w, f < 1 ? 1 : f);
push(r, t[i] - f);
s += w[i];
}
for (let g = 0; s < total && g < total; g++) {
let b = 0;
for (let i = 1; i < n; i++)
if (r[i] > r[b]) b = i;
w[b]++; r[b] = -1; s++;
}
for (let g = 0; s > total && g < total; g++) {
let b = 0;
for (let i = 1; i < n; i++)
if (w[i] > w[b]) b = i;
if (w[b] <= 1) break;
w[b]--; s--;
}
return w;
};
// cur: ширины диапазонов в живой цепочке (любой масштаб)
export function needs_apply(cur, t, total, hyst) {
hyst = hyst ?? DEFAULTS.hyst;
let s = 0;
for (let x in cur) s += x;
if (s <= 0 || length(cur) != length(t))
return true;
for (let i = 0; i < length(t); i++) {
let d = (cur[i] * 1.0) * total / s - t[i];
if ((d < 0 ? -d : d) >= hyst)
return true;
}
return false;
};
@@ -0,0 +1,93 @@
'use strict';
// Один проход без I/O: статус + живая цепочка + uci -> решение.
// inp = { order, members: { iface: base_weight }, status: { iface: {...} }, doc, chain }
import { DEFAULTS, new_state, quality, targets, integerize, needs_apply } from 'mwan3_autobalancer.model';
import { vmap_rules, widths, name_by_id, rebuild_batch } from 'mwan3_autobalancer.chain';
// Усредняем track_ip, у которых есть живой замер. Нет данных -> null.
export function sample_of(st) {
if (st == null || st.status != 'online')
return null;
let n = 0, lat = 0.0, loss = 0.0;
for (let t in (st.track_ip ?? [])) {
if (t.status != 'up' || !(t.latency > 0))
continue;
n++;
lat += t.latency;
loss += (t.packetloss ?? 0);
}
return n ? { latency: lat / n, loss: loss / n } : null;
};
function rule_members(inp, vr) {
let names = [], list = [];
for (let e in vr.entries) {
let name = name_by_id(inp.order, e.id);
let base = (name != null) ? inp.members[name] : null;
if (base == null)
return { error: `id ${e.id}: iface not exist in this policy` };
push(names, name);
push(list, { base });
}
return { names, list };
}
function fq(q) { return (q == null) ? '-' : sprintf('%.2f', q); }
export function plan(inp, cfg, states) {
cfg = cfg ?? DEFAULTS;
let vrs = vmap_rules(inp.doc), lines = [], parts = [], weights = {}, any = false, q = {};
if (!length(vrs))
return { batch: null, lines: ['rule not exitst in numgen/vmap (one member or unknown form)'], summary: 'no vmap' };
for (let n = 0; n < length(vrs); n++) {
let vr = vrs[n], rm = rule_members(inp, vr);
if (rm.error) {
push(lines, `rule ${n}: ${rm.error}`);
push(parts, `${n}:пропуск`);
continue;
}
for (let i = 0; i < length(rm.names); i++) {
let name = rm.names[i];
if (!exists(q, name)) {
if (!exists(states, name))
states[name] = new_state();
q[name] = quality(sample_of((inp.status ?? {})[name]), states[name], cfg);
}
rm.list[i].q = q[name];
}
let t = targets(rm.list, cfg.total), w = integerize(t, cfg.total), cur = widths(vr);
for (let i = 0; i < length(rm.names); i++)
push(lines, sprintf('правило %d: %s вес %d (цель %.1f из %d) q=%s', n, rm.names[i], cur[i], t[i], cfg.total, fq(rm.list[i].q)));
let apply = (vr.mod != cfg.total) || needs_apply(cur, t, cfg.total, cfg.hyst);
push(parts, `${n}:` + join(',', map(rm.names, (nm, i) => `${nm}=${w[i]}`)) + (apply ? '*' : ''));
if (apply) {
weights[n] = w;
any = true;
}
}
return {
batch: any ? rebuild_batch(inp.doc, inp.chain, weights) : null,
lines,
summary: join(' ', parts)
};
};
// Откат к «стоку»: ширины диапазонов = базовые веса из uci
export function restore_plan(inp) {
let vrs = vmap_rules(inp.doc), weights = {}, any = false;
for (let n = 0; n < length(vrs); n++) {
let rm = rule_members(inp, vrs[n]);
if (rm.error)
continue;
weights[n] = map(rm.list, x => x.base);
any = true;
}
return any ? rebuild_batch(inp.doc, inp.chain, weights) : null;
};