Repository navigation
feat(wireguard): support WireGuard as an Xray inbound #672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f99bdda
55795ae
a436433
1a4f090
3cdc934
a38e765
ef79c7e
281115e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| * text=auto eol=lf | ||
| *.sh text eol=lf |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ local_settings.py | |
| *.sqlite3* | ||
| *.sqlite3-journal | ||
| *.db | ||
| *.db-* | ||
|
|
||
| # Flask stuff: | ||
| instance/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| stages: | ||
| - lint | ||
| - test | ||
| - frontend | ||
| - image | ||
|
|
||
| variables: | ||
| UV_LINK_MODE: copy | ||
| UV_PYTHON_DOWNLOADS: "0" | ||
| TESTING: "1" | ||
|
|
||
| .python: | ||
| image: ghcr.io/astral-sh/uv:python3.14-bookworm-slim@sha256:7cf77f594be8042dab6daa9fe326f90962252268b4f120a7f5dccce4d947e6c1 | ||
| before_script: | ||
| - uv sync --frozen --group dev | ||
|
|
||
| lint: | ||
| extends: .python | ||
| stage: lint | ||
| script: | ||
| - uv run ruff check --no-fix . | ||
| - uv run ruff format --check . | ||
| - uv run python scripts/regenerate_node_bridge_proto.py | ||
| - uv run python -c "from PasarGuardNodeBridge.common.service_pb2 import Wireguard; assert any(field.name == 'pre_shared_key' and field.number == 3 for field in Wireguard.DESCRIPTOR.fields); print('bridge PSK OK')" | ||
|
|
||
| test-sqlite: | ||
| extends: .python | ||
| stage: test | ||
| variables: | ||
| SQLALCHEMY_DATABASE_URL: sqlite+aiosqlite:///./test.db | ||
| script: | ||
| - uv run python scripts/regenerate_node_bridge_proto.py | ||
| - uv run alembic upgrade head | ||
| - uv run alembic check | ||
| - uv run pytest tests/ | ||
|
|
||
| test-postgres: | ||
| extends: .python | ||
| stage: test | ||
| services: | ||
| - name: postgres:17.5-alpine | ||
| alias: postgres | ||
| variables: | ||
| POSTGRES_DB: pasarguard_test | ||
| POSTGRES_USER: postgres | ||
| POSTGRES_PASSWORD: postgres | ||
| SQLALCHEMY_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/pasarguard_test | ||
| script: | ||
| - uv run python scripts/regenerate_node_bridge_proto.py | ||
| - uv run alembic upgrade head | ||
| - uv run alembic check | ||
| - uv run pytest tests/ | ||
|
|
||
| dashboard: | ||
| image: oven/bun:1.3.7-alpine | ||
| stage: frontend | ||
| script: | ||
| - cd dashboard | ||
| - bun install --frozen-lockfile | ||
| - bun run tsc --noEmit | ||
| - bun run build | ||
|
|
||
| docker-ci: | ||
| image: docker:28.5.1-cli | ||
| stage: image | ||
| services: | ||
| - docker:28.5.1-dind | ||
| variables: | ||
| DOCKER_HOST: tcp://docker:2375 | ||
| DOCKER_TLS_CERTDIR: "" | ||
| script: | ||
| - docker build --file Dockerfile.ci --tag pasarguard-ci:$CI_COMMIT_SHA . |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # Self-contained CI build of the PasarGuard panel (WireGuard fork). | ||
| # | ||
| # Differs from the upstream ./Dockerfile only in that the dashboard is built | ||
| # *inside* the image (bun stage) instead of being passed as a pre-built CI | ||
| # artifact, so a single `docker build` produces a complete image. | ||
| # | ||
| # The published pasarguard-node-bridge wheel does NOT ship the WireGuard | ||
| # pre_shared_key field, so the bridge protobufs are regenerated below. | ||
| ARG PYTHON_VERSION=3.14 | ||
|
|
||
| # 1. Dashboard (React/Vite) -> static assets in /dashboard/build | ||
| FROM oven/bun:1 AS dashboard-builder | ||
| WORKDIR /dashboard | ||
| COPY dashboard/package.json dashboard/bun.lock* ./ | ||
| RUN bun install --frozen-lockfile | ||
| COPY dashboard/ ./ | ||
| ENV VITE_BASE_API=/ | ||
| RUN bun run build && cp ./build/index.html ./build/404.html | ||
|
|
||
| # 2. Python deps + project (uv), with the dashboard baked in | ||
| FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS builder | ||
| ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0 | ||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||
| gcc python3-dev libc6-dev \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
| WORKDIR /build | ||
| COPY uv.lock pyproject.toml ./ | ||
| RUN --mount=type=cache,target=/root/.cache/uv \ | ||
| uv sync --frozen --no-install-project --no-dev | ||
| COPY . ./ | ||
| COPY --from=dashboard-builder /dashboard/build ./dashboard/build | ||
| RUN --mount=type=cache,target=/root/.cache/uv \ | ||
| uv sync --frozen --no-dev | ||
| # The published pasarguard-node-bridge wheel lacks the WireGuard pre_shared_key | ||
| # field; regenerate it from the vendored contract before producing the image. | ||
| RUN .venv/bin/pip install -q grpcio-tools \ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Use uv to install the protobuf generator.
Proposed change--- "a/Dockerfile.ci"
+++ "b/Dockerfile.ci"
@@ -33,10 +33,10 @@
uv sync --frozen --no-dev
# The published pasarguard-node-bridge wheel lacks the WireGuard pre_shared_key
# field; regenerate it from the vendored contract before producing the image.
-RUN .venv/bin/pip install -q grpcio-tools \
+RUN uv pip install --python .venv/bin/python grpcio-tools \
&& .venv/bin/python scripts/regenerate_node_bridge_proto.py \
&& .venv/bin/python -c "from PasarGuardNodeBridge.common.service_pb2 import Wireguard; assert any(f.name == 'pre_shared_key' for f in Wireguard.DESCRIPTOR.fields), 'PSK field missing after regeneration'; print('bridge PSK OK')" \
- && .venv/bin/pip uninstall -y grpcio-tools
+ && uv pip uninstall --python .venv/bin/python grpcio-tools
# 3. Runtime
FROM python:${PYTHON_VERSION}-slim-bookworm🤖 Prompt for AI Agents |
||
| && .venv/bin/python scripts/regenerate_node_bridge_proto.py \ | ||
| && .venv/bin/python -c "from PasarGuardNodeBridge.common.service_pb2 import Wireguard; assert any(f.name == 'pre_shared_key' for f in Wireguard.DESCRIPTOR.fields), 'PSK field missing after regeneration'; print('bridge PSK OK')" \ | ||
| && .venv/bin/pip uninstall -y grpcio-tools | ||
|
|
||
| # 3. Runtime | ||
| FROM python:${PYTHON_VERSION}-slim-bookworm | ||
| COPY --from=builder /build /code | ||
| WORKDIR /code | ||
| ENV PATH="/code/.venv/bin:$PATH" | ||
| RUN apt-get update && apt-get install -y --no-install-recommends curl openssl \ | ||
| && rm -rf /var/lib/apt/lists/* \ | ||
| && mkdir -p /code/db_data | ||
| COPY cli_wrapper.sh /usr/bin/pasarguard-cli | ||
| COPY tui_wrapper.sh /usr/bin/pasarguard-tui | ||
| RUN sed -i 's/\r$//' /code/start.sh /code/healthcheck.sh \ | ||
| /usr/bin/pasarguard-cli /usr/bin/pasarguard-tui \ | ||
| && chmod +x /usr/bin/pasarguard-cli /usr/bin/pasarguard-tui \ | ||
| /code/healthcheck.sh /code/start.sh | ||
| EXPOSE 8000 | ||
| ENTRYPOINT ["/code/start.sh"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,13 @@ | |
|
|
||
| from app.models.core import CoreType | ||
| from app.models.protocol import ProxyProtocol | ||
| from app.utils.crypto import get_cert_SANs, get_x25519_public_key, validate_mldsa65_seed, validate_mldsa65_verify | ||
| from app.utils.crypto import ( | ||
| get_cert_SANs, | ||
| get_wireguard_public_key, | ||
| get_x25519_public_key, | ||
| validate_mldsa65_seed, | ||
| validate_mldsa65_verify, | ||
| ) | ||
|
|
||
|
|
||
| def _protocols_from_inbounds_by_tag(inbounds_by_tag: dict[str, dict]) -> frozenset[ProxyProtocol]: | ||
|
|
@@ -439,8 +445,31 @@ def _resolve_inbounds(self): | |
| self._read_inbound(inbound) | ||
| self._protocols = _protocols_from_inbounds_by_tag(self._inbounds_by_tag) | ||
|
|
||
| def _read_wireguard_inbound(self, inbound: dict): | ||
| """Register a WireGuard-in-Xray inbound so its tag appears in /api/inbounds.""" | ||
| wg = inbound.get("settings", {}) | ||
| secret_key = wg.get("secretKey", "") | ||
| try: | ||
| public_key = get_wireguard_public_key(secret_key) if secret_key else "" | ||
| except Exception: | ||
| public_key = "" | ||
| settings = self._create_base_settings(inbound) | ||
| settings["listen_port"] = inbound.get("port") | ||
| settings["public_key"] = public_key | ||
| settings["address"] = wg.get("address", []) | ||
| settings["mtu"] = wg.get("mtu", 1420) | ||
| settings["network"] = "udp" | ||
| tag = inbound["tag"] | ||
| if tag not in self._inbounds: | ||
| self._inbounds.append(tag) | ||
| self._inbounds_by_tag[tag] = settings | ||
|
|
||
| def _read_inbound(self, inbound: dict): | ||
| """Read an inbound and its settings.""" | ||
| if inbound["protocol"] == "wireguard": | ||
| self._read_wireguard_inbound(inbound) | ||
|
Comment on lines
+469
to
+470
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Apply inbound exclusions to WireGuard. If an operator adds a WireGuard tag to 🤖 Prompt for AI Agents |
||
| return | ||
|
|
||
| if inbound["protocol"] not in ("vmess", "vless", "trojan", "shadowsocks", "hysteria"): | ||
| return | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| from sqlalchemy import and_, case, cast, delete, func, or_, select, text, update | ||
| from sqlalchemy.dialects.postgresql import JSONB | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
| from sqlalchemy.orm import selectinload | ||
| from sqlalchemy.sql import Select | ||
|
|
||
| from app.db.models import ( | ||
|
|
@@ -253,9 +254,7 @@ async def remove_groups_from_users( | |
|
|
||
| async def count_bulk_expire_targets(db: AsyncSession, bulk_model: BulkUser) -> int: | ||
| final_filter = _create_final_filter(bulk_model) | ||
| return ( | ||
| await db.execute(select(func.count(User.id)).where(and_(final_filter, User.expire.isnot(None)))) | ||
| ).scalar_one_or_none() or 0 | ||
| return (await db.execute(select(func.count(User.id)).where(final_filter))).scalar_one_or_none() or 0 | ||
|
|
||
|
|
||
| async def count_bulk_datalimit_targets(db: AsyncSession, bulk_model: BulkUser) -> int: | ||
|
|
@@ -277,6 +276,21 @@ async def count_bulk_group_scope(db: AsyncSession, bulk_model: BulkGroup) -> int | |
| return (await db.execute(select(func.count(User.id)).where(final_filter))).scalar_one_or_none() or 0 | ||
|
|
||
|
|
||
| async def get_bulk_wireguard_peer_ip_users( | ||
| db: AsyncSession, | ||
| bulk_model: BulkUserFilter, | ||
| admin_id: int | None = None, | ||
| ) -> list[User]: | ||
| final_filter = _create_final_filter(bulk_model) | ||
| if admin_id is not None: | ||
| final_filter = and_(final_filter, User.admin_id == admin_id) | ||
|
|
||
| result = await db.execute( | ||
| select(User).options(selectinload(User.groups).selectinload(Group.inbounds)).where(final_filter) | ||
| ) | ||
| return list(result.unique().scalars().all()) | ||
|
|
||
|
|
||
| def _create_final_filter(bulk_model: BulkUserFilter): | ||
| """Create a comprehensive SQLAlchemy filter condition from a bulk model.""" | ||
| other_conditions = [] | ||
|
|
@@ -313,12 +327,10 @@ async def update_users_expire(db: AsyncSession, bulk_model: BulkUser) -> tuple[l | |
| """ | ||
| final_filter = _create_final_filter(bulk_model) | ||
|
|
||
| count_effctive_users = ( | ||
| await db.execute(select(func.count(User.id)).where(and_(final_filter, User.expire.isnot(None)))) | ||
| ).scalar_one_or_none() or 0 | ||
| # Get database-specific datetime addition expression | ||
| new_expire = get_datetime_add_expression(db, User.expire, bulk_model.amount) | ||
| current_time = dt.now(UTC) | ||
| count_effctive_users = (await db.execute(select(func.count(User.id)).where(final_filter))).scalar_one_or_none() or 0 | ||
| expire_base = func.coalesce(User.expire, current_time) | ||
| new_expire = get_datetime_add_expression(db, expire_base, bulk_model.amount) | ||
|
|
||
| # First, get the users that will have status changes BEFORE updating | ||
| status_change_conditions = or_( | ||
|
|
@@ -327,9 +339,7 @@ async def update_users_expire(db: AsyncSession, bulk_model: BulkUser) -> tuple[l | |
| ) | ||
|
|
||
| # Get IDs of users whose status will change | ||
| result = await db.execute( | ||
| select(User.id).where(and_(final_filter, User.expire.isnot(None), status_change_conditions)) | ||
| ) | ||
| result = await db.execute(select(User.id).where(and_(final_filter, status_change_conditions))) | ||
| status_changed_user_ids = [row[0] for row in result.fetchall()] | ||
|
|
||
| # Perform the update | ||
|
|
@@ -339,9 +349,7 @@ async def update_users_expire(db: AsyncSession, bulk_model: BulkUser) -> tuple[l | |
| ] | ||
|
|
||
| await db.execute( | ||
| update(User) | ||
| .where(and_(final_filter, User.expire.isnot(None))) | ||
| .values(expire=new_expire, status=case(*status_cases, else_=User.status)) | ||
| update(User).where(final_filter).values(expire=new_expire, status=case(*status_cases, else_=User.status)) | ||
|
Comment on lines
330
to
+352
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Exclude The update now runs Proposed fix- final_filter = _create_final_filter(bulk_model)
+ final_filter = and_(_create_final_filter(bulk_model), User.status != UserStatus.on_hold)🤖 Prompt for AI Agents |
||
| ) | ||
| await db.commit() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: PasarGuard/panel
Length of output: 618
Use the locked
grpcio-toolsdependency for regeneration.The Docker build installs an unpinned
grpcio-toolswith.venv/bin/pip. This can select a version that differs from the locked development dependency and can leave a mismatchedprotobufruntime after uninstallinggrpcio-tools. Useuv pipwith the locked version, or otherwise install from the lock resolution, in bothDockerfileandDockerfile.ci.🐛 Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents