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.')">
+
+
Count > 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.
- | Name | Address | Enabled | Status | Created | |
+ | Name | Address | Enabled | Status | Usage | Note | |
{% for peer in peers %}
@@ -19,9 +23,10 @@
| {{ 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 }} |
|
{% else %}
- | No peers yet. Add one above. |
+ | No peers yet. Add one above. |
{% endfor %}
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