Skip to content

Prevent password leakage in login reports - #757

Closed
Rerowros wants to merge 1 commit into
PasarGuard:devfrom
Rerowros:codex/credential-safe-login-reporting
Closed

Prevent password leakage in login reports#757
Rerowros wants to merge 1 commit into
PasarGuard:devfrom
Rerowros:codex/credential-safe-login-reporting

Conversation

@Rerowros

@Rerowros Rerowros commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • keep the submitted password at the authentication boundary only
  • remove it from the admin-login notification contract and Telegram/Discord message rendering
  • cover successful, failed, and disabled database-admin login paths

Validation

  • pytest -q tests/test_admin_login_notifications.py (4 passed)
  • targeted Ruff check and formatting check

The existing API harness currently fails before these tests at its test-admin bootstrap (401/503); the focused tests exercise the affected notification boundary directly.

Summary by CodeRabbit

  • Security Improvements

    • Login notifications no longer include submitted passwords.
    • Discord and Telegram alerts show only the username, IP address, and outcome.
    • Group assignments now enforce administrator access restrictions.
  • New Features

    • Added dynamic variables for SNI and subscription announcement URLs.
    • Improved multi-worker node coordination and shared node lifecycle handling.
  • Bug Fixes

    • WireGuard core creation now avoids unnecessary user peer allocation.
  • Tests

    • Added coverage for notification safety, access controls, dynamic URLs, and multi-worker behavior.

Copilot AI lite review requested due to automatic review settings August 9, 2026 02:10
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The pull request adds multi-worker NATS coordination, shared node lifecycle state, administrator-aware group validation, deferred WireGuard allocation, dynamic subscription variables, dashboard updates, safer login notifications, and workflow maintenance.

Changes

Multi-worker NATS coordination

Layer / File(s) Summary
NATS foundation and leader election
app/nats/*, config.py, .env.example
Adds CAS KV helpers, role-scoped leader election, multi-worker detection, router gating, and NATS bucket settings.
Shared node memory and synchronization
app/node/*
Adds shared user synchronization, lifecycle leases, bridge-memory management, and cross-worker node events.
Worker lifecycle integration
app/app_factory.py, app/jobs/*, app/operation/node.py, app/core/*, role.py
Coordinates scheduler leadership, health checks, node startup, shutdown, deprecated roles, and multi-worker mode.
Coordination validation
tests/test_*nats*, tests/test_node_manager_sync.py, tests/test_connect_concurrency.py, .github/workflows/test-database-migrations.yml
Tests leases, shared memory, synchronization, NATS guards, and bounded node connection concurrency.

Safe login notifications

Layer / File(s) Summary
Password-free notification contracts
app/notification/*, app/routers/admin.py
Login notifications now use only username, client IP, and success status.
Password handling validation
tests/test_admin_login_notifications.py
Tests confirm that password validation remains active and notification payloads exclude submitted passwords.

Access and WireGuard allocation

Layer / File(s) Summary
Administrator-aware group validation
app/operation/*, app/routers/group.py
User, template, and bulk group operations enforce administrator group scope.
WireGuard pool initialization
app/db/crud/wireguard.py, app/operation/core.py, tests/api/test_core.py
Core creation initializes pools without peer allocation, while reconciliation uses eligible users and bounded batches.

Subscription and dashboard updates

Layer / File(s) Summary
Dynamic subscription and SNI formatting
app/operation/subscription.py, app/subscription/share.py, dashboard/src/features/subscriptions/*, tests/api/test_user.py
Announcement URLs and SNI values support dynamic variables.
Dashboard variable controls and actions
dashboard/src/components/ui/*, dashboard/src/features/hosts/*, dashboard/src/features/users/*, dashboard/src/pages/*, dashboard/public/statics/locales/*
Shares variable lookup logic, adds SNI controls, removes subscription variables from general settings, and adjusts mobile actions.

Workflow and dependency maintenance

Layer / File(s) Summary
CI and dependency updates
.github/workflows/*, pyproject.toml
Updates workflow actions and minimum dependency versions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: Backend, refactor

Poem

A rabbit guides the workers bright,
NATS keeps their state in sight.
Passwords stay outside the trail,
Groups and nodes follow the scale.
Fresh variables hop in tune.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main security change: preventing password exposure in login reports.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR removes submitted admin passwords from the admin-login notification contract and downstream Telegram/Discord renderers, ensuring passwords remain confined to the authentication boundary while preserving login-attempt reporting and adding targeted test coverage for success/failure/disabled flows.

Changes:

  • Updated notification.admin_login and all call sites to drop the password parameter (report only username, client IP, and success/failure).
  • Removed password rendering from Telegram and Discord admin-login message templates and formatting.
  • Added focused async tests to assert the submitted password is never forwarded to notification/reporting or included in rendered payloads.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/test_admin_login_notifications.py Adds tests covering successful, failed, and disabled admin login attempts to ensure submitted passwords never reach notifications/renderers.
app/routers/admin.py Stops passing the submitted password into notification.admin_login when reporting admin login attempts.
app/notification/telegram/messages.py Removes the password line from the Telegram admin login message template.
app/notification/telegram/admin.py Updates Telegram admin_login signature/formatting to exclude password handling.
app/notification/discord/messages.py Removes password from the Discord admin login embed description template.
app/notification/discord/admin.py Updates Discord admin_login signature/formatting to exclude password handling.
app/notification/init.py Updates the notification boundary admin_login function signature and dispatch to downstream notifiers without password.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Rerowros
Rerowros force-pushed the codex/credential-safe-login-reporting branch from d3d8ca4 to 88669bf Compare August 9, 2026 03:36
@Rerowros
Rerowros changed the base branch from main to dev August 9, 2026 03:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (5)
app/app_factory.py (1)

80-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Complete the truncated comment.

The comment ends with a semicolon and no clause. State why non-node router roles register the ignore handler for MessageTopic.NODE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/app_factory.py` around lines 80 - 82, Complete the comment above the
MessageTopic.NODE registration in the enable_router branch to explain that
non-node router roles must ignore worker_sync messages intended for node
routers. Keep the existing router.register_handler call and behavior unchanged.
.github/workflows/test-database-migrations.yml (1)

16-16: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add an explicit permissions block.

The workflow declares no permissions, so every job inherits the repository default token scope. These jobs only check out code and run tests. Restrict the token to read access at the workflow level.

🔒 Proposed least-privilege token scope
+permissions:
+    contents: read
+
 jobs:
     test-sqlite:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test-database-migrations.yml at line 16, Add a
workflow-level permissions block alongside jobs in the test-database-migrations
workflow, granting only read access needed for checkout and tests (contents:
read). Ensure all jobs inherit this least-privilege token scope.

Source: Linters/SAST tools

app/nats/leader.py (1)

112-170: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider refreshing the timestamp before the steal write.

now is captured at Line 127, before the kv.create and kv.get round trips. The steal payload at Line 128 therefore encodes now + lease_seconds, so the acquired lease is shorter than lease_seconds by the elapsed round-trip time. The direction is safe, but recomputing the expiry just before kv.update keeps the lease duration accurate.

♻️ Proposed refresh of the lease expiry on the steal path
     info = _parse(entry.value)
     if info is None or info[1] <= now:
         try:
-            await kv.update(leader_key(), payload, last=entry.revision)
+            await kv.update(leader_key(), _payload(token, time.time() + lease_seconds), last=entry.revision)
             _token = token
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/nats/leader.py` around lines 112 - 170, Refresh the current timestamp
immediately before the expired-leader `kv.update` in `try_become_leader`, and
build the steal payload with that timestamp plus `lease_seconds`. Keep the
initial `now` and payload for the create path, while ensuring the steal write
uses the refreshed expiry.
app/node/__init__.py (1)

76-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Keep a strong reference to the shutdown task.

asyncio.create_task returns a task that the event loop holds only weakly. If no reference is kept, the garbage collector can destroy the task before _shutdown_node completes, and the node is never set to Health.INVALID or stopped. handle_node_message in app/node/manager_sync.py awaits remove_node and then immediately calls clear_bridge_memory_for_node, so the shutdown is already concurrent with cleanup.

♻️ Proposed change
+_background_tasks: set[asyncio.Task] = set()
+
     async def remove_node(self, id: int, *, remote_stop: bool = True) -> None:
         async with self._lock.writer_lock:
             old_node: PasarGuardNode | None = self._nodes.pop(id, None)
             self._user_sync_locks.pop(id, None)
 
         # Do cleanup without holding the lock to avoid slow delete operations.
-        asyncio.create_task(self._shutdown_node(old_node, remote_stop=remote_stop))
+        task = asyncio.create_task(self._shutdown_node(old_node, remote_stop=remote_stop))
+        _background_tasks.add(task)
+        task.add_done_callback(_background_tasks.discard)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/node/__init__.py` around lines 76 - 82, Update remove_node to retain a
strong reference to the asyncio task created for _shutdown_node, using the node
manager’s existing task-tracking mechanism or an equivalent collection. Ensure
the reference is removed when the shutdown task completes, while preserving the
current lock-free asynchronous cleanup and remote_stop behavior.
app/jobs/record_usages.py (1)

769-773: 🚀 Performance & Scalability | 🔵 Trivial

Consider keeping a signal for job duration at INFO level.

These two lines were the only INFO-level confirmation that usage recording completed and how long it took. At the default log level operators now see failures (logger.exception at Lines 777 and 876) but no successful-run signal, so a silently stalled job looks the same as a healthy one.

The demotion is reasonable for multi-worker noise. If you keep it, export the job duration and record counts as metrics, or log at INFO only when job_duration exceeds a threshold.

Also applies to: 869-872

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/jobs/record_usages.py` around lines 769 - 773, The completion log in the
usage-recording job should retain an operator-visible health signal. Update the
logging around the completion messages in the job’s main and alternate paths to
emit INFO for slow runs based on an appropriate duration threshold, or expose
job duration and record counts through metrics while keeping DEBUG for routine
runs; preserve the existing completion details and avoid leaving successful runs
observable only at DEBUG.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/app_factory.py`:
- Around line 124-138: Update _reclaim_leadership_loop to catch exceptions
raised by start_job_leader(), log the failure, and continue the loop so
leadership acquisition is retried after the next heartbeat. Preserve the
existing success path that resumes jobs and returns, and use the module’s
established logging mechanism.

In `@app/db/crud/wireguard.py`:
- Around line 32-33: Refactor the reconciliation flow around
RECONCILE_USER_CHUNK to process users in keyset-ordered pages, deriving
accessible tags and related WireGuard data only for the current page and
applying it before fetching the next page. Avoid materializing all matching tag
rows, peer-IP user IDs, proxy settings, desired allocations, or changed user
IDs; retain only the global allocation state required across pages, and update
the related logic at the indicated reconciliation sections consistently.

In `@app/jobs/node_checker.py`:
- Around line 315-336: Align shutdown registration with the execution guard by
updating the condition around shutdown_nodes in the startup/shutdown
registration flow to skip it only when is_multi_worker() and
server_settings.workers > 1. Preserve the existing
feature_settings.stop_nodes_on_shutdown check and leave shutdown_nodes’ guard
unchanged, so workers > 1 with NATS disabled still register and execute remote
node stopping.

In `@app/node/manager_sync.py`:
- Around line 46-73: Keep all uses of db_node within the corresponding GetDB
async session: move node_manager.update_node(db_node) into the upsert session
block, and perform the connect branch’s update_node and
NodeOperation.connect_node calls before exiting its session while preserving the
existing lookup, status filtering, core/user map retrieval, and exception
handling.

In `@app/node/nats_memory.py`:
- Around line 436-444: Update shutdown_bridge_memory to acquire _init_lock while
closing and clearing all bridge globals, suppressing any exception from
_nc.close() so shutdown continues. Add a shutdown-state flag and have
ensure_bridge_memory check it while holding _init_lock, rejecting late
initialization after shutdown has begun; ensure the flag is set atomically with
shutdown and preserved across the cleanup.

In `@app/operation/node.py`:
- Around line 318-332: Update the 409 handling in the node connection flow
around NodeOperation._attach_if_running so every failed attach returns None
instead of falling through to the error-status result. Preserve the existing
successful attach response and the e.code == -4 behavior, ensuring
_connect_single_node_local and _connect_nodes_bulk_local skip database error
updates and notifications when another worker owns the lifecycle lease.

In `@app/subscription/share.py`:
- Line 312: Update the SNI formatting logic in the subscription generation flow
to catch ValueError and KeyError from sni.format_map(format_variables), using
the same fallback behavior as subscription metadata formatting. Ensure malformed
templates and undefined variables cannot abort subscription generation.

In `@pyproject.toml`:
- Around line 40-41: Update the pasarguard-node-bridge dependency declaration to
use a PyPI-published version that provides the required storage and lifecycle
APIs, including PasarGuardNodeBridge.storage ClaimedUser, LifecycleLease,
LifecycleOperation, LifecycleStatus, NodeLifecycleState, and create_node with
its expected parameters; alternatively, configure an explicit local or private
source guaranteeing those APIs, and regenerate uv.lock accordingly.

In `@tests/api/test_core.py`:
- Around line 62-69: Update the test setup around create_group and create_user
so the user is created without passing starter_group["id"] in group_ids. Keep
starter_group creation for the WireGuard group used later in the test, and
preserve the existing assertion flow.

In `@tests/test_connect_concurrency.py`:
- Around line 40-46: Update the test after _connect_nodes_bulk_local returns to
yield control to the event loop once, allowing scheduled
notification.connect_node tasks to run or drain before assertions. Keep the
existing peak concurrency assertions unchanged.

In `@tests/test_nats_leader_steal.py`:
- Around line 24-25: Add an autouse pytest fixture in the test module that
snapshots leader._is_leader and leader._token before each test and restores both
values in teardown. Apply it to both tests, including the later assignment
block, so try_become_leader and direct mutations cannot leak module state into
other tests.

---

Nitpick comments:
In @.github/workflows/test-database-migrations.yml:
- Line 16: Add a workflow-level permissions block alongside jobs in the
test-database-migrations workflow, granting only read access needed for checkout
and tests (contents: read). Ensure all jobs inherit this least-privilege token
scope.

In `@app/app_factory.py`:
- Around line 80-82: Complete the comment above the MessageTopic.NODE
registration in the enable_router branch to explain that non-node router roles
must ignore worker_sync messages intended for node routers. Keep the existing
router.register_handler call and behavior unchanged.

In `@app/jobs/record_usages.py`:
- Around line 769-773: The completion log in the usage-recording job should
retain an operator-visible health signal. Update the logging around the
completion messages in the job’s main and alternate paths to emit INFO for slow
runs based on an appropriate duration threshold, or expose job duration and
record counts through metrics while keeping DEBUG for routine runs; preserve the
existing completion details and avoid leaving successful runs observable only at
DEBUG.

In `@app/nats/leader.py`:
- Around line 112-170: Refresh the current timestamp immediately before the
expired-leader `kv.update` in `try_become_leader`, and build the steal payload
with that timestamp plus `lease_seconds`. Keep the initial `now` and payload for
the create path, while ensuring the steal write uses the refreshed expiry.

In `@app/node/__init__.py`:
- Around line 76-82: Update remove_node to retain a strong reference to the
asyncio task created for _shutdown_node, using the node manager’s existing
task-tracking mechanism or an equivalent collection. Ensure the reference is
removed when the shutdown task completes, while preserving the current lock-free
asynchronous cleanup and remote_stop behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 686a87e0-9472-4c91-bcdf-b3988b4c6fe5

📥 Commits

Reviewing files that changed from the base of the PR and between d3d8ca4 and 88669bf.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (51)
  • .env.example
  • .github/workflows/api-codeql.yml
  • .github/workflows/build-dev.yml
  • .github/workflows/build.yml
  • .github/workflows/frontend-codeql.yml
  • .github/workflows/test-database-migrations.yml
  • app/app_factory.py
  • app/core/hosts.py
  • app/core/manager.py
  • app/db/crud/wireguard.py
  • app/jobs/node_checker.py
  • app/jobs/record_usages.py
  • app/nats/__init__.py
  • app/nats/kv_cas.py
  • app/nats/leader.py
  • app/nats/message.py
  • app/nats/router.py
  • app/node/__init__.py
  • app/node/manager_sync.py
  • app/node/nats_memory.py
  • app/operation/__init__.py
  • app/operation/core.py
  • app/operation/group.py
  • app/operation/node.py
  • app/operation/subscription.py
  • app/operation/user.py
  • app/operation/user_template.py
  • app/routers/group.py
  • app/subscription/share.py
  • config.py
  • dashboard/public/statics/locales/en.json
  • dashboard/public/statics/locales/fa.json
  • dashboard/public/statics/locales/ru.json
  • dashboard/public/statics/locales/zh.json
  • dashboard/src/components/ui/variables-popover.tsx
  • dashboard/src/features/hosts/dialogs/host-modal.tsx
  • dashboard/src/features/subscriptions/components/subscription-general-settings-section.tsx
  • dashboard/src/features/subscriptions/components/subscription-settings-schema.ts
  • dashboard/src/features/users/components/action-buttons.tsx
  • dashboard/src/pages/_dashboard.settings.general.tsx
  • pyproject.toml
  • role.py
  • tests/api/test_core.py
  • tests/api/test_user.py
  • tests/test_connect_concurrency.py
  • tests/test_create_app_nats_guard.py
  • tests/test_group_access_unit.py
  • tests/test_nats_leader_heartbeat.py
  • tests/test_nats_leader_steal.py
  • tests/test_nats_node_memory.py
  • tests/test_node_manager_sync.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 11

🧹 Nitpick comments (5)
app/app_factory.py (1)

80-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Complete the truncated comment.

The comment ends with a semicolon and no clause. State why non-node router roles register the ignore handler for MessageTopic.NODE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/app_factory.py` around lines 80 - 82, Complete the comment above the
MessageTopic.NODE registration in the enable_router branch to explain that
non-node router roles must ignore worker_sync messages intended for node
routers. Keep the existing router.register_handler call and behavior unchanged.
.github/workflows/test-database-migrations.yml (1)

16-16: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add an explicit permissions block.

The workflow declares no permissions, so every job inherits the repository default token scope. These jobs only check out code and run tests. Restrict the token to read access at the workflow level.

🔒 Proposed least-privilege token scope
+permissions:
+    contents: read
+
 jobs:
     test-sqlite:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test-database-migrations.yml at line 16, Add a
workflow-level permissions block alongside jobs in the test-database-migrations
workflow, granting only read access needed for checkout and tests (contents:
read). Ensure all jobs inherit this least-privilege token scope.

Source: Linters/SAST tools

app/nats/leader.py (1)

112-170: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider refreshing the timestamp before the steal write.

now is captured at Line 127, before the kv.create and kv.get round trips. The steal payload at Line 128 therefore encodes now + lease_seconds, so the acquired lease is shorter than lease_seconds by the elapsed round-trip time. The direction is safe, but recomputing the expiry just before kv.update keeps the lease duration accurate.

♻️ Proposed refresh of the lease expiry on the steal path
     info = _parse(entry.value)
     if info is None or info[1] <= now:
         try:
-            await kv.update(leader_key(), payload, last=entry.revision)
+            await kv.update(leader_key(), _payload(token, time.time() + lease_seconds), last=entry.revision)
             _token = token
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/nats/leader.py` around lines 112 - 170, Refresh the current timestamp
immediately before the expired-leader `kv.update` in `try_become_leader`, and
build the steal payload with that timestamp plus `lease_seconds`. Keep the
initial `now` and payload for the create path, while ensuring the steal write
uses the refreshed expiry.
app/node/__init__.py (1)

76-82: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Keep a strong reference to the shutdown task.

asyncio.create_task returns a task that the event loop holds only weakly. If no reference is kept, the garbage collector can destroy the task before _shutdown_node completes, and the node is never set to Health.INVALID or stopped. handle_node_message in app/node/manager_sync.py awaits remove_node and then immediately calls clear_bridge_memory_for_node, so the shutdown is already concurrent with cleanup.

♻️ Proposed change
+_background_tasks: set[asyncio.Task] = set()
+
     async def remove_node(self, id: int, *, remote_stop: bool = True) -> None:
         async with self._lock.writer_lock:
             old_node: PasarGuardNode | None = self._nodes.pop(id, None)
             self._user_sync_locks.pop(id, None)
 
         # Do cleanup without holding the lock to avoid slow delete operations.
-        asyncio.create_task(self._shutdown_node(old_node, remote_stop=remote_stop))
+        task = asyncio.create_task(self._shutdown_node(old_node, remote_stop=remote_stop))
+        _background_tasks.add(task)
+        task.add_done_callback(_background_tasks.discard)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/node/__init__.py` around lines 76 - 82, Update remove_node to retain a
strong reference to the asyncio task created for _shutdown_node, using the node
manager’s existing task-tracking mechanism or an equivalent collection. Ensure
the reference is removed when the shutdown task completes, while preserving the
current lock-free asynchronous cleanup and remote_stop behavior.
app/jobs/record_usages.py (1)

769-773: 🚀 Performance & Scalability | 🔵 Trivial

Consider keeping a signal for job duration at INFO level.

These two lines were the only INFO-level confirmation that usage recording completed and how long it took. At the default log level operators now see failures (logger.exception at Lines 777 and 876) but no successful-run signal, so a silently stalled job looks the same as a healthy one.

The demotion is reasonable for multi-worker noise. If you keep it, export the job duration and record counts as metrics, or log at INFO only when job_duration exceeds a threshold.

Also applies to: 869-872

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/jobs/record_usages.py` around lines 769 - 773, The completion log in the
usage-recording job should retain an operator-visible health signal. Update the
logging around the completion messages in the job’s main and alternate paths to
emit INFO for slow runs based on an appropriate duration threshold, or expose
job duration and record counts through metrics while keeping DEBUG for routine
runs; preserve the existing completion details and avoid leaving successful runs
observable only at DEBUG.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/app_factory.py`:
- Around line 124-138: Update _reclaim_leadership_loop to catch exceptions
raised by start_job_leader(), log the failure, and continue the loop so
leadership acquisition is retried after the next heartbeat. Preserve the
existing success path that resumes jobs and returns, and use the module’s
established logging mechanism.

In `@app/db/crud/wireguard.py`:
- Around line 32-33: Refactor the reconciliation flow around
RECONCILE_USER_CHUNK to process users in keyset-ordered pages, deriving
accessible tags and related WireGuard data only for the current page and
applying it before fetching the next page. Avoid materializing all matching tag
rows, peer-IP user IDs, proxy settings, desired allocations, or changed user
IDs; retain only the global allocation state required across pages, and update
the related logic at the indicated reconciliation sections consistently.

In `@app/jobs/node_checker.py`:
- Around line 315-336: Align shutdown registration with the execution guard by
updating the condition around shutdown_nodes in the startup/shutdown
registration flow to skip it only when is_multi_worker() and
server_settings.workers > 1. Preserve the existing
feature_settings.stop_nodes_on_shutdown check and leave shutdown_nodes’ guard
unchanged, so workers > 1 with NATS disabled still register and execute remote
node stopping.

In `@app/node/manager_sync.py`:
- Around line 46-73: Keep all uses of db_node within the corresponding GetDB
async session: move node_manager.update_node(db_node) into the upsert session
block, and perform the connect branch’s update_node and
NodeOperation.connect_node calls before exiting its session while preserving the
existing lookup, status filtering, core/user map retrieval, and exception
handling.

In `@app/node/nats_memory.py`:
- Around line 436-444: Update shutdown_bridge_memory to acquire _init_lock while
closing and clearing all bridge globals, suppressing any exception from
_nc.close() so shutdown continues. Add a shutdown-state flag and have
ensure_bridge_memory check it while holding _init_lock, rejecting late
initialization after shutdown has begun; ensure the flag is set atomically with
shutdown and preserved across the cleanup.

In `@app/operation/node.py`:
- Around line 318-332: Update the 409 handling in the node connection flow
around NodeOperation._attach_if_running so every failed attach returns None
instead of falling through to the error-status result. Preserve the existing
successful attach response and the e.code == -4 behavior, ensuring
_connect_single_node_local and _connect_nodes_bulk_local skip database error
updates and notifications when another worker owns the lifecycle lease.

In `@app/subscription/share.py`:
- Line 312: Update the SNI formatting logic in the subscription generation flow
to catch ValueError and KeyError from sni.format_map(format_variables), using
the same fallback behavior as subscription metadata formatting. Ensure malformed
templates and undefined variables cannot abort subscription generation.

In `@pyproject.toml`:
- Around line 40-41: Update the pasarguard-node-bridge dependency declaration to
use a PyPI-published version that provides the required storage and lifecycle
APIs, including PasarGuardNodeBridge.storage ClaimedUser, LifecycleLease,
LifecycleOperation, LifecycleStatus, NodeLifecycleState, and create_node with
its expected parameters; alternatively, configure an explicit local or private
source guaranteeing those APIs, and regenerate uv.lock accordingly.

In `@tests/api/test_core.py`:
- Around line 62-69: Update the test setup around create_group and create_user
so the user is created without passing starter_group["id"] in group_ids. Keep
starter_group creation for the WireGuard group used later in the test, and
preserve the existing assertion flow.

In `@tests/test_connect_concurrency.py`:
- Around line 40-46: Update the test after _connect_nodes_bulk_local returns to
yield control to the event loop once, allowing scheduled
notification.connect_node tasks to run or drain before assertions. Keep the
existing peak concurrency assertions unchanged.

In `@tests/test_nats_leader_steal.py`:
- Around line 24-25: Add an autouse pytest fixture in the test module that
snapshots leader._is_leader and leader._token before each test and restores both
values in teardown. Apply it to both tests, including the later assignment
block, so try_become_leader and direct mutations cannot leak module state into
other tests.

---

Nitpick comments:
In @.github/workflows/test-database-migrations.yml:
- Line 16: Add a workflow-level permissions block alongside jobs in the
test-database-migrations workflow, granting only read access needed for checkout
and tests (contents: read). Ensure all jobs inherit this least-privilege token
scope.

In `@app/app_factory.py`:
- Around line 80-82: Complete the comment above the MessageTopic.NODE
registration in the enable_router branch to explain that non-node router roles
must ignore worker_sync messages intended for node routers. Keep the existing
router.register_handler call and behavior unchanged.

In `@app/jobs/record_usages.py`:
- Around line 769-773: The completion log in the usage-recording job should
retain an operator-visible health signal. Update the logging around the
completion messages in the job’s main and alternate paths to emit INFO for slow
runs based on an appropriate duration threshold, or expose job duration and
record counts through metrics while keeping DEBUG for routine runs; preserve the
existing completion details and avoid leaving successful runs observable only at
DEBUG.

In `@app/nats/leader.py`:
- Around line 112-170: Refresh the current timestamp immediately before the
expired-leader `kv.update` in `try_become_leader`, and build the steal payload
with that timestamp plus `lease_seconds`. Keep the initial `now` and payload for
the create path, while ensuring the steal write uses the refreshed expiry.

In `@app/node/__init__.py`:
- Around line 76-82: Update remove_node to retain a strong reference to the
asyncio task created for _shutdown_node, using the node manager’s existing
task-tracking mechanism or an equivalent collection. Ensure the reference is
removed when the shutdown task completes, while preserving the current lock-free
asynchronous cleanup and remote_stop behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 686a87e0-9472-4c91-bcdf-b3988b4c6fe5

📥 Commits

Reviewing files that changed from the base of the PR and between d3d8ca4 and 88669bf.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (51)
  • .env.example
  • .github/workflows/api-codeql.yml
  • .github/workflows/build-dev.yml
  • .github/workflows/build.yml
  • .github/workflows/frontend-codeql.yml
  • .github/workflows/test-database-migrations.yml
  • app/app_factory.py
  • app/core/hosts.py
  • app/core/manager.py
  • app/db/crud/wireguard.py
  • app/jobs/node_checker.py
  • app/jobs/record_usages.py
  • app/nats/__init__.py
  • app/nats/kv_cas.py
  • app/nats/leader.py
  • app/nats/message.py
  • app/nats/router.py
  • app/node/__init__.py
  • app/node/manager_sync.py
  • app/node/nats_memory.py
  • app/operation/__init__.py
  • app/operation/core.py
  • app/operation/group.py
  • app/operation/node.py
  • app/operation/subscription.py
  • app/operation/user.py
  • app/operation/user_template.py
  • app/routers/group.py
  • app/subscription/share.py
  • config.py
  • dashboard/public/statics/locales/en.json
  • dashboard/public/statics/locales/fa.json
  • dashboard/public/statics/locales/ru.json
  • dashboard/public/statics/locales/zh.json
  • dashboard/src/components/ui/variables-popover.tsx
  • dashboard/src/features/hosts/dialogs/host-modal.tsx
  • dashboard/src/features/subscriptions/components/subscription-general-settings-section.tsx
  • dashboard/src/features/subscriptions/components/subscription-settings-schema.ts
  • dashboard/src/features/users/components/action-buttons.tsx
  • dashboard/src/pages/_dashboard.settings.general.tsx
  • pyproject.toml
  • role.py
  • tests/api/test_core.py
  • tests/api/test_user.py
  • tests/test_connect_concurrency.py
  • tests/test_create_app_nats_guard.py
  • tests/test_group_access_unit.py
  • tests/test_nats_leader_heartbeat.py
  • tests/test_nats_leader_steal.py
  • tests/test_nats_node_memory.py
  • tests/test_node_manager_sync.py
🛑 Comments failed to post (11)
app/app_factory.py (1)

124-138: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the reclaim loop against exceptions.

_reclaim_leadership_loop calls start_job_leader() without exception handling. start_job_leader reaches _ensure_kv, which awaits create_nats_client() and get_or_create_kv_bucket. If NATS is unreachable at that moment, the call can raise. The exception then terminates the background task silently, because nothing awaits it before shutdown. After that the worker never retries leadership, so the scheduler stays paused and the notification dispatcher stays stopped for the process lifetime.

Wrap the acquisition attempt in a try/except and log the failure so the loop keeps retrying.

🛡️ Proposed fix to keep the reclaim loop alive
     async def _reclaim_leadership_loop():
         # start_job_leader = try_become_leader + heartbeat restart
         while True:
             await asyncio.sleep(HEARTBEAT_INTERVAL)
             if is_job_leader():
                 return
-            if await start_job_leader():
-                await _resume_jobs_on_leadership_gained()
-                return
+            try:
+                won = await start_job_leader()
+            except Exception as exc:
+                logger.warning("Leadership reclaim attempt failed: %s", exc)
+                continue
+            if won:
+                await _resume_jobs_on_leadership_gained()
+                return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    async def _reclaim_leadership_loop():
        # start_job_leader = try_become_leader + heartbeat restart
        while True:
            await asyncio.sleep(HEARTBEAT_INTERVAL)
            if is_job_leader():
                return
            try:
                won = await start_job_leader()
            except Exception as exc:
                logger.warning("Leadership reclaim attempt failed: %s", exc)
                continue
            if won:
                await _resume_jobs_on_leadership_gained()
                return

    def _ensure_reclaim_task():
        task = reclaim_task["task"]
        if task is not None and not task.done():
            return
        reclaim_task["task"] = asyncio.create_task(_reclaim_leadership_loop())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/app_factory.py` around lines 124 - 138, Update _reclaim_leadership_loop
to catch exceptions raised by start_job_leader(), log the failure, and continue
the loop so leadership acquisition is retried after the next heartbeat. Preserve
the existing success path that resumes jobs and returns, and use the module’s
established logging mechanism.
app/db/crud/wireguard.py (1)

32-33: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Make reconciliation memory-bounded.

RECONCILE_USER_CHUNK only limits each IN query. The code still materializes every matching tag row, peer-IP user ID, proxy setting, desired allocation, and changed user ID.

A large deployment where most users have WireGuard access can still exhaust worker memory. Use keyset pages for users, derive accessible tags per page, and apply each page before loading the next page. Preserve only the global allocation state that reconciliation requires.

Also applies to: 320-362, 541-546, 597-603

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/db/crud/wireguard.py` around lines 32 - 33, Refactor the reconciliation
flow around RECONCILE_USER_CHUNK to process users in keyset-ordered pages,
deriving accessible tags and related WireGuard data only for the current page
and applying it before fetching the next page. Avoid materializing all matching
tag rows, peer-IP user IDs, proxy settings, desired allocations, or changed user
IDs; retain only the global allocation state required across pages, and update
the related logic at the indicated reconciliation sections consistently.
app/jobs/node_checker.py (1)

315-336: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align the two multi-worker conditions for remote node stop.

Line 316 gates registration on server_settings.workers <= 1. Line 334 gates execution on is_multi_worker() and server_settings.workers > 1. The two conditions disagree when workers > 1 and NATS is disabled.

In that configuration is_multi_worker() is false, so there is no shared lifecycle coordination and no other worker owns the remote core. The execution guard at Line 334 would allow the stop. But registration at Line 316 already skipped shutdown_nodes, so remote cores keep running after shutdown even though feature_settings.stop_nodes_on_shutdown is enabled.

Gate registration on the same predicate that the function uses.

🐛 Proposed fix
     # Multi-uvicorn workers must not Stop remote cores / clear shared sync queues on exit.
-    if feature_settings.stop_nodes_on_shutdown and server_settings.workers <= 1:
+    if feature_settings.stop_nodes_on_shutdown and not (is_multi_worker() and server_settings.workers > 1):
         on_shutdown(shutdown_nodes)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    # Multi-uvicorn workers must not Stop remote cores / clear shared sync queues on exit.
    if feature_settings.stop_nodes_on_shutdown and not (is_multi_worker() and server_settings.workers > 1):
        on_shutdown(shutdown_nodes)

    on_shutdown(_stop_node_loops)
    on_shutdown(shutdown_bridge_memory)


async def _stop_node_loops():
    for task in _node_loop_tasks:
        task.cancel()
    if _node_loop_tasks:
        await asyncio.gather(*_node_loop_tasks, return_exceptions=True)
    _node_loop_tasks.clear()


async def shutdown_nodes():
    if not runtime_settings.role.runs_node:
        return
    if is_multi_worker() and server_settings.workers > 1:
        logger.info("Skipping remote node stop on multi-worker shutdown")
        return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/jobs/node_checker.py` around lines 315 - 336, Align shutdown registration
with the execution guard by updating the condition around shutdown_nodes in the
startup/shutdown registration flow to skip it only when is_multi_worker() and
server_settings.workers > 1. Preserve the existing
feature_settings.stop_nodes_on_shutdown check and leave shutdown_nodes’ guard
unchanged, so workers > 1 with NATS disabled still register and execute remote
node stopping.
app/node/manager_sync.py (1)

46-73: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use db_node inside the session scope.

GetDB.__aexit__ closes the session (app/db/base.py Lines 46-65). Both the upsert branch and the connect branch use db_node after the async with GetDB() block ends, so the instance is detached at that point.

  • Line 51: node_manager.update_node(db_node) reads address, port, api_port, server_ca, api_key, name, default_timeout, internal_timeout, proxy_url, usage_coefficient, and id in _create_node_kwargs.
  • Lines 68 and 72: update_node and NodeOperation.connect_node read status, name, and keep_alive.

These column attributes are already loaded, so the code works today. Any future access to an unloaded or expired attribute, or to a lazy relationship such as core_config, raises MissingGreenlet on a closed async session. _connect_single_node_local in app/operation/node.py keeps the equivalent work inside the session. Match that pattern.

♻️ Proposed change
     if action == "upsert":
         async with GetDB() as db:
             db_node = await get_node_by_id(db, node_id, load_usage_logs=False)
-        if db_node is None:
-            return
-        await node_manager.update_node(db_node)
+            if db_node is None:
+                return
+            await node_manager.update_node(db_node)
         return
 
     if action == "connect":
         # Quiet attach/start on siblings — originator already wrote DB status / notifications.
         from app.operation.node import NodeOperation
 
         async with GetDB() as db:
             db_node = await get_node_by_id(db, node_id, load_usage_logs=False)
             if db_node is None or db_node.status in (NodeStatus.disabled, NodeStatus.limited):
                 return
             core_id = db_node.core_config_id or 1
             cores_by_id, users_by_core = await NodeOperation._get_core_users_map(db, {core_id})
             core = cores_by_id.get(core_id)
             users = users_by_core.get(core_id, [])
 
-        try:
-            await node_manager.update_node(db_node)
-        except Exception:
-            logger.exception("Node sync connect update_node failed for node_id=%s", node_id)
-            return
-        await NodeOperation.connect_node(db_node, core, users)
+            try:
+                await node_manager.update_node(db_node)
+            except Exception:
+                logger.exception("Node sync connect update_node failed for node_id=%s", node_id)
+                return
+            await NodeOperation.connect_node(db_node, core, users)
         return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if action == "upsert":
        async with GetDB() as db:
            db_node = await get_node_by_id(db, node_id, load_usage_logs=False)
            if db_node is None:
                return
            await node_manager.update_node(db_node)
        return

    if action == "connect":
        # Quiet attach/start on siblings — originator already wrote DB status / notifications.
        from app.operation.node import NodeOperation

        async with GetDB() as db:
            db_node = await get_node_by_id(db, node_id, load_usage_logs=False)
            if db_node is None or db_node.status in (NodeStatus.disabled, NodeStatus.limited):
                return
            core_id = db_node.core_config_id or 1
            cores_by_id, users_by_core = await NodeOperation._get_core_users_map(db, {core_id})
            core = cores_by_id.get(core_id)
            users = users_by_core.get(core_id, [])

            try:
                await node_manager.update_node(db_node)
            except Exception:
                logger.exception("Node sync connect update_node failed for node_id=%s", node_id)
                return
            await NodeOperation.connect_node(db_node, core, users)
        return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/node/manager_sync.py` around lines 46 - 73, Keep all uses of db_node
within the corresponding GetDB async session: move
node_manager.update_node(db_node) into the upsert session block, and perform the
connect branch’s update_node and NodeOperation.connect_node calls before exiting
its session while preserving the existing lookup, status filtering, core/user
map retrieval, and exception handling.
app/node/nats_memory.py (1)

436-444: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard shutdown with _init_lock and suppress close errors.

shutdown_bridge_memory mutates the same globals that ensure_bridge_memory writes, but it does not take _init_lock. Two problems follow.

  1. ensure_bridge_memory runs on every NodeManager.update_node call (app/node/__init__.py Line 61). If a node update races with shutdown, ensure_bridge_memory can create a new NATS client after shutdown_bridge_memory has already cleared the globals. That connection is then never closed.
  2. await _nc.close() is not wrapped. The failure path at Lines 418-420 uses contextlib.suppress(Exception) for the same call. If close() raises here, the shutdown hook propagates the exception and later hooks registered through on_shutdown may not run.

Add a shutdown flag so a late ensure_bridge_memory call does not re-initialize.

🔒️ Proposed fix
+_shutting_down = False
+
+
 async def shutdown_bridge_memory() -> None:
-    global _nc, _user_sync_kv, _lifecycle_kv, _user_sync_store, _lifecycle_coordinator
-    if _nc is not None:
-        await _nc.close()
-    _nc = None
-    _user_sync_kv = None
-    _lifecycle_kv = None
-    _user_sync_store = None
-    _lifecycle_coordinator = None
+    global _nc, _user_sync_kv, _lifecycle_kv, _user_sync_store, _lifecycle_coordinator, _shutting_down
+    async with _init_lock:
+        _shutting_down = True
+        if _nc is not None:
+            with contextlib.suppress(Exception):
+                await _nc.close()
+        _nc = None
+        _user_sync_kv = None
+        _lifecycle_kv = None
+        _user_sync_store = None
+        _lifecycle_coordinator = None

Then reject late initialization inside ensure_bridge_memory:

     async with _init_lock:
+        if _shutting_down:
+            return None, None
         if _user_sync_store is not None and _lifecycle_coordinator is not None:
             return _user_sync_store, _lifecycle_coordinator
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/node/nats_memory.py` around lines 436 - 444, Update
shutdown_bridge_memory to acquire _init_lock while closing and clearing all
bridge globals, suppressing any exception from _nc.close() so shutdown
continues. Add a shutdown-state flag and have ensure_bridge_memory check it
while holding _init_lock, rejecting late initialization after shutdown has
begun; ensure the flag is set atomically with shutdown and preserved across the
cleanup.
app/operation/node.py (1)

318-332: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not report an error status when another worker owns the lifecycle lease.

The 409 branch attaches once. If the attach fails, execution falls through to Line 334 and returns a result with NodeStatus.error. _connect_single_node_local and _connect_nodes_bulk_local then write error to the database and dispatch notification.error_node.

A 409 means another worker holds the lease and is starting the node. The attach can legitimately fail during that window, because pg_node.info() returns no versions until the remote core is listening. The losing worker then raises a false error alert for a node that is starting correctly. Returning None skips the status write and leaves the owner's result authoritative, which matches the existing e.code == -4 handling on Line 319.

🐛 Proposed fix
             if e.code == 409:
                 # Another worker holds the lifecycle lease; try attach once more.
                 attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
                 if attached is not None:
                     return {
                         "node_id": db_node.id,
                         "status": NodeStatus.connected,
                         "message": "",
                         "xray_version": attached.core_version,
                         "node_version": attached.node_version,
                         "old_status": old_status,
                     }
+                # The lease owner is still starting the node. Let it report the result.
+                logger.debug(f'Skipping status update for "{db_node.name}"; lease held by another worker')
+                return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        except NodeAPIError as e:
            if e.code == -4:
                return None
            if e.code == 409:
                # Another worker holds the lifecycle lease; try attach once more.
                attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
                if attached is not None:
                    return {
                        "node_id": db_node.id,
                        "status": NodeStatus.connected,
                        "message": "",
                        "xray_version": attached.core_version,
                        "node_version": attached.node_version,
                        "old_status": old_status,
                    }
                # The lease owner is still starting the node. Let it report the result.
                logger.debug(f'Skipping status update for "{db_node.name}"; lease held by another worker')
                return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/operation/node.py` around lines 318 - 332, Update the 409 handling in the
node connection flow around NodeOperation._attach_if_running so every failed
attach returns None instead of falling through to the error-status result.
Preserve the existing successful attach response and the e.code == -4 behavior,
ensuring _connect_single_node_local and _connect_nodes_bulk_local skip database
error updates and notifications when another worker owns the lifecycle lease.
app/subscription/share.py (1)

312-312: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle invalid SNI format strings.

Line 312 calls format_map() without error handling. A malformed configured SNI can raise ValueError and fail subscription generation. An undefined variable can also raise KeyError when the caller provides a normal mapping.

Catch these errors with the same fallback used by subscription metadata formatting, or reject invalid SNI templates when configuration is saved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/subscription/share.py` at line 312, Update the SNI formatting logic in
the subscription generation flow to catch ValueError and KeyError from
sni.format_map(format_variables), using the same fallback behavior as
subscription metadata formatting. Ensure malformed templates and undefined
variables cannot abort subscription generation.
pyproject.toml (1)

40-41: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check published versions and the bridge storage API surface.
set -euo pipefail
curl -s https://pypi.org/pypi/typer/json | jq -r '.releases | keys[]' | tail -5
curl -s https://pypi.org/pypi/pasarguard-node-bridge/json | jq -r '.info.version, (.releases | keys[])' | tail -10
pip install "pasarguard-node-bridge==0.9.0" >/dev/null 2>&1 || echo "install failed"
python - <<'PY'
import inspect
try:
    from PasarGuardNodeBridge import storage, create_node
except Exception as exc:
    print("import failed:", exc)
    raise SystemExit(0)
print([n for n in dir(storage) if not n.startswith("_")])
print(inspect.signature(create_node))
PY

Repository: PasarGuard/panel

Length of output: 313


🌐 Web query:

PyPI typer 0.27.1 Release Summary

💡 Result:

The Typer 0.27.1 release was published on August 3, 2026 [1][2][3]. This release focused on documentation improvements and minor formatting consistency [1][3]. Key changes in version 0.27.1 include: Features: - Formatting: The epilog in help strings was updated to be consistent with other parts of the help output [1][3]. Documentation: - Added new Library Skills documentation [1][3]. - Updated help documentation [1][3]. - Now recommends using uv projects by default [1][3]. - Included a fix to prevent the page from scrolling to the top when using restart or fast buttons in the documentation [1][3]. Internal: - Upgraded the latest-changes dependency to version 0.7.1 [1][3].

Citations:


🌐 Web query:

PyPI pasarguard-node-bridge 0.9.0

💡 Result:

The PyPI package pasarguard-node-bridge is an asynchronous Python client library designed for connecting to and interacting with a PasarGuard node via gRPC or REST [1][2]. As of August 9, 2026, the available documentation and history on PyPI reflect version 0.8.1 as a recent version [1][3]. There is no official record or release listing for a version 0.9.0 in the public package history [1]. Users should verify their requirements against the latest available version on the official PyPI project page [1]. The library requires Python 3.12 or higher and provides tools for node management, including: - Unified node API for gRPC and protobuf-over-HTTP transport types [3][4]. - User synchronization (single, batch, and chunked streaming) [3][4]. - Health, version, and log streaming helpers [3][4]. - Node maintenance endpoints [3][4]. The source code for this project is maintained on GitHub under the PasarGuard organization [2][4].

Citations:


🌐 Web query:

PasarGuardNodeBridge storage ClaimedUser LifecycleLease LifecycleOperation LifecycleStatus NodeLifecycleState create_node node_id user_sync_store lifecycle_coordinator worker_id

💡 Result:

The PasarGuardNodeBridge is an asynchronous Python client library used to connect and interact with PasarGuard nodes via gRPC or REST protocols [1][2][3]. The parameters mentioned relate to the configuration and management of node lifecycles and user synchronization, particularly in distributed environments [3][4]: - create_node: This is the primary factory method used to instantiate a node client [1][3]. It accepts connection details (address, port, api_port), authentication (API key, server CA), and optional identifiers like node_id and worker_id [1][3]. - user_sync_store: By default, the bridge uses a process-local, in-memory store to queue user updates [3][4]. For multi-process or multi-host deployments, you can provide a custom user_sync_store implementation (e.g., backed by Redis or SQL) that satisfies the UserSyncStoreProtocol, ensuring all workers claim from the same pending-user queue [3][4]. - lifecycle_coordinator: This component manages node operations such as start, stop, and updates (e.g., core, geofiles) [3][4]. Similar to the user sync store, the default is process-local; providing a shared lifecycle_coordinator in distributed setups ensures that only one worker can execute lifecycle operations for a specific node at any given time [3][4]. - node_id and worker_id: These are identifiers passed during node creation [3]. node_id distinguishes the specific node instance (or group of instances sharing a configuration), while worker_id identifies the specific process or host worker responsible for that node [3][4]. - Lifecycle States (ClaimedUser, LifecycleLease, Operation, LifecycleStatus, NodeLifecycleState): These represent the internal models and states used to coordinate asynchronous tasks across distributed workers, ensuring that operations like user syncing and node maintenance remain consistent and conflict-free [3][4]. In summary, these components enable the PasarGuard node bridge to scale beyond a single process by offloading state management and coordination to external backends [3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== PyPI package/release metadata =="
python3 - <<'PY'
import json
import urllib.request
for pkg in ["typer", "pasarguard-node-bridge", "PasarGuardNodeBridge"]:
    try:
        with urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/json", timeout=20) as r:
            data = json.load(r)
        info = data.get("info", {})
        releases = data.get("releases", {})
        print(pkg)
        print("  info_name=", info.get("name"))
        print("  latest_version=", info.get("version"))
        print("  release_count=", len(releases))
        target="0.27.1" if pkg=="typer" else "0.9.0"
        print(f"  has_{target}=", target in releases)
        if target in releases:
            for url in [u["url"] for u in releases[target]][:5]:
                print("    ", url)
    except Exception as exc:
        print(pkg, "ERROR:", repr(exc))
PY

echo
echo "== Repository references to bridge storage symbols/signature =="
rg -n "PasarGuardNodeBridge|ClaimedUser|LifecycleLease|LifecycleOperation|LifecycleStatus|NodeLifecycleState|create_node|user_sync_store|lifecycle_coordinator" -S . || true

Repository: PasarGuard/panel

Length of output: 11465


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate dependency files =="
git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|uv\.lock|poetry\.lock|Pipfile|Pipfile\.lock|setup\.py|setup\.cfg)$' || true

echo
echo "== pyproject relevant lines =="
if [ -f pyproject.toml ]; then
  sed -n '1,90p' pyproject.toml | cat -n
fi

echo
echo "== lock/package references =="
rg -n 'typer|pasarguard-node-bridge|PasarGuardNodeBridge' -S . --glob 'pyproject.toml' --glob '*.lock' --glob 'requirements*.txt' --glob 'setup.py' --glob 'setup.cfg' || true

Repository: PasarGuard/panel

Length of output: 4042


Use a publishable pasarguard-node-bridge lower bound.

typer 0.27.1 is published and satisfied by uv.lock. pasarguard-node-bridge 0.9.0 is not available on PyPI, but pyproject.toml and uv.lock still allow it to resolve as a public lower bound. Raise this to a PyPI-published version that exposes PasarGuardNodeBridge.storage ClaimedUser, LifecycleLease, LifecycleOperation, LifecycleStatus, NodeLifecycleState, and create_node(node_id, user_sync_store, lifecycle_coordinator, worker_id), or change the dependency source to a local/private version that guarantees these APIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 40 - 41, Update the pasarguard-node-bridge
dependency declaration to use a PyPI-published version that provides the
required storage and lifecycle APIs, including PasarGuardNodeBridge.storage
ClaimedUser, LifecycleLease, LifecycleOperation, LifecycleStatus,
NodeLifecycleState, and create_node with its expected parameters; alternatively,
configure an explicit local or private source guaranteeing those APIs, and
regenerate uv.lock accordingly.
tests/api/test_core.py (1)

62-69: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the test independent of existing WireGuard inbounds.

create_group without inbound_tags includes all current inbounds. If the test database already has a WireGuard inbound, starter_group can allocate a peer IP during user creation. The assertion on Line 113 then fails because it expects only 10.70.0.2/32.

Create the user without starter_group. This test only needs the WireGuard group created on Line 98.

Proposed fix
-    starter = create_core(access_token, name=unique_name("wg_create_starter"))
-    starter_group = create_group(access_token, name=unique_name("wg_create_starter_group"))
     user = create_user(
         access_token,
-        group_ids=[starter_group["id"]],
         payload={"username": unique_name("wg_create_user")},
     )
@@
         delete_user(access_token, user["username"])
-        delete_group(access_token, starter_group["id"])
         delete_core(access_token, wg_core["id"])
-        delete_core(access_token, starter["id"])

Also applies to: 112-120

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/api/test_core.py` around lines 62 - 69, Update the test setup around
create_group and create_user so the user is created without passing
starter_group["id"] in group_ids. Keep starter_group creation for the WireGuard
group used later in the test, and preserve the existing assertion flow.
tests/test_connect_concurrency.py (1)

40-46: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Drain the notification tasks before the test ends.

_connect_nodes_bulk_local schedules asyncio.create_task(notification.connect_node(...)) for every connected node (app/operation/node.py Line 700). All 25 nodes return NodeStatus.connected, so 25 tasks are pending when _connect_nodes_bulk_local returns. The test finishes immediately and pytest-asyncio closes the loop, which can produce "Task was destroyed but it is pending" warnings. If the project enables filterwarnings = error, the test becomes flaky.

Yield to the loop once before asserting.

💚 Proposed change
     await op._connect_nodes_bulk_local(MagicMock(), nodes)
+    await asyncio.sleep(0)
 
     assert peak <= CONNECT_CONCURRENCY
     assert peak > 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    nodes = [
        SimpleNamespace(id=i, status=NodeStatus.connecting, core_config_id=1, name=f"n{i}") for i in range(25)
    ]
    await op._connect_nodes_bulk_local(MagicMock(), nodes)
    await asyncio.sleep(0)

    assert peak <= CONNECT_CONCURRENCY
    assert peak > 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_connect_concurrency.py` around lines 40 - 46, Update the test
after _connect_nodes_bulk_local returns to yield control to the event loop once,
allowing scheduled notification.connect_node tasks to run or drain before
assertions. Keep the existing peak concurrency assertions unchanged.
tests/test_nats_leader_steal.py (1)

24-25: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the leader module globals after each test.

Both tests assign leader._is_leader and leader._token directly and never reset them. try_become_leader sets _is_leader = True on success, so the module keeps _is_leader = True and a live _token after these tests finish. Other tests that read leader.is_job_leader() or drive leader._heartbeat_loop then depend on execution order.

Add an autouse fixture that saves and restores the module state.

💚 Proposed isolation fixture
 from app.nats import leader
 from role import Role
 
 
+@pytest.fixture(autouse=True)
+def _reset_leader_state():
+    saved = (leader._is_leader, leader._token, leader._kv, leader._nc)
+    yield
+    leader._is_leader, leader._token, leader._kv, leader._nc = saved
+
+
 `@pytest.mark.asyncio`
 async def test_try_become_leader_falls_through_to_steal_after_generic_create_error():

Also applies to: 54-55

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_nats_leader_steal.py` around lines 24 - 25, Add an autouse pytest
fixture in the test module that snapshots leader._is_leader and leader._token
before each test and restores both values in teardown. Apply it to both tests,
including the later assignment block, so try_become_leader and direct mutations
cannot leak module state into other tests.

@ImMohammad20000

Copy link
Copy Markdown
Contributor

We never send a success password in notifications only wrong passwords that is because if a server was under brut force attack the admin can know it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants