mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 12:53:56 +08:00
This commit is contained in:
1 parent
c7bafc635d
commit
c71da84035
264 files changed
+8169
-3944
No files matched your search
+86
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user