Skip to content

Issue precise subscription tokens without revocation bypass - #758

Open
Rerowros wants to merge 2 commits into
PasarGuard:devfrom
Rerowros:codex/revocation-safe-subscription-tokens
Open

Issue precise subscription tokens without revocation bypass#758
Rerowros wants to merge 2 commits into
PasarGuard:devfrom
Rerowros:codex/revocation-safe-subscription-tokens

Conversation

@Rerowros

@Rerowros Rerowros commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Stack order: 2/4. Depends on #756. Merge after #756 with Squash and merge.

Summary

  • issue version 5 subscription tokens bound to the exact user-creation timestamp, without future-dating the issuance time
  • reject a revoked token on a timestamp tie for every supported token payload version
  • retain legacy parsing for v2/v3 tokens; their coarser timestamp semantics are documented in code and intentionally not strengthened retroactively

Validation

  • targeted token/revocation tests and Ruff checks passed

  • targeted Ruff check and formatting check

  • current stacked PR CI passed SQLite, PostgreSQL, TimescaleDB, MySQL, and MariaDB suites

  • clean stacked integration suite: 644 passed, 4 skipped; Alembic has one linear head and upgrade head/alembic check pass.

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

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 74929564-d812-464a-874f-42b51b60cbef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The pull request adds multi-worker NATS coordination, shared node lifecycle state, administrator-scoped group validation, precise v4 subscription tokens, optimized WireGuard reconciliation, subscription variable formatting, dashboard updates, and workflow maintenance.

Changes

Platform coordination and shared worker state

Layer / File(s) Summary
Deployment and job leadership
.env.example, config.py, role.py, app/app_factory.py, app/nats/*, .github/workflows/*
Multi-worker all-in-one deployment support now includes NATS requirements, role deprecation warnings, KV bucket settings, leader election, and leadership-aware scheduler startup.
CAS storage and bridge memory
app/nats/kv_cas.py, app/node/nats_memory.py, app/nats/router.py
NATS KV CAS helpers, in-memory test storage, shared user synchronization, lifecycle leases, and conditional message routing are added.
Node synchronization and lifecycle execution
app/node/*, app/operation/node.py, app/jobs/node_checker.py, app/core/*, tests/test_node_manager_sync.py, tests/test_nats_node_memory.py
Node operations synchronize actions across workers, attach through lifecycle leases, avoid remote shutdowns in multi-worker mode, and limit bulk connection concurrency.

Application behavior and access contracts

Layer / File(s) Summary
Token precision and administrator-scoped access
app/utils/jwt.py, app/operation/__init__.py, app/operation/user.py, app/operation/user_template.py, app/operation/group.py, app/routers/group.py, tests/api/test_user.py, tests/test_subscription_token_revocation.py, tests/test_group_access_unit.py
Subscription tokens now use v4 nanosecond timestamps with version-aware revocation checks. User, template, and bulk group flows enforce administrator scope while preserving existing inaccessible assignments.
WireGuard pool and reconciliation flow
app/db/crud/wireguard.py, app/operation/core.py, tests/api/test_core.py
Core creation initializes subnet pools without scanning users. Reconciliation filters eligible users and processes database work in bounded chunks.
Subscription formatting and dashboard controls
app/operation/subscription.py, app/subscription/share.py, dashboard/src/*, dashboard/public/statics/locales/*
Announcement URLs and SNI values support variables. Dashboard controls expose SNI guidance, reuse variable lookup logic, relax announcement URL validation, and remove general-settings custom-variable editing.

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

Possibly related PRs

  • PasarGuard/panel#237: Extends the earlier multi-worker and NATS architecture in the same application, NATS, and node-management paths.
  • PasarGuard/panel#713: Relates to the WireGuard allocation and reconciliation changes.
  • PasarGuard/panel#754: Relates to administrator-aware group validation for users and templates.

Suggested labels: Backend, refactor

Poem

A rabbit wires the workers tight,
Shares node state through day and night.
Tokens keep time with finer grace,
Groups stay within their granted space.
Pools fill only when users join,
“Hop!” says the rabbit, “all systems align!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.12% 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 describes the primary subscription-token timestamp and revocation change.
✨ 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 updates subscription token issuance/parsing and revocation validation to avoid “future-dated” timestamps and to ensure revocation checks fail closed at microsecond boundaries, with targeted regression tests to lock in boundary behavior.

Changes:

  • Switch subscription token issuance to a v4 format storing epoch nanoseconds (microsecond-accurate in payload decoding).
  • Preserve token version during parsing and tighten revocation comparisons (including conservative handling for older ceil-rounded tokens).
  • Add regression tests covering v3 future-rounding behavior and v4 revocation tie behavior, plus deterministic issuance-time coverage.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
app/utils/jwt.py Issues v4 tokens with time_ns() and parses v2/v3/v4/legacy payloads, including token version propagation.
app/operation/__init__.py Updates subscription validation to apply version-aware revocation comparisons and fail-closed semantics.
tests/api/test_user.py Adds deterministic test asserting v4 issuance time is precise and non-future-dated.
tests/test_subscription_token_revocation.py Adds revocation-boundary regression tests for v3 future-rounding and v4 tie-at-microsecond behavior.

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

Comment thread app/operation/__init__.py Outdated
Comment thread app/utils/jwt.py Outdated
@Rerowros
Rerowros force-pushed the codex/revocation-safe-subscription-tokens branch 2 times, most recently from a3b2d43 to 7aaf331 Compare August 9, 2026 02:16
@Rerowros Rerowros changed the title Make subscription token revocation fail closed Issue precise subscription tokens without revocation bypass Aug 9, 2026

@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: 1

🤖 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 `@tests/test_subscription_token_revocation.py`:
- Around line 22-38: Extend the subscription validation tests around
get_validated_sub to cover a timestamp-only payload without token_version, with
token created_at equal to sub_revoked_at, and assert rejection. Update the
validator’s legacy branch in get_validated_sub so legacy tokens use an inclusive
revocation comparison, while preserving strict > semantics for v2 and v3 tokens.
🪄 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: 71879964-8156-4ad9-8ca5-6f6ac2c2f959

📥 Commits

Reviewing files that changed from the base of the PR and between 7bdc4ba and 7aaf331.

📒 Files selected for processing (2)
  • app/operation/__init__.py
  • tests/test_subscription_token_revocation.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/operation/init.py

Comment thread tests/test_subscription_token_revocation.py
@Rerowros
Rerowros force-pushed the codex/revocation-safe-subscription-tokens branch 3 times, most recently from b36be99 to 2825c7f 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: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
dashboard/src/pages/_dashboard.settings.general.tsx (1)

54-58: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep subscription.custom_variables when saving general settings.

modify_settings replaces general when filteredData.general is sent. This payload does not include general.custom_variables or the full existing settings, so existing settings.subscription.custom_variables are dropped from the JSON row. Send subscription custom_variables in the payload or make general a partial update.

🤖 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 `@dashboard/src/pages/_dashboard.settings.general.tsx` around lines 54 - 58,
Update the filteredData payload in the general settings save flow to preserve
the existing subscription.custom_variables under general.custom_variables,
either by including them in the payload or by using a partial general-settings
update. Ensure modify_settings does not replace general without carrying forward
these existing custom variables.
.github/workflows/test-database-migrations.yml (1)

16-17: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add an explicit least-privilege permissions block.

The workflow declares no permissions, so jobs receive the repository default token scopes. These jobs only check out code and run migrations and tests. Set read-only permissions at the workflow level.

🔒 Proposed fix
+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 around lines 16 - 17, Add a
workflow-level permissions block near the top-level configuration of the
migration workflow, granting only read access to repository contents. Keep the
existing test-sqlite job behavior unchanged while ensuring checkout and test
steps operate with least-privilege token permissions.

Source: Linters/SAST tools

app/node/__init__.py (1)

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

Do not call node.stop() from sibling upsert/connect sync.

_update_node_sync publishes upsert, and handle_node_message calls node_manager.update_node(db_node) on sibling workers before remove/disconnect use remote_stop=False. Because update_node uses _shutdown_node(old_node) with remote_stop=True, each sibling running local sync can also shut the same remote core while other workers still use it. Add remote_stop=False to update_node for the shared-sync path or skip remote stops when bridge memory is active.

🤖 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 60 - 74, The update_node flow currently
calls _shutdown_node(old_node) with remote stopping enabled, causing sibling
upsert/connect synchronization to stop a shared remote core. Change
update_node’s shutdown behavior for the shared-sync path to use
remote_stop=False, or skip remote stops when bridge memory is active, while
preserving local cleanup and the existing remove/disconnect remote-stop
behavior.
🧹 Nitpick comments (5)
tests/api/test_user.py (1)

187-203: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Patch the module reference instead of the stdlib time module.

Line 195 sets time_ns on the shared time module object. Every caller in the process observes the frozen value for the duration of the test. Patch a module-local seam instead.

The timestamp assertion itself is correct: 1_723_000_000 seconds is 2024-08-07T03:06:40Z, and the sub-second part truncates to 123456 microseconds.

♻️ Proposed change
+    from types import SimpleNamespace
+
     monkeypatch.setattr(jwt_utils, "get_secret_key", fake_get_secret_key)
-    monkeypatch.setattr(jwt_utils.time, "time_ns", lambda: issued_at_ns)
+    monkeypatch.setattr(jwt_utils, "time", SimpleNamespace(time_ns=lambda: issued_at_ns))
🤖 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_user.py` around lines 187 - 203, Update
test_subscription_token_uses_precise_non_future_issuance_time to patch the
module-local time_ns seam used by the token implementation, rather than mutating
the shared jwt_utils.time module object. Keep the issued_at_ns value and
timestamp assertion unchanged.
app/jobs/node_checker.py (1)

255-262: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add startup jitter so worker health loops do not run in phase.

Every uvicorn worker starts _interval_loop at process start and uses the same core_health_check_interval. The workers therefore run node_health_check at nearly the same instant on every tick. Each run queries all nodes from the database and contacts every node. With several workers this produces a synchronized load spike against the database and the node API on each interval.

Add a small random initial delay before the first iteration.

♻️ Proposed refactor
 async def _interval_loop(coro, seconds: float, name: str):
     """Run node maintenance on every worker (APScheduler may be leader-only)."""
+    # Stagger workers so health ticks do not align across processes.
+    await asyncio.sleep(random.uniform(0, min(seconds, 5)))
     while True:
         try:
             await coro()
         except Exception as exc:
             logger.error("Node loop %s failed: %s", name, exc)
         await asyncio.sleep(seconds)

Add import random at the top of the file.

🤖 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 255 - 262, Update _interval_loop to
await a small random delay before entering its first maintenance iteration,
using the module’s random import as suggested. Keep the existing repeated coro
execution, error logging, and interval sleep behavior unchanged after startup.
app/operation/node.py (2)

233-258: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Raise the log level when attach fails for a non-routine reason.

Line 257 logs every attach failure at debug. The handler catches all exceptions, so a KV outage, an authentication failure, or a bridge bug is indistinguishable from the routine "core is not running" case. The caller then falls back to start(), which hides the fault. Log at warning for unexpected exception types, or include the exception type in the message so operators can filter it.

🤖 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 233 - 258, Update _attach_if_running’s
broad exception handler so unexpected failures such as KV, authentication, or
bridge errors are logged at warning level or otherwise include the exception
type, while preserving debug-level logging for routine attach-skipped
conditions. Keep the fallback return None behavior unchanged.

260-277: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the duplicate lifecycle-state read on the connect path.

Line 262 calls pg_node.get_lifecycle_state(). _attach_if_running then calls get_lifecycle_state() again at line 237. Each call is a NATS KV read in shared-bridge mode. Bulk connect runs up to CONNECT_CONCURRENCY nodes in parallel, so this doubles lifecycle KV traffic during startup, which is the exact contention the concurrency cap targets.

Pass the already-fetched state into _attach_if_running as an optional argument.

♻️ Proposed refactor
     `@staticmethod`
-    async def _attach_if_running(pg_node: PasarGuardNode, node_name: str):
+    async def _attach_if_running(pg_node: PasarGuardNode, node_name: str, state=None):
         """Attach to an already-started remote core without calling Start RPC."""
         try:
-            state = await pg_node.get_lifecycle_state()
+            if state is None:
+                state = await pg_node.get_lifecycle_state()
         state = await pg_node.get_lifecycle_state()
         if state is not None and state.observed is LifecycleStatus.HEALTHY:
-            attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
+            attached = await NodeOperation._attach_if_running(pg_node, db_node.name, state)
             if attached is not None:
                 return attached
🤖 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 260 - 277, Update _start_or_attach_node
and _attach_if_running to accept an optional pre-fetched lifecycle state, pass
the state read by _start_or_attach_node into _attach_if_running, and have
_attach_if_running reuse it instead of calling get_lifecycle_state again; retain
its existing read behavior when no state is supplied.
app/nats/kv_cas.py (1)

61-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Push the prefix into kv.keys() instead of filtering in Python.

kv_list_keys fetches every key in the bucket, then filters by prefix. The user-sync bucket holds pending and claimed keys for all nodes. NatsUserSyncStore.claim_users calls this twice per claim cycle, so the cost grows with total bucket size rather than with the keys for one node.

The CasKv protocol already declares a filters parameter, and NATS KV supports subject filters. Pass the prefix through.

♻️ Proposed change
 async def kv_list_keys(kv: CasKv, prefix: str) -> list[str]:
     try:
-        keys = await kv.keys()
+        keys = await kv.keys(filters=[f"{prefix}>"])
     except nats_js_errors.NoKeysError:
         return []
     return [key for key in keys if key.startswith(prefix)]

MemoryCasKv.keys uses substring matching, so update it to match NATS subject-filter semantics if you adopt this.

Confirm the exact filters semantics for KeyValue.keys in nats-py 2.15.0 before applying.

🤖 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/kv_cas.py` around lines 61 - 66, Update kv_list_keys to pass prefix
as the filters argument to kv.keys() and remove the Python-side filtering,
preserving the NoKeysError empty-list behavior. Verify nats-py 2.15.0
KeyValue.keys filter semantics first, then update MemoryCasKv.keys to match NATS
subject-filter semantics rather than substring matching if needed.
🤖 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 149-157: Update _pause_jobs_on_leadership_lost to pause the
scheduler before awaiting stop_notification_dispatcher; move the
scheduler.running check and scheduler.pause() ahead of the notification shutdown
while preserving the existing notification state reset and reclaim-task
behavior.

In `@app/db/crud/wireguard.py`:
- Around line 320-362: Bound WireGuard reconciliation memory by replacing
all-result materialization in app/db/crud/wireguard.py:320-362 with page-scoped
tag and proxy-settings retrieval, preserving stable user ordering and chunk
limits. Update the reconciliation flow at app/db/crud/wireguard.py:541-546 to
select users in stable keyset pages, process each page completely before
fetching the next, and avoid accumulating accessible tags, peer-IP IDs, proxy
settings, desired values, or allocation candidates across pages.

In `@app/jobs/node_checker.py`:
- Around line 315-320: Align the shutdown policy by removing the workers-count
restriction from the on_shutdown(shutdown_nodes) registration near the
feature_settings.stop_nodes_on_shutdown check. Let shutdown_nodes own the
multi-worker decision through its existing is_multi_worker() and
server_settings.workers guard, preserving that guard for direct calls.
- Around line 174-175: Update both update_observed_lifecycle calls in the
node-checking flow to capture their return values and detect rejected CAS
updates caused by an epoch mismatch. Log each failed update with sufficient
context instead of awaiting the calls without inspecting the result, while
preserving the existing lifecycle status and expected_epoch arguments.

In `@app/nats/leader.py`:
- Around line 253-256: Guard the NATS connection close in stop_job_leader at
app/nats/leader.py#L253-L256 with contextlib.suppress(Exception), preserving the
unconditional _nc and _kv resets. Apply the same change in
shutdown_bridge_memory at app/node/nats_memory.py#L436-L444, ensuring all five
global resets execute even when close() fails; contextlib is already imported in
both files.

In `@app/node/__init__.py`:
- Around line 26-47: Update _create_node_kwargs to remove the unsupported
node_id, user_sync_store, lifecycle_coordinator, and worker_id entries from the
create_node keyword arguments. Keep the standard node client fields and extra
metadata, and remove the now-unneeded get_bridge_memory call and conditional
block.

In `@app/operation/node.py`:
- Around line 704-709: Update _connect_nodes_bulk_local to return its
valid_results, then revise _connect_nodes_bulk_sync to publish connect events
only for results with NodeStatus.connected, using the result’s node ID. Replace
the sequential eligible-node publishing loop with asyncio.gather while
preserving the existing disabled/limited filtering and avoiding publishes for
failed connections.

In `@app/operation/subscription.py`:
- Around line 274-283: Update create_info_response_headers to build format
variables through get_format_variables, matching subscription response
formatting so url and TEMPLATE_TITLE resolve consistently in announce and
announce-url. Preserve the existing custom-variable application and formatting
flow after using the shared builder.

In `@app/subscription/share.py`:
- Line 312: Update the SNI handling around the sni.format_map call to prevent
malformed templates such as unmatched braces from aborting subscription
generation. Prefer validating SNI templates when they are saved; otherwise catch
formatting failures and safely retain the original raw SNI value while
preserving the existing empty-string behavior.

In
`@dashboard/src/features/subscriptions/components/subscription-general-settings-section.tsx`:
- Around line 139-140: Add a translated aria-label to the icon-only trigger
rendered by VariablesPopover, using the existing translation mechanism and an
appropriate variable-related label. Update VariablesPopover rather than
CustomVariablesPopover, while preserving the current trigger behavior.

In
`@dashboard/src/features/subscriptions/components/subscription-settings-schema.ts`:
- Line 105: Validate the URL produced by _format_announce_url after variable
expansion, before emitting announce-url, while preserving the empty-value
allowance. Reject invalid expanded URLs and fall back safely when custom
variables in the stored value cannot be resolved. Add coverage for empty,
successfully token-expanded, and invalid expanded URLs.

In `@pyproject.toml`:
- Around line 40-41: Update the pasarguard-node-bridge dependency declaration in
pyproject.toml to require the latest available compatible public PyPI release,
0.8.1, or otherwise align it with a published 0.9.0 registry release; ensure the
lockfile dependency resolution remains consistent.

In `@tests/api/test_core.py`:
- Around line 60-96: Update
test_wireguard_core_create_skips_user_scan_and_allocates_on_group to spy on
CoreOperation._reconcile_wireguard during the create_core call and assert it is
not invoked. Keep the existing peer_ips assertion, but make the test directly
distinguish pool initialization from the former reconciliation path.

In `@tests/test_nats_leader_steal.py`:
- Around line 10-30: Add an autouse fixture in the test module that resets the
leader module globals before and after each test, matching the existing pattern
in test_nats_leader_heartbeat.py. Ensure _is_leader and _token are restored to
their inactive values so tests involving try_become_leader do not leak singleton
state.

---

Outside diff comments:
In @.github/workflows/test-database-migrations.yml:
- Around line 16-17: Add a workflow-level permissions block near the top-level
configuration of the migration workflow, granting only read access to repository
contents. Keep the existing test-sqlite job behavior unchanged while ensuring
checkout and test steps operate with least-privilege token permissions.

In `@app/node/__init__.py`:
- Around line 60-74: The update_node flow currently calls
_shutdown_node(old_node) with remote stopping enabled, causing sibling
upsert/connect synchronization to stop a shared remote core. Change
update_node’s shutdown behavior for the shared-sync path to use
remote_stop=False, or skip remote stops when bridge memory is active, while
preserving local cleanup and the existing remove/disconnect remote-stop
behavior.

In `@dashboard/src/pages/_dashboard.settings.general.tsx`:
- Around line 54-58: Update the filteredData payload in the general settings
save flow to preserve the existing subscription.custom_variables under
general.custom_variables, either by including them in the payload or by using a
partial general-settings update. Ensure modify_settings does not replace general
without carrying forward these existing custom variables.

---

Nitpick comments:
In `@app/jobs/node_checker.py`:
- Around line 255-262: Update _interval_loop to await a small random delay
before entering its first maintenance iteration, using the module’s random
import as suggested. Keep the existing repeated coro execution, error logging,
and interval sleep behavior unchanged after startup.

In `@app/nats/kv_cas.py`:
- Around line 61-66: Update kv_list_keys to pass prefix as the filters argument
to kv.keys() and remove the Python-side filtering, preserving the NoKeysError
empty-list behavior. Verify nats-py 2.15.0 KeyValue.keys filter semantics first,
then update MemoryCasKv.keys to match NATS subject-filter semantics rather than
substring matching if needed.

In `@app/operation/node.py`:
- Around line 233-258: Update _attach_if_running’s broad exception handler so
unexpected failures such as KV, authentication, or bridge errors are logged at
warning level or otherwise include the exception type, while preserving
debug-level logging for routine attach-skipped conditions. Keep the fallback
return None behavior unchanged.
- Around line 260-277: Update _start_or_attach_node and _attach_if_running to
accept an optional pre-fetched lifecycle state, pass the state read by
_start_or_attach_node into _attach_if_running, and have _attach_if_running reuse
it instead of calling get_lifecycle_state again; retain its existing read
behavior when no state is supplied.

In `@tests/api/test_user.py`:
- Around line 187-203: Update
test_subscription_token_uses_precise_non_future_issuance_time to patch the
module-local time_ns seam used by the token implementation, rather than mutating
the shared jwt_utils.time module object. Keep the issued_at_ns value and
timestamp assertion unchanged.
🪄 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: da72b71d-bd8d-4d9d-9a07-107c2adf8e1c

📥 Commits

Reviewing files that changed from the base of the PR and between 7aaf331 and 2825c7f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (52)
  • .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
  • tests/test_subscription_token_revocation.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.

🛑 Comments failed to post (14)
app/app_factory.py (1)

149-157: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pause APScheduler before awaiting notification shutdown.

Line 151 can yield while this worker is no longer the leader. The scheduler remains active until Line 156. A scheduled job can run during that interval and duplicate leader-only work, including node limit updates.

Proposed fix
 async def _pause_jobs_on_leadership_lost():
+    if scheduler.running:
+        scheduler.pause()
     if started_notifications["value"]:
         from app.notification.client import stop_notification_dispatcher

         await stop_notification_dispatcher()
         started_notifications["value"] = False
-    if scheduler.running:
-        scheduler.pause()
     _ensure_reclaim_task()
📝 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 _pause_jobs_on_leadership_lost():
        if scheduler.running:
            scheduler.pause()
        if started_notifications["value"]:
            from app.notification.client import stop_notification_dispatcher

            await stop_notification_dispatcher()
            started_notifications["value"] = False
        _ensure_reclaim_task()
🤖 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 149 - 157, Update
_pause_jobs_on_leadership_lost to pause the scheduler before awaiting
stop_notification_dispatcher; move the scheduler.running check and
scheduler.pause() ahead of the notification shutdown while preserving the
existing notification state reset and reclaim-task behavior.
app/db/crud/wireguard.py (1)

320-362: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Process WireGuard reconciliation as bounded pages.

The new chunk size limits individual SQL IN lists. It does not bound total memory. Reconciliation still materializes all accessible-tag rows, peer-IP IDs, proxy settings, desired peer-IP values, and allocation candidates before updates start.

  • app/db/crud/wireguard.py#L320-L362: replace complete list and dictionary results with page-scoped tag and settings retrieval.
  • app/db/crud/wireguard.py#L541-L546: select relevant users in stable keyset pages and process each page before loading the next one.
📍 Affects 1 file
  • app/db/crud/wireguard.py#L320-L362 (this comment)
  • app/db/crud/wireguard.py#L541-L546
🤖 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 320 - 362, Bound WireGuard
reconciliation memory by replacing all-result materialization in
app/db/crud/wireguard.py:320-362 with page-scoped tag and proxy-settings
retrieval, preserving stable user ordering and chunk limits. Update the
reconciliation flow at app/db/crud/wireguard.py:541-546 to select users in
stable keyset pages, process each page completely before fetching the next, and
avoid accumulating accessible tags, peer-IP IDs, proxy settings, desired values,
or allocation candidates across pages.
app/jobs/node_checker.py (2)

174-175: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import inspect
try:
    from PasarGuardNodeBridge import PasarGuardNode
    print(inspect.signature(PasarGuardNode.update_observed_lifecycle))
    print(inspect.getsource(PasarGuardNode.update_observed_lifecycle))
except Exception as exc:
    print("inspect failed:", exc)
PY

# Show every call site so the handling stays consistent.
rg -nP -C3 'update_observed_lifecycle\s*\(' --type=py

Repository: PasarGuard/panel

Length of output: 209


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | sed -n '1,120p'

echo "== locate node_checker =="
fd -a 'node_checker.py' . || true

echo "== outline =="
ast-grep outline app/jobs/node_checker.py || true

echo "== relevant lines =="
sed -n '120,215p' app/jobs/node_checker.py

echo "== search shared_state/update calls =="
rg -n -C 4 'shared_state|update_observed_lifecycle|get_lifecycle_state|LifecycleStatus\.BROKEN' app/jobs/node_checker.py app || true

echo "== lifecycle symbols references =="
rg -n 'class PasarGuardNode|def update_observed_lifecycle|def get_lifecycle_state|observed_lifecycle|LifecycleStatus' . --type=py --glob '!**/__pycache__/**' || true

Repository: PasarGuard/panel

Length of output: 21032


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== nats_memory lifecycle state =="
sed -n '320,390p' app/node/nats_memory.py

echo "== node_checker lifecycle imports/context =="
sed -n '1,90p' app/jobs/node_checker.py

echo "== lifecycle tests =="
sed -n '1,180p' tests/test_nats_node_memory.py

echo "== deterministic CAS-equivalent behavioral probe from source text =="
python3 - <<'PY'
from pathlib import Path
p = Path("app/node/nats_memory.py").read_text()
start = p.index("    async def update_observed(")
end = p.index("    async def update_shared_state", start)
method = p[start:end]
print(method)
print("\nexpected_epoch appears:", "expected_epoch" in method)
print("returns None:", "return None" in method or method.strip().endswith("None"))
print("raises exception on stale epoch:", "raise " in method)
PY

Repository: PasarGuard/panel

Length of output: 12756


Log rejected observed-lifecycle Cas updates.

update_observed_lifecycle(..., expected_epoch=shared_state.epoch) can be discarded when expected_epoch no longer matches, and the next shared-state writer will then see an older observed status. Capture and log the failed return for both calls instead of using await node.update_observed_lifecycle(...) as a fire-and-forget health-side note.

🤖 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 174 - 175, Update both
update_observed_lifecycle calls in the node-checking flow to capture their
return values and detect rejected CAS updates caused by an epoch mismatch. Log
each failed update with sufficient context instead of awaiting the calls without
inspecting the result, while preserving the existing lifecycle status and
expected_epoch arguments.

315-320: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the two shutdown guards; they express different policies.

Line 316 registers shutdown_nodes only when server_settings.workers <= 1. Line 334 inside shutdown_nodes skips the stop when is_multi_worker() and server_settings.workers > 1.

The conditions differ. If an operator sets UVICORN_WORKERS=2 without NATS_ENABLED=1, then is_multi_worker() is False but workers > 1. The registration at line 316 skips shutdown_nodes entirely, so remote cores are never stopped, while the runtime guard at line 334 would have permitted the stop. The registration decides one way and the function body the other.

Use the same predicate in both places. Because registration already filters, the check at line 334 is unreachable through this path and only matters for direct calls.

♻️ 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:
+    # Multi-uvicorn workers must not Stop remote cores / clear shared sync queues on exit.
+    if feature_settings.stop_nodes_on_shutdown:
         on_shutdown(shutdown_nodes)

shutdown_nodes then owns the multi-worker decision through its existing guard at line 334.

Also applies to: 331-336

🤖 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 - 320, Align the shutdown policy
by removing the workers-count restriction from the on_shutdown(shutdown_nodes)
registration near the feature_settings.stop_nodes_on_shutdown check. Let
shutdown_nodes own the multi-worker decision through its existing
is_multi_worker() and server_settings.workers guard, preserving that guard for
direct calls.
app/nats/leader.py (1)

253-256: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded await _nc.close() in two shutdown paths. Both functions close the NATS connection without suppressing exceptions, and both reset module globals only on the lines that follow. If the connection is already broken, close() raises, the reset lines never run, and the shutdown hook chain aborts. Stale globals then make a later re-initialization in the same process reuse a dead client. ensure_bridge_memory in app/node/nats_memory.py already wraps a comparable close with contextlib.suppress; apply the same pattern to both sites.

  • app/nats/leader.py#L253-L256: wrap await _nc.close() in stop_job_leader with contextlib.suppress(Exception) so _nc = None and _kv = None always run. contextlib is imported at line 6.
  • app/node/nats_memory.py#L436-L444: wrap await _nc.close() in shutdown_bridge_memory with contextlib.suppress(Exception) so the five global resets always run. contextlib is imported at line 7.
📍 Affects 2 files
  • app/nats/leader.py#L253-L256 (this comment)
  • app/node/nats_memory.py#L436-L444
🤖 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 253 - 256, Guard the NATS connection close
in stop_job_leader at app/nats/leader.py#L253-L256 with
contextlib.suppress(Exception), preserving the unconditional _nc and _kv resets.
Apply the same change in shutdown_bridge_memory at
app/node/nats_memory.py#L436-L444, ensuring all five global resets execute even
when close() fails; contextlib is already imported in both files.
app/node/__init__.py (1)

26-47: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the installed bridge package and inspect create_node's signature.
fd -t d 'PasarGuardNodeBridge' / 2>/dev/null | head
python - <<'PY'
import inspect
try:
    import PasarGuardNodeBridge as b
    print(inspect.signature(b.create_node))
except Exception as exc:
    print("import failed:", exc)
PY
# Fallback: check the declared dependency version in the manifest.
rg -n 'pasarguard[-_]node[-_]bridge' --iglob 'pyproject.toml' --iglob 'requirements*.txt' --iglob 'uv.lock'

Repository: PasarGuard/panel

Length of output: 208


🌐 Web query:

pasarguard-node-bridge 0.9.0 create_node signature node_id user_sync_store lifecycle_coordinator worker_id

💡 Result:

The pasarguard-node-bridge library does not support a create_node method with the signature containing node_id, user_sync_store, lifecycle_coordinator, and worker_id. Official documentation and repository examples for pasarguard-node-bridge show that the create_node method is used to instantiate a node client for connecting to a PasarGuard node, accepting parameters such as connection, address, port, api_port, server_ca, api_key, name, extra, default_timeout, internal_timeout, and proxy [1][2][3]. The parameters you mentioned—node_id, user_sync_store, lifecycle_coordinator, and worker_id—do not appear in the library's create_node API, nor are they mentioned in the associated documentation for this specific Python package [1][4][5]. It is possible these terms refer to an internal component architecture or a different library entirely.

Citations:


🌐 Web query:

pasarguard-node-bridge 0.9.0 create_node API documentation

💡 Result:

The pasarguard-node-bridge library is a Python package (often referred to as node_bridge_py) used to interact with PasarGuard nodes [1][2]. As of the available documentation, the API utilizes a create_node method to initialize a node client [3][1]. The create_node method signature is as follows [3][1]: node = Bridge.create_node( connection=Bridge.NodeType.grpc, # Bridge.NodeType.grpc or Bridge.NodeType.rest address="127.0.0.1", port=2096, api_port=2097, server_ca=server_ca_pem_string, api_key="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", name="node-1", # optional extra={"region": "eu-1"}, # optional default_timeout=10, # optional internal_timeout=15, # optional proxy="socks5://user:pass@127.0.0.1:1080", # optional) Parameters: connection: Specifies the connection type, either Bridge.NodeType.grpc or Bridge.NodeType.rest [3]. address: The IP address or hostname of the node [3]. port: The node service port (gRPC or protobuf-REST) [3]. api_port: The node REST JSON API port, used internally for maintenance [3]. server_ca: The PEM certificate content provided as a string [3]. api_key: A UUID string used for authentication [3]. name (optional): A name for the node logger [3]. extra (optional): A dictionary for additional metadata [3]. default_timeout (optional): Default timeout for public API methods [3]. internal_timeout (optional): Timeout for internal synchronization and log operations [3]. proxy (optional): An upstream proxy URL for node traffic [3]. max_message_size: Relevant for gRPC connections, defining HTTP/2 window or message sizing [3]. Note that PasarGuard also maintains a separate Go library called node_bridge, which uses a different API structure (e.g., node_bridge.New) [4][5]. Users should ensure they are referencing the Python-specific implementation when using pasarguard-node-bridge [1][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf 'Tracked dependency manifests:\n'
git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|Pipfile|poetry\.lock|uv\.lock|pipfile\.lock)$' || true

printf '\nDependency mentions:\n'
rg -n -i 'pasarguard[node_-]bridge|PasarGuardNodeBridge|node-bridge' --glob 'pyproject.toml' --glob 'requirements*.txt' --glob 'uv.lock' --glob 'poetry.lock' --glob 'Pipfile*' . || true

printf '\nRelevant source imports/usages:\n'
fd -a '__init__.py' . | while read -r f; do
  if rg -n 'create_node|_create_node_kwargs|get_bridge_memory|PasarGuardNodeBridge' "$f" >/dev/null 2>&1; then
    echo "--- $f"
    fd -a . "$f" -d 0 -x sh -c 'wc -l "{}"'
    rg -n -C 3 'create_node|_create_node_kwargs|get_bridge_memory|PasarGuardNodeBridge' "$f"
  fi
done

Repository: PasarGuard/panel

Length of output: 3929


Remove unsupported create_node keyword arguments.

_create_node_kwargs passes node_id, user_sync_store, lifecycle_coordinator, and worker_id to pasarguard-node-bridge >=0.9.0 create_node. That API does not accept these optional keywords in its documented signature; only standard node client fields and extra are passed through, so unknown keywords can raise TypeError during node creation.

🤖 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 26 - 47, Update _create_node_kwargs to
remove the unsupported node_id, user_sync_store, lifecycle_coordinator, and
worker_id entries from the create_node keyword arguments. Keep the standard node
client fields and extra metadata, and remove the now-unneeded get_bridge_memory
call and conditional block.
app/operation/node.py (1)

704-709: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bulk sync publishes connect for nodes whose local connect failed.

_connect_nodes_bulk_local filters by node.status, not by the connect outcome. This loop then publishes a connect event for every node that was not disabled or limited, including nodes that returned NodeStatus.error.

Each sibling worker consumes that event and runs update_node followed by connect_node (see handle_node_message in app/node/manager_sync.py). An unreachable node therefore receives one failed start attempt per worker instead of one. With UVICORN_WORKERS=4 and a large node set, this multiplies the retry load against nodes that are already down.

Make _connect_nodes_bulk_local return its valid_results list, then publish only for nodes whose result status is NodeStatus.connected.

The sequential await per node also serializes the publishes. Consider asyncio.gather over the eligible node IDs.

🐛 Proposed direction
     async def _connect_nodes_bulk_sync(self, db: AsyncSession, nodes: list[Node]) -> None:
-        await self._connect_nodes_bulk_local(db, nodes)
-        for node in nodes:
-            if node is not None and node.status not in (NodeStatus.disabled, NodeStatus.limited):
-                await publish_node_sync("connect", node.id)
+        results = await self._connect_nodes_bulk_local(db, nodes)
+        connected_ids = [r["node_id"] for r in (results or []) if r["status"] == NodeStatus.connected]
+        if connected_ids:
+            await asyncio.gather(*(publish_node_sync("connect", node_id) for node_id in connected_ids))

_connect_nodes_bulk_local must return valid_results for this to work.

🤖 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 704 - 709, Update
_connect_nodes_bulk_local to return its valid_results, then revise
_connect_nodes_bulk_sync to publish connect events only for results with
NodeStatus.connected, using the result’s node ID. Replace the sequential
eligible-node publishing loop with asyncio.gather while preserving the existing
disabled/limited filtering and avoiding publishes for failed connections.
app/operation/subscription.py (1)

274-283: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C6 \
  'create_info_response_headers|get_format_variables|announce_url|PROFILE_TITLE' \
  app tests

Repository: PasarGuard/panel

Length of output: 22747


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== helper definitions =="
rg -n -C6 'def setup_format_variables|def apply_custom_format_variables|def get_effective_custom_variables|def sanitize_response_headers|def encode_title|def sub_url|BUILTIN_FORMAT_VARIABLES' app

echo
echo "== create_info_response_headers slice =="
sed -n '240,310p' app/operation/subscription.py

echo
echo "== create_response_headers slice =="
sed -n '156,193p' app/operation/subscription.py

echo
echo "== get_format_variables slice =="
sed -n '490,520p' app/operation/subscription.py

echo
echo "== tests mentioning info response headers or create_info_response_headers =="
rg -n -C5 'info|announce-url|announce_url|profile-title|profile_web_page_url|create_info_response_headers' tests app | sed -n '1,240p'

Repository: PasarGuard/panel

Length of output: 29600


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from collections import defaultdict
from datetime import UTC, date

BUILTIN_FORMAT_VARIABLES = {
    "SERVER_IP", "SERVER_IPV6", "USERNAME", "DATA_USAGE", "DATA_LIMIT",
    "DATA_LEFT", "STATUS_EMOJI", "TIME_LEFT", "STATUS", "JALALI_TIME_LEFT",
    "EXPIRE_DATE", "JALALI_EXPIRE_DATE", "USAGE_PERCENTAGE", "ADMIN_USERNAME",
    "PROFILE_TITLE", "PROTOCOL", "TRANSPORT", "url", "format",
}

def _custom_variable_parts(value):
    # Minimal mirror of app/subscription/share.py without repository imports.
    key = value
    return (key or None), ""

def get_effective_custom_variables(custom_variables=None):
    variables = list(custom_variables or [])
    admin_variables = []
    variables.extend(admin_variables)
    return variables

def apply_custom_format_variables(format_variables, custom_variables=None):
    custom_variables = get_effective_custom_variables(custom_variables)
    if not custom_variables:
        return format_variables
    custom_keys = {key for key, _ in (_custom_variable_parts(variable) for variable in custom_variables) if key}
    base_variables = defaultdict(lambda: "<missing>", **format_variables, **custom_keys)
    return base_variables

def setup_format_variables(user=None, custom_variables=None):
    base_variables = BUILTIN_FORMAT_VARIABLES.copy()
    format_variables = {}
    for key in base_variables:
        if key == "USERNAME":
            format_variables[key] = "alice"
    if user is None:
        user = type("UsersResponseWithInbounds", (), {"status": "active", "data_limit": 1000000, "used_traffic": 0, "expire": date.today()})()
        user.admin = None
    format_variables["STATUS"] = user.status
    format_variables["DATA_USAGE"] = user.used_traffic
    format_variables["DATA_LIMIT"] = str(user.data_limit / 1000000)
    format_variables["DATA_LEFT"] = str((user.data_limit - user.used_traffic) / 1000000)
    format_variables["STATUS_EMOJI"] = "(normal)"
    format_variables["USAGE_PERCENTAGE"] = "0.00"
    format_variables["TIME_LEFT"] = "∞"
    format_variables["JALALI_TIME_LEFT"] = "∞"
    format_variables["EXPIRE_DATE"] = "∞"
    format_variables["JALALI_EXPIRE_DATE"] = "∞"
    format_variables["ADMIN_USERNAME"] = str(user.admin.username if user.admin else "")
    return apply_custom_format_variables(format_variables, custom_variables)

def get_format_variables(user=None, custom_variables=None):
    sub_url = "https://example.com/sub/abc"
    formatted_title = "Custom Title"
    format_variables = setup_format_variables(user, custom_variables)
    format_variables.update({"url": sub_url})
    format_variables.update({"PROFILE_TITLE": formatted_title})
    return apply_custom_format_variables(format_variables, custom_variables)

custom = ["url", "PROFILE_TITLE"]
info = setup_format_variables(None, custom)
sub = get_format_variables(None, custom)

for key in ("url", "PROFILE_TITLE"):
    print(f"{key}: /info={info.get(key)!r} vs subscription={sub.get(key)!r}")
PY

Repository: PasarGuard/panel

Length of output: 316


Keep /info announcement variables consistent with subscription responses.

get_format_variables adds url (subscription URL) and PROFILE_TITLE, but create_info_response_headers uses setup_format_variables directly for announce and annouce_url. This can make {url} and {TEMPLATE_TITLE} resolve to <missing> in /info while subscription responses use the completed variables. Use the same variable builder if the endpoint should support them, or update the tests/docs if this narrower contract is intentional.

🤖 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/subscription.py` around lines 274 - 283, Update
create_info_response_headers to build format variables through
get_format_variables, matching subscription response formatting so url and
TEMPLATE_TITLE resolve consistently in announce and announce-url. Preserve the
existing custom-variable application and formatting flow after using the shared
builder.
app/subscription/share.py (1)

312-312: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C6 'sni|format_map|validate.*sni' app tests

Repository: PasarGuard/panel

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app/subscription/share.py around line 312 =="
sed -n '280,325p' app/subscription/share.py | cat -n

echo
echo "== SNI model/API/schema references =="
rg -n -C3 'class .*Host|sni:|StringArray|Field\(|validator\s*\(|@.*validat' app tests/api tests -g '*.py' | head -n 200

Repository: PasarGuard/panel

Length of output: 13074


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SNI-specific parser/formatter references =="
rg -n -C5 'sni.*format|format.*sni|HostModify|ProxyHost|TLSConfig|transport_config|sni:' app/models app/api app/core app/operation app/db app/subscription -g '*.py' | head -n 260

echo
echo "== static formatter parser behavior for edge-{ and wildcards =="
python3 - <<'PY'
values = ["edge-{", "edge-{USERNAME}", "*-", "no wildcards"]
variables = {"USERNAME": "alice"}
for value in values:
    after_wildcard = value.replace("*", "SALT")
    try:
        result = after_wildcard.format_map(variables) if after_wildcard else ""
        print(f"{value!r} -> {result!r}")
    except Exception as exc:
        print(f"{value!r} -> {type(exc).__name__}: {exc}")
PY

Repository: PasarGuard/panel

Length of output: 15697


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== app/models/host.py relevant snippets =="
sed -n '1,160p' app/models/host.py | cat -n
sed -n '520,595p' app/models/host.py | cat -n

echo
echo "== validate_subscription_templates implementation =="
sed -n '35,70p' app/operation/host.py | cat -n

echo
echo "== string formatter behavior probe =="
python3 - <<'PY'
from string import Formatter
values = ["edge-{", "edge-{USERNAME}", "*-", "no wildcards"]
variables = {"USERNAME": "alice"}
formatter = Formatter()
for value in values:
    after_wildcard = value.replace("*", "SALT")
    print(f"input={value!r}, after_wildcard={after_wildcard!r}")
    try:
        parsed = list(formatter.parse(after_wildcard))
        print(f"  parsed={parsed!r}")
    except Exception as exc:
        print(f"  parse_error={type(exc).__name__}: {exc}")
    try:
        result = after_wildcard.format_map(variables) if after_wildcard else ""
        print(f"  format_result={result!r}")
    except Exception as exc:
        print(f"  format_error={type(exc).__name__}: {exc}")
PY

Repository: PasarGuard/panel

Length of output: 12831


Handle invalid SNI format strings before generating subscriptions.

app/subscription/share.py:312 formats the selected SNI without a fallback. A stored value such as edge-{ raises ValueError and aborts subscription generation. Validate SNI templates when saving, or format with a safe fallback that preserves unsupported raw values.

🤖 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 handling around the
sni.format_map call to prevent malformed templates such as unmatched braces from
aborting subscription generation. Prefer validating SNI templates when they are
saved; otherwise catch formatting failures and safely retain the original raw
SNI value while preserving the existing empty-string behavior.
dashboard/src/features/subscriptions/components/subscription-general-settings-section.tsx (1)

139-140: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Give the new variable trigger an accessible name.

VariablesPopover renders an icon-only button without aria-label or text. The new announce_url control is therefore unnamed for screen-reader users.

Add a translated aria-label to the shared VariablesPopover trigger.

Proposed fix
-<Button type="button" variant="ghost" size="icon" className="h-auto w-auto p-0 hover:bg-transparent">
+<Button
+  type="button"
+  variant="ghost"
+  size="icon"
+  aria-label={t('hostsDialog.variables.title')}
+  className="h-auto w-auto p-0 hover:bg-transparent"
+>
🤖 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
`@dashboard/src/features/subscriptions/components/subscription-general-settings-section.tsx`
around lines 139 - 140, Add a translated aria-label to the icon-only trigger
rendered by VariablesPopover, using the existing translation mechanism and an
appropriate variable-related label. Update VariablesPopover rather than
CustomVariablesPopover, while preserving the current trigger behavior.
dashboard/src/features/subscriptions/components/subscription-settings-schema.ts (1)

105-105: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 'announce_url|custom_variables|replace|format' app/operation/subscription.py

Repository: PasarGuard/panel

Length of output: 23614


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- subscription-settings-schema ---'
cat -n dashboard/src/features/subscriptions/components/subscription-settings-schema.ts | sed -n '1,135p'

printf '%s\n' '--- validate_announce_url occurrences ---'
rg -n -C 6 'announce_url|announce-url|URL|urlparse|validat|raise_error' app tests . --glob '!node_modules' --glob '!dist' --glob '!build' || true

printf '%s\n' '--- relevant model/schema snippets ---'
rg -n -C 8 'class Subscription|SUBSettings|announce_url|announce-url|response_schema|validate_announce' app tests --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: PasarGuard/panel

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- subscription-settings-schema.ts ---'
sed -n '1,145p' dashboard/src/features/subscriptions/components/subscription-settings-schema.ts

printf '%s\n' '--- app/models/settings.py relevant Subscription fields ---'
rg -n -C 12 'class Subscription|announce_url|profile_title|announce' app/models/settings.py

printf '%s\n' '--- app/utils/validation?url utilities ---'
rg -n -C 4 'raise_error|invalid .*url|invalid.?url|urlparse|UrlParse|pydantic|model_validate|field_validator' app/utils app -g '*.py' --max-count 120 || true

printf '%s\n' '--- tests mentioning announce_url or announceUrl ---'
rg -n -C 8 'announce_url|announceUrl|announce-url' tests . --glob '!node_modules' --glob '!dist' --glob '!build' --max-count 160 || true

printf '%s\n' '--- subscription operation lines 135-200 ---'
sed -n '135,200p' app/operation/subscription.py

Repository: PasarGuard/panel

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- focused frontend schema ---'
python3 - <<'PY'
from pathlib import Path
p=Path('dashboard/src/features/subscriptions/components/subscription-settings-schema.ts')
print(f"exists={p.exists()}")
if p.exists():
    lines=p.read_text().splitlines()
    for i in range(max(0,103), min(len(lines),108)):
        print(f"{i+1}: {lines[i]}")
PY

printf '%s\n' '--- focused backend operation lines ---'
python3 - <<'PY'
from pathlib import Path
p=Path('app/operation/subscription.py')
lines=p.read_text().splitlines()
for i in range(136, 201):
    print(f"{i+1}: {lines[i]}")
PY

printf '%s\n' '--- focused model definitions ---'
python3 - <<'PY'
from pathlib import Path
for rel in ['app/models/settings.py','app/models/subscription.py']:
    p=Path(rel)
    print(f'--- {rel} ---')
    lines=p.read_text().splitlines()
    for start,end in [('class Subscription',120),('class SubRule',80),('announce_url',60),('announce',50),('profile_title',60)]:
        hits=[i for i,l in enumerate(lines) if start in l]
        for hit in hits[:3]:
            for j in range(max(0,hit-25), min(len(lines), hit+55)):
                print(f"{j+1}: {lines[j]}")
            print()
PY

printf '%s\n' '--- focused test/search files only ---'
rg -n -C 8 'announce._url|announceUrl|announce-url|subscription-settings-schema|subscription_response_headers|user_subscription_raw|user_subscription_with_client_type' tests app --glob '!node_modules' --glob '!dist' --glob '!build' --max-count 200 || true

Repository: PasarGuard/panel

Length of output: 48348


Validate announce_url after variable expansion.

_format_announce_url() expands sub_settings.announce_url, but only announce-url is emitted afterward. Add validation after format substitution and reject invalid expanded URLs, with a fallback when the stored value contains invalid custom variables.

Add coverage for an empty value, a tokenized URL that resolves as configured, and an expanded URL that fails validation.

🤖 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
`@dashboard/src/features/subscriptions/components/subscription-settings-schema.ts`
at line 105, Validate the URL produced by _format_announce_url after variable
expansion, before emitting announce-url, while preserving the empty-value
allowance. Reject invalid expanded URLs and fall back safely when custom
variables in the stored value cannot be resolved. Add coverage for empty,
successfully token-expanded, and invalid expanded URLs.
pyproject.toml (1)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 \
  'pasarguard-node-bridge|index-url|extra-index-url|tool\.uv|sources' \
  pyproject.toml .github 2>/dev/null || true

python -m pip index versions pasarguard-node-bridge

Repository: PasarGuard/panel

Length of output: 500


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pyproject dependency/index snippets =="
sed -n '1,80p' pyproject.toml

echo
echo "== repository-wide package index/source references =="
rg -n 'pypi|index-url|extra-index-url|find-links|trusted-host|tool\.uv|tool\.pdm|tool\.poetry|sources|pasarguard-node-bridge' -g '!**/.git/**' . || true

echo
echo "== python package tooling availability =="
command -v python || true
python --version || true

Repository: PasarGuard/panel

Length of output: 50373


🌐 Web query:

pypi.org project pasarguard-node-bridge versions

💡 Result:

The pasarguard-node-bridge project on PyPI maintains several historical versions. Documented versions include: - 0.8.1 [1][2] - 0.8.0 [3] - 0.7.1 [4] - 0.6.0 [5] The package is an asynchronous Python client designed to connect to a PasarGuard node using gRPC or REST protocols [1][6]. It requires Python 3.12 or higher [1]. Official documentation and source code are available through the project's GitHub repository at https://github.com/PasarGuard/node_bridge_py [1][6].

Citations:


🌐 Web query:

uv.lock package "pasarguard-node-bridge" version 0.9.0 uv.lock

💡 Result:

There is no evidence of a version 0.9.0 for the pasarguard-node-bridge package as of August 9, 2026. The latest available version on PyPI is 0.8.1 [1]. Technical projects utilizing this package, such as the PasarGuard panel, have been updated to use version 0.8.1 [2][3]. If you encounter a reference to version 0.9.0 in a uv.lock file, it is likely a misconfiguration or a placeholder, as this version does not exist in the official package repository [1]. You should check your pyproject.toml and uv.lock files to ensure they are pointing to a valid, released version such as 0.8.1 [1][2].

Citations:


Pin pasarguard-node-bridge to an available release.

pyproject.toml requires pasarguard-node-bridge>=0.9.0, and uv.lock uses the public PyPI registry. PyPI only lists 0.8.1 as the latest release, so resolver/install commands will not find a satisfying package. Lower the minimum to an available compatible version or publish 0.9.0 to the configured registry.

🤖 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 in pyproject.toml to require the latest available
compatible public PyPI release, 0.8.1, or otherwise align it with a published
0.9.0 registry release; ensure the lockfile dependency resolution remains
consistent.
tests/api/test_core.py (1)

60-96: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the test fail if core creation performs reconciliation.

The unchanged peer_ips assertion does not distinguish pool initialization from the former full reconciliation path. The user cannot access the new inbound at this point, so both paths can leave its peer IPs unchanged.

Spy on CoreOperation._reconcile_wireguard, or instrument the reconciliation query path, and assert that WireGuard core creation does not invoke it.

🤖 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 60 - 96, Update
test_wireguard_core_create_skips_user_scan_and_allocates_on_group to spy on
CoreOperation._reconcile_wireguard during the create_core call and assert it is
not invoked. Keep the existing peer_ips assertion, but make the test directly
distinguish pool initialization from the former reconciliation path.
tests/test_nats_leader_steal.py (1)

10-30: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reset the leader module globals after each test.

Both async tests assign leader._is_leader and leader._token directly and never restore them. Each test leaves leader._is_leader = True and a live token in the module singleton. Any later test in the session that reads leader.is_job_leader() sees a stale True, and the result depends on collection order.

tests/test_nats_leader_heartbeat.py already defines an autouse reset fixture. Add the same protection here.

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

Also applies to: 44-59

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 14-14: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"token": "old", "expires_at": 0})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 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 10 - 30, Add an autouse fixture
in the test module that resets the leader module globals before and after each
test, matching the existing pattern in test_nats_leader_heartbeat.py. Ensure
_is_leader and _token are restored to their inactive values so tests involving
try_become_leader do not leak singleton state.

@Rerowros
Rerowros force-pushed the codex/revocation-safe-subscription-tokens branch 3 times, most recently from 4a7eed8 to 054cb93 Compare August 9, 2026 21:32
@Rerowros
Rerowros force-pushed the codex/revocation-safe-subscription-tokens branch 4 times, most recently from d25a97d to dfce4a1 Compare August 10, 2026 07:52
@Rerowros
Rerowros force-pushed the codex/revocation-safe-subscription-tokens branch from dfce4a1 to d9d2cfb Compare August 10, 2026 07:57
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.

2 participants