Files
forgetting/internal/ctrl/shaping.go
T
maenekoandClaude Sonnet 5.5 18106115f7
ci / check (push) Successful in 6m6s
Start the Go rewrite: jsjson, awg-ctrl port, differential bench
- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:27:05 +03:00

606 lines
17 KiB
Go

package ctrl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
// старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по времени,
// ни по неактивности (правило проекта: удаление только по явному списку от панели).
type ShapingSettings struct {
Enabled bool `json:"enabled"`
DownMbit int `json:"downMbit"`
UpMbit int `json:"upMbit"`
Auto bool `json:"auto"`
}
type QdiscStats struct {
Packets float64 `json:"packets"`
Bytes float64 `json:"bytes"`
Drops float64 `json:"drops"`
Backlog float64 `json:"backlog"`
}
type ShapingStatus struct {
Settings ShapingSettings `json:"settings"`
Active bool `json:"active"`
Congested bool `json:"congested"`
Heavy []string `json:"heavy"`
Devices int `json:"devices"`
RateDown float64 `json:"rateDown"`
RateUp float64 `json:"rateUp"`
Down *QdiscStats `json:"down,omitempty"`
Up *QdiscStats `json:"up,omitempty"`
}
const (
congestOn = 0.85 // доля ширины, выше которой канал считается перегруженным
congestOff = 0.70 // и ниже которой перегрузка снимается (гистерезис)
activeMbit = 0.1 // меньше — устройство не считается активным
heavyMult = 1.5 // тяжёлое: ≥ 1.5 честных долей…
heavyMinFrac = 0.05 // …и не меньше 5% ширины канала
checkEvery = 3 // страховочная проверка qdisc — раз в N тиков
ifbName = "ifb-awg"
nftTable = "awg_shape"
)
// Окна и пороги; переопределение через env — только для тестов на стенде.
func envNum(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
return f
}
}
return def
}
type tick struct{ dt, bytes float64 }
type peerCounters struct {
IP string
Rx, Tx float64
}
// orderedSet — множество с порядком вставки (JS Set): порядок попадает в nft-скрипт.
type orderedSet struct {
keys []string
m map[string]bool
}
func newOrderedSet() *orderedSet { return &orderedSet{m: map[string]bool{}} }
func (o *orderedSet) has(k string) bool { return o.m[k] }
func (o *orderedSet) add(k string) {
if !o.m[k] {
o.m[k] = true
o.keys = append(o.keys, k)
}
}
func (o *orderedSet) del(k string) {
if !o.m[k] {
return
}
delete(o.m, k)
for i, x := range o.keys {
if x == k {
o.keys = append(o.keys[:i], o.keys[i+1:]...)
break
}
}
}
func (o *orderedSet) clear() { o.keys, o.m = nil, map[string]bool{} }
func (o *orderedSet) list() []string { return append([]string(nil), o.keys...) }
func (o *orderedSet) size() int { return len(o.keys) }
type Shaping struct {
s *Server
opMu sync.Mutex // сериализует tc/nft: два параллельных Apply дали бы полусобранные qdisc
mu sync.Mutex // состояние сэмплера
prev map[string][2]float64
history map[string][]tick
histOrder []string
totals []tick
heavy *orderedSet
congested bool
lastRateDn float64
lastRateUp float64
lastTickAt time.Time
tickNo int
lastError string
windowTicks int
sampleS float64
started bool
}
func newShaping(s *Server) *Shaping {
sampleS := envNum("SHAPE_SAMPLE_S", 10)
windowS := envNum("SHAPE_WINDOW_S", 600)
return &Shaping{
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(),
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
}
}
func (h *Shaping) Settings() ShapingSettings {
s := h.s
down, _ := strconv.Atoi(s.getCfg("shapeDownMbit", "0"))
up, _ := strconv.Atoi(s.getCfg("shapeUpMbit", "0"))
return ShapingSettings{
Enabled: s.getCfg("shapeEnabled", "0") == "1",
DownMbit: down, UpMbit: up,
Auto: s.getCfg("shapeAuto", "1") == "1",
}
}
func (h *Shaping) save(st ShapingSettings) {
b := func(v bool) string {
if v {
return "1"
}
return "0"
}
h.s.setCfg("shapeEnabled", b(st.Enabled))
h.s.setCfg("shapeDownMbit", strconv.Itoa(st.DownMbit))
h.s.setCfg("shapeUpMbit", strconv.Itoa(st.UpMbit))
h.s.setCfg("shapeAuto", b(st.Auto))
}
func (h *Shaping) noteError(err error) {
h.mu.Lock()
defer h.mu.Unlock()
if m := err.Error(); m != h.lastError { // одинаковую ошибку в лог не спамим
h.s.log.Warn("shaping: " + m)
h.lastError = m
}
}
func (h *Shaping) overhead() int {
if v := os.Getenv("SHAPE_OVERHEAD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
return int(f)
}
}
return 60 + h.s.cfg().Params.S4
}
func shExec(name string, args ...string) (string, error) {
return runCtx(15*time.Second, name, args...)
}
func shExecStdin(name string, args []string, input string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var se bytes.Buffer
cmd.Stderr = &se
cmd.Stdin = strings.NewReader(input)
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s: %s", name, msg)
}
return nil
}
func (h *Shaping) nftApply(heavy []string) error {
elements := ""
if len(heavy) > 0 {
elements = "\n elements = { " + strings.Join(heavy, ", ") + " }"
}
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
script := "\nadd table inet " + nftTable + "\ndelete table inet " + nftTable + "\ntable inet " + nftTable + " {\n" +
" set heavy {\n type ipv4_addr" + elements + "\n }\n" +
" chain postrouting {\n type filter hook postrouting priority -150; policy accept;\n" +
" oifname \"" + h.s.iface + "\" ip daddr @heavy ip dscp set cs1\n }\n}\n"
return shExecStdin("nft", []string{"-f", "-"}, script)
}
func (h *Shaping) hasCake(dev string) bool {
out, err := shExec("tc", "qdisc", "show", "dev", dev, "root")
return err == nil && strings.Contains(out, "cake")
}
func (h *Shaping) hasIngressRedirect() bool {
out, err := shExec("tc", "filter", "show", "dev", h.s.iface, "parent", "ffff:")
return err == nil && strings.Contains(out, "mirred")
}
func (h *Shaping) applyNow() error {
st := h.Settings()
if !st.Enabled || st.DownMbit <= 0 || st.UpMbit <= 0 {
h.removeNow()
return nil
}
iface := h.s.iface
oh := strconv.Itoa(h.overhead())
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
if _, err := shExec("tc", "qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.DownMbit), "diffserv3", "dual-dsthost", "overhead", oh); err != nil {
return err
}
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
if _, err := shExec("ip", "link", "show", ifbName); err != nil {
if _, err := shExec("ip", "link", "add", ifbName, "type", "ifb"); err != nil {
return err
}
}
if _, err := shExec("ip", "link", "set", ifbName, "up"); err != nil {
return err
}
if _, err := shExec("tc", "qdisc", "replace", "dev", ifbName, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.UpMbit), "besteffort", "dual-srchost", "overhead", oh); err != nil {
return err
}
if !h.hasIngressRedirect() {
shExec("tc", "qdisc", "del", "dev", iface, "ingress") // ошибки игнорируем
if _, err := shExec("tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"); err != nil {
return err
}
if _, err := shExec("tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", ifbName); err != nil {
return err
}
}
h.mu.Lock()
heavy := h.heavy.list()
h.mu.Unlock()
if err := h.nftApply(heavy); err != nil {
return err
}
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
return nil
}
func (h *Shaping) removeNow() {
iface := h.s.iface
shExec("tc", "qdisc", "del", "dev", iface, "root")
shExec("tc", "qdisc", "del", "dev", iface, "ingress")
shExec("ip", "link", "del", ifbName)
shExec("nft", "delete", "table", "inet", nftTable)
h.mu.Lock()
h.heavy.clear()
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
h.totals = nil
h.congested = false
h.mu.Unlock()
}
// Apply не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от
// ensureInterfaceUp).
func (h *Shaping) Apply() {
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
// Update сохраняет настройки и применяет их; ошибку применения отдаёт вызывающему —
// пользователю нужна честная причина, а не молчаливый warn.
func (h *Shaping) Update(st ShapingSettings) error {
h.save(st)
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err == nil {
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
}
return err
}
// ───────── сэмплер (RFC 6057) ─────────
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
arr = append(arr, t)
for len(arr) > h.windowTicks {
arr = arr[1:]
}
return arr
}
func mbit(arr []tick) float64 {
var dt, by float64
for _, t := range arr {
dt += t.dt
by += t.bytes
}
if dt <= 0 {
return 0
}
return by * 8 / dt / 1e6
}
func (h *Shaping) readPeers() ([]peerCounters, error) {
out, err := runCtx(10*time.Second, "awg", "show", h.s.iface, "dump")
if err != nil {
return nil, err
}
var rows []peerCounters
lines := strings.Split(out, "\n")
if len(lines) > 0 {
lines = lines[1:]
}
for _, l := range lines {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" || f[3] == "" {
continue
}
ip := strings.SplitN(strings.SplitN(f[3], ",", 2)[0], "/", 2)[0]
rx, tx := 0.0, 0.0
if len(f) > 5 {
rx = orZero(jsNumber(f[5]))
}
if len(f) > 6 {
tx = orZero(jsNumber(f[6]))
}
rows = append(rows, peerCounters{ip, rx, tx})
}
return rows, nil
}
func orZero(f float64) float64 {
if math.IsNaN(f) {
return 0
}
return f
}
func (h *Shaping) tick() {
st := h.Settings()
if !st.Enabled {
return
}
now := time.Now()
h.mu.Lock()
dt := h.sampleS
if !h.lastTickAt.IsZero() {
dt = now.Sub(h.lastTickAt).Seconds()
}
h.lastTickAt = now
h.tickNo++
tickNo := h.tickNo
h.mu.Unlock()
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
h.s.log.Warn("shaping: qdisc на интерфейсе пропал, применяю заново")
h.Apply()
}
peers, err := h.readPeers()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
var totalDown, totalUp float64
seen := map[string]bool{}
for _, p := range peers {
seen[p.IP] = true
before, had := h.prev[p.IP]
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира →
// отрицательная дельта = 0.
dTx, dRx := 0.0, 0.0
if had && p.Tx >= before[1] {
dTx = p.Tx - before[1]
}
if had && p.Rx >= before[0] {
dRx = p.Rx - before[0]
}
h.prev[p.IP] = [2]float64{p.Rx, p.Tx}
totalDown += dTx
totalUp += dRx
if _, ok := h.history[p.IP]; !ok {
h.histOrder = append(h.histOrder, p.IP)
}
h.history[p.IP] = h.pushTick(h.history[p.IP], tick{dt, dTx})
}
var kept []string
for _, ip := range h.histOrder {
if seen[ip] {
kept = append(kept, ip)
continue
}
delete(h.prev, ip)
delete(h.history, ip)
h.heavy.del(ip)
}
h.histOrder = kept
h.totals = h.pushTick(h.totals, tick{dt, totalDown})
h.lastRateDn = totalDown * 8 / dt / 1e6
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
changed := h.heavy.size() > 0 || h.congested
h.heavy.clear()
h.congested = false
h.mu.Unlock()
if changed {
h.opMu.Lock()
err := h.nftApply(nil)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
h.mu.Unlock()
return
}
avg := mbit(h.totals)
wasCongested := h.congested
if !h.congested && avg >= congestOn*float64(st.DownMbit) {
h.congested = true
}
if h.congested && avg < congestOff*float64(st.DownMbit) {
h.congested = false
}
next := newOrderedSet()
if h.congested {
type rate struct {
ip string
r float64
}
var rates []rate
for _, ip := range h.histOrder {
if hh := h.history[ip]; len(hh) >= h.windowTicks {
rates = append(rates, rate{ip, mbit(hh)})
}
}
active := 0
for _, r := range rates {
if r.r >= activeMbit {
active++
}
}
share := float64(st.DownMbit) / math.Max(1, float64(active))
for _, r := range rates {
wasHeavy := h.heavy.has(r.ip)
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if r.r >= math.Max(heavyMult*share, heavyMinFrac*float64(st.DownMbit)) || (wasHeavy && r.r >= share) {
next.add(r.ip)
}
}
}
changed := next.size() != h.heavy.size()
if !changed {
for _, ip := range next.keys {
if !h.heavy.has(ip) {
changed = true
break
}
}
}
var apply []string
if changed || wasCongested != h.congested {
h.heavy = next
apply = next.list()
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply)
}
h.mu.Unlock()
if changed || wasCongested != h.congested {
h.opMu.Lock()
err := h.nftApply(apply)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
}
func (h *Shaping) Start() {
h.mu.Lock()
if h.started {
h.mu.Unlock()
return
}
h.started = true
h.mu.Unlock()
go func() {
t := time.NewTicker(time.Duration(h.sampleS * float64(time.Second)))
defer t.Stop()
var busy sync.Mutex
for range t.C {
if !busy.TryLock() { // предыдущий тик ещё идёт — пропускаем, а не копим очередь
continue
}
go func() {
defer busy.Unlock()
h.tick()
}()
}
}()
}
// ───────── состояние ─────────
func (h *Shaping) qdiscStats(dev string) *QdiscStats {
out, err := shExec("tc", "-s", "-j", "qdisc", "show", "dev", dev, "root")
if err != nil {
return nil
}
var arr []map[string]any
if json.Unmarshal([]byte(out), &arr) != nil {
return nil
}
for _, q := range arr {
if q["kind"] == "cake" {
n := func(k string) float64 { f, _ := q[k].(float64); return f }
return &QdiscStats{n("packets"), n("bytes"), n("drops"), n("backlog")}
}
}
return nil
}
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.heavy.has(ip) {
return "background"
}
return "normal"
}
func (h *Shaping) Status() ShapingStatus {
st := h.Settings()
var active bool
var down, up *QdiscStats
if st.Enabled {
active = h.hasCake(h.s.iface)
down, up = h.qdiscStats(h.s.iface), h.qdiscStats(ifbName)
}
h.mu.Lock()
defer h.mu.Unlock()
devices := 0
for _, hh := range h.history {
if mbit(hh) >= activeMbit {
devices++
}
}
heavy := h.heavy.list()
if heavy == nil {
heavy = []string{}
}
round1 := func(f float64) float64 { return math.Round(f*10) / 10 }
return ShapingStatus{
Settings: st, Active: active, Congested: h.congested, Heavy: heavy, Devices: devices,
RateDown: round1(h.lastRateDn), RateUp: round1(h.lastRateUp), Down: down, Up: up,
}
}