From c4dc22074ea124ca3bf2844754afd3e6d98baf49 Mon Sep 17 00:00:00 2001 From: lofyer Date: Sat, 4 Jul 2026 07:34:57 +0800 Subject: [PATCH] Peer enhancements: notes, traffic quotas with auto-disable, batch create, usage tracking; relay subnets for bridging into other tunnels Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .env.example | 12 ++++++ app/db.py | 23 ++++++++++- app/main.py | 43 ++++++++++++++++++-- app/models.py | 14 +++++++ app/service.py | 74 +++++++++++++++++++++++++++++++++- app/static/style.css | 5 ++- app/templates/peer_detail.html | 17 ++++++++ app/templates/peers.html | 15 ++++--- docker-compose.yml | 7 +++- entrypoint.sh | 12 ++++++ 10 files changed, 209 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index e8674a2..95c5276 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,18 @@ WG_PORT=51821 WG_SUBNET=10.8.0.0/24 WG_DNS=8.8.8.8 +# What client configs route through the tunnel. +# Full tunnel (all traffic): 0.0.0.0/0, ::/0 +# Split tunnel / mesh: list subnets, e.g. "10.8.0.0/24, 10.7.0.0/24" +# to let clients reach both this VPN and the wg0 tunnel network. +WG_ALLOWED_IPS=0.0.0.0/0, ::/0 + +# Relay (组网): extra host networks to NAT wg1 clients into, e.g. "10.7.0.0/24" +# to bridge clients into the wg0 tunnel. Requires network_mode: host in +# docker-compose.yml (uncomment it and remove ports:) so the container can +# reach the host's wg0 interface. +WG_RELAY_SUBNETS= + # Admin panel login ADMIN_USERNAME=admin ADMIN_PASSWORD=password diff --git a/app/db.py b/app/db.py index 029ef68..c1879ce 100644 --- a/app/db.py +++ b/app/db.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from .config import settings @@ -21,3 +21,24 @@ def get_db(): yield db finally: db.close() + + +PEER_MIGRATIONS = { + "note": "ALTER TABLE peers ADD COLUMN note VARCHAR(256) NOT NULL DEFAULT ''", + "quota_bytes": "ALTER TABLE peers ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 0", + "cum_rx": "ALTER TABLE peers ADD COLUMN cum_rx INTEGER NOT NULL DEFAULT 0", + "cum_tx": "ALTER TABLE peers ADD COLUMN cum_tx INTEGER NOT NULL DEFAULT 0", + "last_rx": "ALTER TABLE peers ADD COLUMN last_rx INTEGER NOT NULL DEFAULT 0", + "last_tx": "ALTER TABLE peers ADD COLUMN last_tx INTEGER NOT NULL DEFAULT 0", +} + + +def run_migrations() -> None: + with engine.connect() as conn: + existing = { + row[1] for row in conn.execute(text("PRAGMA table_info(peers)")) + } + for column, ddl in PEER_MIGRATIONS.items(): + if existing and column not in existing: + conn.execute(text(ddl)) + conn.commit() diff --git a/app/main.py b/app/main.py index 51aa557..6fd8fd9 100644 --- a/app/main.py +++ b/app/main.py @@ -13,9 +13,9 @@ from sqlalchemy.orm import Session from . import service, wireguard from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials from .config import settings -from .db import Base, SessionLocal, engine, get_db +from .db import Base, SessionLocal, engine, get_db, run_migrations -TRAFFIC_SAMPLE_INTERVAL = 300 +TRAFFIC_SAMPLE_INTERVAL = 60 templates = Jinja2Templates(directory="app/templates") @@ -25,6 +25,7 @@ async def _background_sampler() -> None: await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL) db = SessionLocal() try: + service.accumulate_usage(db) service.disable_expired_peers(db) service.sample_traffic(db) except Exception: @@ -36,6 +37,7 @@ async def _background_sampler() -> None: @asynccontextmanager async def lifespan(app: FastAPI): Base.metadata.create_all(engine) + run_migrations() db = SessionLocal() try: service.apply_config(db) @@ -114,14 +116,26 @@ def peers_page(request: Request, db: Session = Depends(get_db)): ) +def _parse_quota_gib(value: str) -> int: + quota = float(value) if value.strip() else 0 + return int(quota * 1024**3) + + @app.post("/peers", dependencies=[logged_in]) def create_peer( db: Session = Depends(get_db), name: str = Form(...), expires_at: str = Form(""), + note: str = Form(""), + quota_gib: str = Form(""), + count: int = Form(1), ): expiry = datetime.fromisoformat(expires_at) if expires_at else None - peer = service.create_peer(db, name.strip(), expiry) + quota = _parse_quota_gib(quota_gib) + if count > 1: + service.create_peers_batch(db, name.strip(), min(count, 50), expiry, note.strip(), quota) + return RedirectResponse("/peers", status_code=303) + peer = service.create_peer(db, name.strip(), expiry, note.strip(), quota) return RedirectResponse(f"/peers/{peer.id}", status_code=303) @@ -143,6 +157,24 @@ def peer_detail(request: Request, peer_id: int, db: Session = Depends(get_db)): ) +@app.post("/peers/{peer_id}/update", dependencies=[logged_in]) +def update_peer( + peer_id: int, + db: Session = Depends(get_db), + note: str = Form(""), + quota_gib: str = Form(""), +): + peer = _get_peer_or_404(db, peer_id) + service.update_peer(db, peer, note.strip(), _parse_quota_gib(quota_gib)) + return RedirectResponse(f"/peers/{peer_id}", status_code=303) + + +@app.post("/peers/{peer_id}/reset-usage", dependencies=[logged_in]) +def reset_usage(peer_id: int, db: Session = Depends(get_db)): + service.reset_peer_usage(db, _get_peer_or_404(db, peer_id)) + return RedirectResponse(f"/peers/{peer_id}", status_code=303) + + @app.post("/peers/{peer_id}/toggle", dependencies=[logged_in]) def toggle_peer(peer_id: int, db: Session = Depends(get_db)): service.toggle_peer(db, _get_peer_or_404(db, peer_id)) @@ -211,6 +243,11 @@ def api_status(db: Session = Depends(get_db)): "name": peer.name, "address": peer.address, "enabled": peer.enabled, + "note": peer.note, + "quota_bytes": peer.quota_bytes, + "cum_rx": peer.cum_rx, + "cum_tx": peer.cum_tx, + "over_quota": peer.over_quota, "online": (ps := status.peers.get(peer.public_key)) is not None and ps.online, "endpoint": ps.endpoint if ps else None, "latest_handshake": ps.latest_handshake.isoformat() diff --git a/app/models.py b/app/models.py index ed83edc..0a61d37 100644 --- a/app/models.py +++ b/app/models.py @@ -22,6 +22,20 @@ class Peer(Base): enabled: Mapped[bool] = mapped_column(Boolean, default=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + note: Mapped[str] = mapped_column(String(256), default="") + quota_bytes: Mapped[int] = mapped_column(Integer, default=0) + cum_rx: Mapped[int] = mapped_column(Integer, default=0) + cum_tx: Mapped[int] = mapped_column(Integer, default=0) + last_rx: Mapped[int] = mapped_column(Integer, default=0) + last_tx: Mapped[int] = mapped_column(Integer, default=0) + + @property + def cum_total(self) -> int: + return self.cum_rx + self.cum_tx + + @property + def over_quota(self) -> bool: + return self.quota_bytes > 0 and self.cum_total >= self.quota_bytes class TrafficSample(Base): diff --git a/app/service.py b/app/service.py index a923284..fcab3b8 100644 --- a/app/service.py +++ b/app/service.py @@ -16,7 +16,13 @@ def get_peer(db: Session, peer_id: int) -> Peer | None: return db.get(Peer, peer_id) -def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> Peer: +def create_peer( + db: Session, + name: str, + expires_at: datetime | None = None, + note: str = "", + quota_bytes: int = 0, +) -> Peer: private, public = wireguard.generate_keypair() psk = wireguard.genpsk() address = wireguard.next_free_address([p.address for p in list_peers(db)]) @@ -27,6 +33,8 @@ def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> P preshared_key_enc=encrypt(psk), address=address, expires_at=expires_at, + note=note, + quota_bytes=quota_bytes, ) db.add(peer) db.commit() @@ -34,8 +42,47 @@ def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> P return peer +def create_peers_batch( + db: Session, + base_name: str, + count: int, + expires_at: datetime | None = None, + note: str = "", + quota_bytes: int = 0, +) -> list[Peer]: + existing = {p.name for p in list_peers(db)} + peers = [] + index = 1 + for _ in range(count): + while f"{base_name}-{index}" in existing: + index += 1 + name = f"{base_name}-{index}" + existing.add(name) + peers.append(create_peer(db, name, expires_at, note, quota_bytes)) + return peers + + +def update_peer(db: Session, peer: Peer, note: str, quota_bytes: int) -> Peer: + peer.note = note + peer.quota_bytes = quota_bytes + db.commit() + if not peer.over_quota: + apply_config(db) + return peer + + +def reset_peer_usage(db: Session, peer: Peer) -> Peer: + peer.cum_rx = 0 + peer.cum_tx = 0 + db.commit() + apply_config(db) + return peer + + def rotate_peer_keys(db: Session, peer: Peer) -> Peer: private, public = wireguard.generate_keypair() + peer.last_rx = 0 + peer.last_tx = 0 peer.public_key = public peer.private_key_enc = encrypt(private) peer.preshared_key_enc = encrypt(wireguard.genpsk()) @@ -61,10 +108,12 @@ def disable_expired_peers(db: Session) -> bool: now = datetime.now(timezone.utc) changed = False for peer in list_peers(db): + if not peer.enabled: + continue expires = peer.expires_at if expires is not None and expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) - if peer.enabled and expires is not None and expires <= now: + if (expires is not None and expires <= now) or peer.over_quota: peer.enabled = False changed = True if changed: @@ -73,6 +122,27 @@ def disable_expired_peers(db: Session) -> bool: return changed +def accumulate_usage(db: Session) -> None: + status = wireguard.get_status() + changed = False + for peer in list_peers(db): + peer_status = status.peers.get(peer.public_key) + if peer_status is None: + continue + rx, tx = peer_status.rx_bytes, peer_status.tx_bytes + # wg counters reset on interface restart or peer re-add + delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx + delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx + if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx: + peer.cum_rx += delta_rx + peer.cum_tx += delta_tx + peer.last_rx = rx + peer.last_tx = tx + changed = True + if changed: + db.commit() + + def apply_config(db: Session) -> None: private, _ = wireguard.ensure_server_keys() wireguard.sync_peers(private, list_peers(db)) diff --git a/app/static/style.css b/app/static/style.css index 921df26..fad9a30 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -69,7 +69,10 @@ input { border-radius: 6px; font-size: 0.9rem; } -.add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } +.add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.25rem; flex-wrap: wrap; } +input.narrow { width: 80px; } +form.stack label { display: block; margin-bottom: 0.5rem; } +form.stack { margin-top: 0.75rem; } .login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; } .login-box { diff --git a/app/templates/peer_detail.html b/app/templates/peer_detail.html index 4b0a4c7..9536007 100644 --- a/app/templates/peer_detail.html +++ b/app/templates/peer_detail.html @@ -13,6 +13,19 @@

Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}

{% if peer.expires_at %}

Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}

{% endif %} +

Usage: {{ peer.cum_total | fmt_bytes }} + {% if peer.quota_bytes %} / {{ peer.quota_bytes | fmt_bytes }} + {% if peer.over_quota %}over quota{% endif %} + {% endif %} +

+
+ + + +

Live status

@@ -40,6 +53,10 @@ onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')"> +
+ +
diff --git a/app/templates/peers.html b/app/templates/peers.html index f5f8873..e0a8f8f 100644 --- a/app/templates/peers.html +++ b/app/templates/peers.html @@ -5,13 +5,17 @@ + + + - +
+

Count > 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.

- + {% for peer in peers %} @@ -19,9 +23,10 @@ - + - + + {% else %} - + {% endfor %}
NameAddressEnabledStatusCreated
NameAddressEnabledStatusUsageNote
{{ peer.name }} {{ peer.address }}{% if peer.enabled %}enabled{% else %}disabled{% endif %}{% if peer.enabled %}enabled{% elif peer.over_quota %}over quota{% else %}disabled{% endif %} {% if ps and ps.online %}online{% else %}offline{% endif %}{{ peer.created_at.strftime("%Y-%m-%d") }}{{ peer.cum_total | fmt_bytes }}{% if peer.quota_bytes %} / {{ peer.quota_bytes | fmt_bytes }}{% endif %}{{ peer.note }}
@@ -33,7 +38,7 @@
No peers yet. Add one above.
No peers yet. Add one above.
diff --git a/docker-compose.yml b/docker-compose.yml index 922f61f..181d9b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,15 +10,20 @@ services: - net.ipv4.conf.all.src_valid_mark=1 devices: - /dev/net/tun:/dev/net/tun + # For relay/组网 into host tunnels (WG_RELAY_SUBNETS), use host networking: + # uncomment network_mode and remove the ports section. + # network_mode: host ports: - "${WG_PORT:-51821}:${WG_PORT:-51821}/udp" - - "127.0.0.1:8000:8000/tcp" + - "8000:8000/tcp" environment: WG_INTERFACE: ${WG_INTERFACE:-wg1} WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain} WG_PORT: ${WG_PORT:-51821} WG_SUBNET: ${WG_SUBNET:-10.8.0.0/24} WG_DNS: ${WG_DNS:-8.8.8.8} + WG_ALLOWED_IPS: ${WG_ALLOWED_IPS:-0.0.0.0/0, ::/0} + WG_RELAY_SUBNETS: ${WG_RELAY_SUBNETS:-} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env} diff --git a/entrypoint.sh b/entrypoint.sh index d55c86b..6f2a4d7 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -12,4 +12,16 @@ iptables -C FORWARD -i "$WG_INTERFACE" -j ACCEPT 2>/dev/null \ iptables -C FORWARD -o "$WG_INTERFACE" -j ACCEPT 2>/dev/null \ || iptables -A FORWARD -o "$WG_INTERFACE" -j ACCEPT +# Relay wg clients into extra networks (e.g. another wg tunnel on the host). +OLD_IFS="$IFS"; IFS=',' +for subnet in ${WG_RELAY_SUBNETS:-}; do + subnet="$(echo "$subnet" | tr -d ' ')" + [ -n "$subnet" ] || continue + relay_iface="$(ip route get "${subnet%/*}" 2>/dev/null | awk '/dev/ {for (i=1;i/dev/null \ + || iptables -t nat -A POSTROUTING -s "$WG_SUBNET" -d "$subnet" -o "$relay_iface" -j MASQUERADE +done +IFS="$OLD_IFS" + exec uvicorn app.main:app --host 0.0.0.0 --port 8000