diff --git a/CHANGELOG.md b/CHANGELOG.md index 238a6c3..5dc8d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ --- +## 1.4.10 — 2026-11-03 +- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается. + ## Как обновиться после фиксов ```bash diff --git a/VERSION b/VERSION index 4ea2b1f..ac9f79c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.9 +1.4.10 diff --git a/app.py b/app.py index 62379f3..3e4a402 100644 --- a/app.py +++ b/app.py @@ -802,6 +802,7 @@ _last_fanout = 0 _last_peer_sync = 0 server_stats_cache = {} server_handshakes_cache = {} +server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health) def count_client_peers(): @@ -889,6 +890,7 @@ def get_remote_stats(server): raise Exception("no response") # cache handshakes so page renders never block on remote HTTP server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {} + server_transfer_cache[server.get("ip", "")] = data.get("transfer", {}) or {} # Filter peers: only count known client public keys clients = get_clients() client_pubs = {c.get("public_key", "") for c in clients} @@ -1751,6 +1753,71 @@ def finish_legacy_update(): finish_legacy_update() threading.Thread(target=rate_sampler, daemon=True).start() +# ---- Monthly traffic per client, summed over all servers; resets on the 1st ---- +# WireGuard counters live per server and drop to 0 on wg restart/reboot, so we add deltas. +TRAFFIC_FILE = f"{PANEL_DIR}/traffic.json" +_traffic_lock = threading.Lock() + + +def _traffic_load(): + try: + with open(TRAFFIC_FILE) as f: + return json.load(f) + except Exception: + return {} + + +def _local_transfer(): + out = {} + try: + for line in subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5).strip().split("\n")[1:]: + p = line.split("\t") + if len(p) >= 7: + out[p[0]] = [int(p[5]), int(p[6])] + except Exception: + pass + return out + + +def traffic_sample(): + month = datetime.now().strftime("%Y-%m") + with _traffic_lock: + d = _traffic_load() + if d.get("month") != month: + if d.get("usage"): + d.setdefault("history", {})[d.get("month", "?")] = d["usage"] + d["month"], d["usage"] = month, {} + prev, usage = d.setdefault("prev", {}), d.setdefault("usage", {}) + pub2cid = {c.get("public_key"): c.get("client_id") for c in get_clients() if c.get("public_key")} + srcs = {SERVER_IP: _local_transfer()} + srcs.update(server_transfer_cache) + for ip, counters in srcs.items(): + for pub, (rx, tx) in counters.items(): + key, old, cid = ip + "|" + pub, prev.get(ip + "|" + pub), pub2cid.get(pub) + prev[key] = [rx, tx] + if old is None or not cid: # first sight = baseline only + continue + u = usage.setdefault(cid, [0, 0]) + u[0] += rx - old[0] if rx >= old[0] else rx # counter went down = wg restarted + u[1] += tx - old[1] if tx >= old[1] else tx + tmp = TRAFFIC_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(d, f) + os.replace(tmp, TRAFFIC_FILE) + + +def traffic_sampler(): + time.sleep(20) + while True: + try: + traffic_sample() + except Exception: + pass + time.sleep(60) + + +threading.Thread(target=traffic_sampler, daemon=True).start() + PAGE = """ @@ -2294,6 +2361,27 @@ def _terminal_button(url, title): f'style="background:#334155" title="{_html_attr(title)}">🖥️') +def _fmt_bytes(n): + for u in ("B", "KiB", "MiB", "GiB", "TiB"): + if n < 1024 or u == "TiB": + return f"{n:.2f} {u}" if u != "B" else f"{int(n)} B" + n /= 1024 + + +def _traffic_cell(u): + if not u: + return "—" + return f"↓ {_fmt_bytes(u[1])} · ↑ {_fmt_bytes(u[0])}" # server tx = client download + + +@app.route("/api/traffic") +@auth_required +def api_traffic(): + """Monthly traffic per client: current month + history ({client: [rx, tx]}, server-side bytes).""" + d = _traffic_load() + return jsonify({"month": d.get("month"), "usage": d.get("usage", {}), "history": d.get("history", {})}) + + @app.route("/clients", methods=["GET", "POST"]) @auth_required def clients_page(): @@ -2601,6 +2689,7 @@ def clients_page(): """ + _tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage lim = str(limits.get(cid, "") or "") rate = client_rates.get(pub) speed = (f'↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}' if rate else "") @@ -2613,7 +2702,7 @@ def clients_page(): """ rows += f"""
-
{client_cell} {ip} {status} {expiry_badge} {f'≤{_html_attr(lim)} Mbit' if lim and lim != "0" else ""} {speed}
+
{client_cell} {ip} {status} {expiry_badge} {_traffic_cell(_tusage.get(cid))} {f'≤{_html_attr(lim)} Mbit' if lim and lim != "0" else ""} {speed}
{server_cell}{expiry_cell}{limit_cell}{rename_box}
{actions_cell}
""" diff --git a/server/api.py b/server/api.py index 45dc78f..53c6763 100644 --- a/server/api.py +++ b/server/api.py @@ -69,11 +69,16 @@ def health(): out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5) peer_keys = [] handshakes = {} + transfer = {} for line in out.strip().split("\n")[1:]: parts = line.split("\t") if len(parts) >= 4: pub = parts[0] peer_keys.append(pub) + try: + transfer[pub] = [int(parts[5]), int(parts[6])] + except Exception: + pass try: handshakes[pub] = int(parts[4]) except Exception: @@ -83,6 +88,7 @@ def health(): peers = 0 peer_keys = [] handshakes = {} + transfer = {} cpu = cpu_percent() try: parts = Path("/proc/meminfo").read_text().splitlines() @@ -96,7 +102,7 @@ def health(): mem_str = f"{used}/{total}MB" except Exception: mem_str = "?" - return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str, "net": net_bytes()}) + return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()}) @app.route("/api/obf-health")