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>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
d97ae64c85
commit
c4dc22074e
@@ -10,6 +10,18 @@ WG_PORT=51821
|
|||||||
WG_SUBNET=10.8.0.0/24
|
WG_SUBNET=10.8.0.0/24
|
||||||
WG_DNS=8.8.8.8
|
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 panel login
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=password
|
ADMIN_PASSWORD=password
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -21,3 +21,24 @@ def get_db():
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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()
|
||||||
|
|||||||
+40
-3
@@ -13,9 +13,9 @@ from sqlalchemy.orm import Session
|
|||||||
from . import service, wireguard
|
from . import service, wireguard
|
||||||
from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials
|
from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials
|
||||||
from .config import settings
|
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")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ async def _background_sampler() -> None:
|
|||||||
await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL)
|
await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
service.accumulate_usage(db)
|
||||||
service.disable_expired_peers(db)
|
service.disable_expired_peers(db)
|
||||||
service.sample_traffic(db)
|
service.sample_traffic(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -36,6 +37,7 @@ async def _background_sampler() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
run_migrations()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
service.apply_config(db)
|
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])
|
@app.post("/peers", dependencies=[logged_in])
|
||||||
def create_peer(
|
def create_peer(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
expires_at: 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
|
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)
|
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])
|
@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in])
|
||||||
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
|
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
|
||||||
service.toggle_peer(db, _get_peer_or_404(db, peer_id))
|
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,
|
"name": peer.name,
|
||||||
"address": peer.address,
|
"address": peer.address,
|
||||||
"enabled": peer.enabled,
|
"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,
|
"online": (ps := status.peers.get(peer.public_key)) is not None and ps.online,
|
||||||
"endpoint": ps.endpoint if ps else None,
|
"endpoint": ps.endpoint if ps else None,
|
||||||
"latest_handshake": ps.latest_handshake.isoformat()
|
"latest_handshake": ps.latest_handshake.isoformat()
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ class Peer(Base):
|
|||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
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):
|
class TrafficSample(Base):
|
||||||
|
|||||||
+72
-2
@@ -16,7 +16,13 @@ def get_peer(db: Session, peer_id: int) -> Peer | None:
|
|||||||
return db.get(Peer, peer_id)
|
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()
|
private, public = wireguard.generate_keypair()
|
||||||
psk = wireguard.genpsk()
|
psk = wireguard.genpsk()
|
||||||
address = wireguard.next_free_address([p.address for p in list_peers(db)])
|
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),
|
preshared_key_enc=encrypt(psk),
|
||||||
address=address,
|
address=address,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
|
note=note,
|
||||||
|
quota_bytes=quota_bytes,
|
||||||
)
|
)
|
||||||
db.add(peer)
|
db.add(peer)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -34,8 +42,47 @@ def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> P
|
|||||||
return peer
|
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:
|
def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
|
||||||
private, public = wireguard.generate_keypair()
|
private, public = wireguard.generate_keypair()
|
||||||
|
peer.last_rx = 0
|
||||||
|
peer.last_tx = 0
|
||||||
peer.public_key = public
|
peer.public_key = public
|
||||||
peer.private_key_enc = encrypt(private)
|
peer.private_key_enc = encrypt(private)
|
||||||
peer.preshared_key_enc = encrypt(wireguard.genpsk())
|
peer.preshared_key_enc = encrypt(wireguard.genpsk())
|
||||||
@@ -61,10 +108,12 @@ def disable_expired_peers(db: Session) -> bool:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
changed = False
|
changed = False
|
||||||
for peer in list_peers(db):
|
for peer in list_peers(db):
|
||||||
|
if not peer.enabled:
|
||||||
|
continue
|
||||||
expires = peer.expires_at
|
expires = peer.expires_at
|
||||||
if expires is not None and expires.tzinfo is None:
|
if expires is not None and expires.tzinfo is None:
|
||||||
expires = expires.replace(tzinfo=timezone.utc)
|
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
|
peer.enabled = False
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
@@ -73,6 +122,27 @@ def disable_expired_peers(db: Session) -> bool:
|
|||||||
return changed
|
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:
|
def apply_config(db: Session) -> None:
|
||||||
private, _ = wireguard.ensure_server_keys()
|
private, _ = wireguard.ensure_server_keys()
|
||||||
wireguard.sync_peers(private, list_peers(db))
|
wireguard.sync_peers(private, list_peers(db))
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ input {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
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-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
.login-box {
|
.login-box {
|
||||||
|
|||||||
@@ -13,6 +13,19 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="muted">Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}</p>
|
<p class="muted">Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}</p>
|
||||||
{% if peer.expires_at %}<p class="muted">Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}</p>{% endif %}
|
{% if peer.expires_at %}<p class="muted">Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}</p>{% endif %}
|
||||||
|
<p>Usage: {{ peer.cum_total | fmt_bytes }}
|
||||||
|
{% if peer.quota_bytes %} / {{ peer.quota_bytes | fmt_bytes }}
|
||||||
|
{% if peer.over_quota %}<span class="badge err">over quota</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/update" class="stack">
|
||||||
|
<label class="muted">Note <input name="note" value="{{ peer.note }}"></label>
|
||||||
|
<label class="muted">Quota GiB
|
||||||
|
<input name="quota_gib" type="number" step="0.1" min="0" class="narrow"
|
||||||
|
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}" placeholder="0 = unlimited">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Live status</h3>
|
<h3>Live status</h3>
|
||||||
@@ -40,6 +53,10 @@
|
|||||||
onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')">
|
onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')">
|
||||||
<button type="submit">Rotate keys</button>
|
<button type="submit">Rotate keys</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/reset-usage" class="inline"
|
||||||
|
onsubmit="return confirm('Reset usage counters to zero?')">
|
||||||
|
<button type="submit">Reset usage</button>
|
||||||
|
</form>
|
||||||
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
|
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
|
||||||
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
|
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
|
||||||
<button type="submit" class="danger">Delete</button>
|
<button type="submit" class="danger">Delete</button>
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
|
|
||||||
<form method="post" action="/peers" class="add-peer">
|
<form method="post" action="/peers" class="add-peer">
|
||||||
<input name="name" placeholder="Peer name" required>
|
<input name="name" placeholder="Peer name" required>
|
||||||
|
<input name="note" placeholder="Note (optional)">
|
||||||
|
<label class="muted">Quota GiB <input name="quota_gib" type="number" step="0.1" min="0" class="narrow" placeholder="0"></label>
|
||||||
|
<label class="muted">Count <input name="count" type="number" min="1" max="50" value="1" class="narrow"></label>
|
||||||
<label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
|
<label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
|
||||||
<button type="submit">Add peer</button>
|
<button type="submit">Add peer(s)</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="muted">Count > 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Address</th><th>Enabled</th><th>Status</th><th>Created</th><th></th></tr>
|
<tr><th>Name</th><th>Address</th><th>Enabled</th><th>Status</th><th>Usage</th><th>Note</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for peer in peers %}
|
{% for peer in peers %}
|
||||||
@@ -19,9 +23,10 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="/peers/{{ peer.id }}">{{ peer.name }}</a></td>
|
<td><a href="/peers/{{ peer.id }}">{{ peer.name }}</a></td>
|
||||||
<td>{{ peer.address }}</td>
|
<td>{{ peer.address }}</td>
|
||||||
<td>{% if peer.enabled %}<span class="badge ok">enabled</span>{% else %}<span class="badge err">disabled</span>{% endif %}</td>
|
<td>{% if peer.enabled %}<span class="badge ok">enabled</span>{% elif peer.over_quota %}<span class="badge err">over quota</span>{% else %}<span class="badge err">disabled</span>{% endif %}</td>
|
||||||
<td>{% if ps and ps.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</td>
|
<td>{% if ps and ps.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</td>
|
||||||
<td class="muted">{{ peer.created_at.strftime("%Y-%m-%d") }}</td>
|
<td class="muted">{{ peer.cum_total | fmt_bytes }}{% if peer.quota_bytes %} / {{ peer.quota_bytes | fmt_bytes }}{% endif %}</td>
|
||||||
|
<td class="muted">{{ peer.note }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
|
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
|
||||||
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
|
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
|
||||||
@@ -33,7 +38,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="6" class="muted">No peers yet. Add one above.</td></tr>
|
<tr><td colspan="7" class="muted">No peers yet. Add one above.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+6
-1
@@ -10,15 +10,20 @@ services:
|
|||||||
- net.ipv4.conf.all.src_valid_mark=1
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
devices:
|
devices:
|
||||||
- /dev/net/tun:/dev/net/tun
|
- /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:
|
ports:
|
||||||
- "${WG_PORT:-51821}:${WG_PORT:-51821}/udp"
|
- "${WG_PORT:-51821}:${WG_PORT:-51821}/udp"
|
||||||
- "127.0.0.1:8000:8000/tcp"
|
- "8000:8000/tcp"
|
||||||
environment:
|
environment:
|
||||||
WG_INTERFACE: ${WG_INTERFACE:-wg1}
|
WG_INTERFACE: ${WG_INTERFACE:-wg1}
|
||||||
WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain}
|
WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain}
|
||||||
WG_PORT: ${WG_PORT:-51821}
|
WG_PORT: ${WG_PORT:-51821}
|
||||||
WG_SUBNET: ${WG_SUBNET:-10.8.0.0/24}
|
WG_SUBNET: ${WG_SUBNET:-10.8.0.0/24}
|
||||||
WG_DNS: ${WG_DNS:-8.8.8.8}
|
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_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||||
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
|
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
|
||||||
|
|||||||
@@ -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 -C FORWARD -o "$WG_INTERFACE" -j ACCEPT 2>/dev/null \
|
||||||
|| iptables -A FORWARD -o "$WG_INTERFACE" -j ACCEPT
|
|| 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<NF;i++) if ($i=="dev") print $(i+1); exit}')"
|
||||||
|
[ -n "$relay_iface" ] || { echo "WARN: no route to relay subnet $subnet, skipping"; continue; }
|
||||||
|
iptables -t nat -C POSTROUTING -s "$WG_SUBNET" -d "$subnet" -o "$relay_iface" -j MASQUERADE 2>/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
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
Reference in New Issue
Block a user