Skip to content

Harden subscription and admin security - #756

Open
Rerowros wants to merge 1 commit into
PasarGuard:devfrom
Rerowros:codex/security-csv-10-49
Open

Harden subscription and admin security#756
Rerowros wants to merge 1 commit into
PasarGuard:devfrom
Rerowros:codex/security-csv-10-49

Conversation

@Rerowros

@Rerowros Rerowros commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Stack order: 1/4. Merge this PR first with Squash and merge.

Summary

  • reject inactive subscriptions across config, raw, info, and external-config paths
  • bound cleanup and usage work; revoke runtime credentials through an acknowledged node/NATS RPC before deleting user rows
  • require expiring admin JWTs, redact subscription credentials from webhooks, and bind seeded client listeners to loopback
  • safely preserve and validate installer paths before rendering the systemd unit

Validation

  • uv run pytest -q tests/test_security_hardening.py tests/test_node_sync.py tests/test_node_manager_sync.py tests/test_nats_node_memory.py — 28 passed
  • targeted Ruff check and formatting check
  • bash -n install_service.sh after normalizing the repository CRLF working-tree copy to LF for Bash validation
  • prior migration/CI checks remain green for the earlier PR commits

Compatibility

The revoke path uses a new revoke_user/revoke_users RPC action. During a mixed rolling upgrade, an old node worker returns Unknown action; deletion is deliberately refused and can be retried after the worker upgrade. Existing normal asynchronous user updates continue to use the original command path.

Copilot AI lite review requested due to automatic review settings August 9, 2026 02:08
@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: 405377b3-bc85-4fc2-8274-dca1cafcf221

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 bounded security settings, subscription and group access checks, synchronized cleanup, NATS-based multi-worker coordination, WireGuard reconciliation changes, dashboard variable support, and expanded tests and workflow updates.

Changes

Security hardening and access control

Layer / File(s) Summary
Security controls and cleanup
config.py, app/models/user.py, app/utils/jwt.py, app/notification/webhook/__init__.py, app/operation/user.py, app/jobs/remove_expired_users.py, app/db/migrations/versions/*, install_service.sh
JWT claims, cleanup settings, webhook payloads, expired-user removal, installer paths, and Xray client templates now use stricter handling.
Subscription and group access
app/operation/subscription.py, app/operation/__init__.py, app/operation/user.py, app/operation/user_template.py, app/operation/group.py, app/routers/group.py
Subscription configuration requires eligible users. Usage queries are limited to 31 days. Administrator group access applies to user, template, and bulk group operations.
WireGuard allocation and reconciliation
app/db/crud/wireguard.py, app/operation/core.py
Core creation initializes subnet pools without scanning users. Reconciliation selects relevant users and processes them in chunks.

Distributed runtime coordination

Layer / File(s) Summary
NATS primitives and shared memory
app/nats/*, app/node/nats_memory.py, config.py
NATS helpers, CAS storage, leader election, user synchronization, and node lifecycle coordination are added.
Node synchronization and lifecycle
app/node/*, app/operation/node.py, app/jobs/node_checker.py, app/nats/router.py
Workers publish node actions and coordinate node startup, health checks, leases, attachment, shutdown, and cleanup.
Application wiring and deployment roles
app/app_factory.py, role.py, .env.example, .github/workflows/test-database-migrations.yml
Multi-worker NATS validation, deprecated-role warnings, leader-controlled scheduler lifecycle, and all-in-one migration test execution are added.

Dashboard and supporting updates

Layer / File(s) Summary
Dashboard configuration and localization
dashboard/src/components/ui/variables-popover.tsx, dashboard/src/features/hosts/..., dashboard/src/features/subscriptions/..., dashboard/src/pages/..., dashboard/public/statics/locales/*
SNI and announcement URL fields support variables. Custom-variable handling is shared, and general settings no longer edits subscription custom variables.
Validation and regression coverage
tests/*
Tests cover security controls, subscription eligibility, group access, WireGuard allocation, NATS guards, leader behavior, shared memory, node synchronization, and connection concurrency.
Workflow, dependency, and logging updates
.github/workflows/*, pyproject.toml, app/jobs/record_usages.py
CI actions and registry login actions are upgraded. Dependency minimums and selected logging levels are updated.

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

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant AppFactory
  participant NATS
  participant Leader
  participant NodeManager
  Worker->>AppFactory: Start application
  AppFactory->>NATS: Validate multi-worker availability
  AppFactory->>Leader: Start role-scoped election
  Leader->>NATS: Acquire or renew lease
  Leader-->>AppFactory: Leadership state
  AppFactory->>NodeManager: Start coordinated node services
  NodeManager->>NATS: Publish node synchronization action
  NATS-->>NodeManager: Deliver action to other workers
Loading

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

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: immohammad20000, x0sina, m03ed

Poem

A rabbit checks each lease and claim,
Keeps bounded settings in the frame.
NATS hops from node to node,
WireGuard shares a lighter load.
Safe tokens, paths, and groups align.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.62% 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 summarizes the pull request's primary focus on subscription and administrator security hardening.
✨ 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 hardens subscription access control and administrative security across API, background jobs, webhook notifications, and deployment artifacts, with accompanying tests and a targeted migration to reduce default network exposure.

Changes:

  • Enforce subscription eligibility checks across config/raw/info/apps/header paths and bound subscription usage query ranges.
  • Require expiring admin JWTs and redact sensitive subscription credentials from webhook payloads.
  • Revoke deleted/expired user credentials on nodes during scheduled/manual cleanup, bind seeded client listeners to loopback, and safely quote installer paths in the systemd unit.

Reviewed changes

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

Show a summary per file
File Description
tests/test_security_hardening.py Adds focused regression tests for the new security hardening behaviors.
tests/api/test_user.py Adds an API-level test ensuring disabled users cannot download subscription configs while still rendering the subscription page safely.
install_service.sh Quotes/escapes install paths for systemd unit generation and rejects control-character paths.
config.py Adds validation bounds for JWT expiry minutes and user cleanup autodelete days.
app/utils/jwt.py Requires exp/iat/sub on admin JWTs and always sets an expiry when creating admin tokens.
app/operation/user.py Updates manual expired-user deletion flow to revoke users on nodes and handle dry-run earlier.
app/operation/subscription.py Adds eligibility gating for subscription output paths and enforces a maximum usage range window.
app/notification/webhook/init.py Redacts subscription_url and proxy_settings from webhook-encoded user payloads.
app/models/user.py Bounds auto_delete_in_days at the model level to prevent overflow/abuse.
app/jobs/remove_expired_users.py Revokes deleted users on nodes during scheduled expiry cleanup.
app/db/migrations/versions/a8c2d491e705_bind_xray_client_inbounds_to_loopback.py Migration updates seeded Xray subscription templates to listen on loopback.
app/db/crud/user.py Adds an upper bound to persisted auto-delete values to keep datetime arithmetic safe.

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

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

🧹 Nitpick comments (1)
app/notification/webhook/__init__.py (1)

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

Add bulk webhook redaction coverage.

The supplied security test covers notify, but bulk_notify has a separate enqueue path. Add a test with multiple users and assert that each queued payload omits subscription_url and proxy_settings.

🤖 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/notification/webhook/__init__.py` around lines 131 - 134, Add test
coverage for bulk_notify using multiple messages/users, exercising its separate
enqueue path and asserting every queued payload excludes subscription_url and
proxy_settings. Reuse the existing webhook notification test fixtures and
mocking patterns, and verify all messages are enqueued with the sensitive fields
redacted.
🤖 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/db/migrations/versions/a8c2d491e705_bind_xray_client_inbounds_to_loopback.py`:
- Around line 31-37: The migration’s exact string replacement misses valid JSON
whitespace variants of the listen field. Update the migration logic around the
client_templates content replacement to parse each template as JSON and set
every inbounds[].listen value from 0.0.0.0 to 127.0.0.1, then serialize the
modified document while preserving unaffected content and handling the validated
template format.

In `@app/operation/subscription.py`:
- Around line 91-96: Update user_subscription_by_user to call await
self.require_config_eligible(user) immediately after validated_user, ensuring
disabled, expired, and limited users are rejected before configuration
generation. Since user_subscription_by_id delegates to this method, add a
regression test covering rejection through the by-id path.

In `@app/operation/user.py`:
- Around line 1594-1596: Update both cleanup paths in app/operation/user.py
lines 1594-1596 and app/jobs/remove_expired_users.py line 19 to retain the tasks
created by sync_remove_user() and await them before deleting or reporting
deletion completion. Propagate or handle any dispatch failures so cleanup does
not return success when node removal fails; apply the same change at both sites.

In `@install_service.sh`:
- Around line 8-12: Update the INSTALL_DIR assignment to capture the complete
canonical output of pwd -P without command-substitution newline stripping,
preserving any trailing newline for the control-character validation. Also
detect and abort when canonicalization fails, before using INSTALL_DIR to
generate escaped systemd paths.

---

Nitpick comments:
In `@app/notification/webhook/__init__.py`:
- Around line 131-134: Add test coverage for bulk_notify using multiple
messages/users, exercising its separate enqueue path and asserting every queued
payload excludes subscription_url and proxy_settings. Reuse the existing webhook
notification test fixtures and mocking patterns, and verify all messages are
enqueued with the sensitive fields redacted.
🪄 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: 6d2148f2-ea37-4730-8e31-46eda30fed80

📥 Commits

Reviewing files that changed from the base of the PR and between e81877c and b9bafa2.

📒 Files selected for processing (12)
  • app/db/crud/user.py
  • app/db/migrations/versions/a8c2d491e705_bind_xray_client_inbounds_to_loopback.py
  • app/jobs/remove_expired_users.py
  • app/models/user.py
  • app/notification/webhook/__init__.py
  • app/operation/subscription.py
  • app/operation/user.py
  • app/utils/jwt.py
  • config.py
  • install_service.sh
  • tests/api/test_user.py
  • tests/test_security_hardening.py

Comment thread app/db/migrations/versions/a8c2d491e705_bind_xray_client_inbounds_to_loopback.py Outdated
Comment thread app/operation/subscription.py
Comment thread app/operation/user.py Outdated
Comment thread install_service.sh Outdated
@Rerowros
Rerowros force-pushed the codex/security-csv-10-49 branch from b9bafa2 to 209a3db Compare August 9, 2026 03:36
@Rerowros
Rerowros changed the base branch from main to dev August 9, 2026 03:36
@coderabbitai coderabbitai Bot added enhancement New feature or request and removed Backend DB labels 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: 10

Caution

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

⚠️ Outside diff range comments (2)
.github/workflows/test-database-migrations.yml (1)

16-16: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add an explicit permissions block.

The workflow declares no permissions: key, so every job inherits the repository default token scopes. These jobs only run migrations and tests. They need no write access to repository contents.

Add a least-privilege declaration 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 at line 16, Add a
workflow-level permissions block immediately under the top-level jobs
configuration in the migration workflow, granting only the minimal read access
required by migration and test jobs and leaving repository contents without
write access.

Source: Linters/SAST tools

app/node/__init__.py (1)

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

Prevent sibling workers from remote-stopping the old node on update_node.

remove_node already uses remote_stop=False for remove and disconnect sync messages, but app/node/manager_sync.py:51 still calls node_manager.update_node(db_node) for upsert. update_node() stops the old node with remote_stop=True, so each sibling worker can send a duplicate remote stop() for the same core. Add remote_stop support to update_node() and pass remote_stop=False from the upsert handler; the originator should be responsible for remote stops.

🤖 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 - 72, Add a remote_stop parameter to
update_node and pass it through to _shutdown_node, preserving the current
default behavior for existing callers. In the upsert handler of manager_sync,
call update_node with remote_stop=False so sibling workers do not issue
duplicate remote stops; the originating worker remains responsible for remote
shutdowns.
🧹 Nitpick comments (15)
app/nats/router.py (1)

15-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse the shared helpers instead of duplicating the predicate.

_router_enabled repeats the exact expression of app/nats/__init__.py is_multi_worker() combined with is_nats_enabled(). app/core/hosts.py and app/core/manager.py already compose the two helpers. Keep one definition so the gates cannot diverge.

♻️ Proposed refactor
-from config import nats_settings, runtime_settings, server_settings
+from app.nats import is_multi_worker, is_nats_enabled
+from config import nats_settings
 
 logger = get_logger("nats-router")
 
 
 def _router_enabled() -> bool:
-    multi_worker = runtime_settings.role.requires_nats or server_settings.workers > 1
-    return nats_settings.enabled and multi_worker
+    return is_nats_enabled() and is_multi_worker()
🤖 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/router.py` around lines 15 - 17, Update _router_enabled to reuse the
shared is_multi_worker() and is_nats_enabled() helpers from app.nats instead of
recomputing runtime_settings and server_settings predicates; preserve the
existing combined gate behavior and remove the duplicated local expression.
app/nats/leader.py (1)

29-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the module-level assert with an explicit check.

Python removes assert statements when the interpreter runs with -O. The timing invariant then disappears silently. Raise a ValueError instead, or move the constraint into a unit test.

🤖 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 29 - 30, Replace the module-level assert
validating HEARTBEAT_INTERVAL, HEARTBEAT_MAX_RETRIES, HEARTBEAT_RETRY_DELAY, and
DEFAULT_LEASE_SECONDS with an explicit runtime check that raises ValueError when
the lease timing invariant is violated. Keep the existing inequality and
validation at module initialization so it remains enforced under optimized
Python execution.
app/node/manager_sync.py (1)

46-72: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The ORM instance is used after the session closes.

Both the upsert branch and the connect branch load db_node inside async with GetDB() as db and then use it after the block exits. GetDB.__aexit__ closes the session, which detaches the instance.

The code works today because load_usage_logs=False avoids relationship access, no commit expires the attributes, and every attribute read afterwards is an already-loaded column. It breaks if anyone later adds a relationship read, for example db_node.core_config, inside node_manager.update_node or connect_node. The failure is DetachedInstanceError at runtime on sibling workers only.

Extend the session scope over the calls that consume db_node.

♻️ Proposed refactor for the `connect` branch
         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

Note that this holds the database connection for the duration of the connect. If that is not acceptable, copy the required fields into a plain object instead.

🤖 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 - 72, Extend the GetDB session
scope in both the upsert and connect branches so db_node remains attached while
node_manager.update_node and NodeOperation.connect_node consume it. Keep the
existing status checks and error handling, and ensure the connect branch
performs its node operations before the async context exits.
app/jobs/node_checker.py (2)

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

Add startup jitter to the per-worker interval loop.

Every uvicorn worker runs initialize_nodes at approximately the same moment, and Lines 287-292 start one _interval_loop per worker. All workers therefore run node_health_check for all nodes in lockstep on every interval. NODE_CHECK_SEM caps concurrency to 5 inside one worker, but it does not coordinate across workers, so the effective burst is 5 × worker count against the nodes and the database.

A random initial delay spreads the load.

♻️ 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 all uvicorn processes do not check in lockstep.
+    await asyncio.sleep(random.uniform(0, min(seconds, 30)))
     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 252 - 262, Add startup jitter to
_interval_loop by importing random and awaiting a random initial delay before
entering its existing while loop, using a bounded delay appropriate to the
interval (seconds). Preserve the current recurring execution, exception logging,
and sleep behavior after the initial stagger.

137-162: 🚀 Performance & Scalability | 🔵 Trivial

Consider the KV read cost of the unconditional lifecycle fetch.

Line 139 calls node.get_lifecycle_state() for every node on every health check, before the branch that needs it. In multi-worker mode this resolves to a NATS KV read. Lines 285-292 start a health loop on every uvicorn worker, so the read count is nodes × workers per interval.

The value is also used at Lines 174-175 and 199-200, so it cannot simply move inside the NOT_CONNECTED branch. Consider adding a metric for lifecycle KV read volume, and confirm that job_settings.core_health_check_interval is sized for the node count you expect.

🤖 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 137 - 162, Instrument the
lifecycle-state retrieval around node.get_lifecycle_state() with a metric
tracking KV read volume, including node/worker context if available. Keep the
shared state available for its later uses at the referenced lifecycle checks,
and review or validate core_health_check_interval against expected node and
uvicorn worker counts so the resulting read rate is acceptable.
app/operation/node.py (4)

321-332: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a short delay before the 409 attach retry.

The 409 response means another worker holds the lifecycle lease and the start is still in progress. The retry at Line 323 runs immediately, so the remote core is very likely still starting and pg_node.info() fails again. The node is then recorded as error at Line 338 even though the start succeeds moments later.

A short sleep before the retry raises the success rate.

♻️ Proposed refactor
             if e.code == 409:
                 # Another worker holds the lifecycle lease; try attach once more.
+                await asyncio.sleep(1)
                 attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
🤖 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 321 - 332, In the 409 branch of the node
start flow, add a short asynchronous delay before invoking
NodeOperation._attach_if_running. Keep the existing single retry and success
response unchanged after the delay, while preserving the current error handling
when attachment still fails.

625-631: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Clear bridge memory after the siblings have removed the node.

_remove_node_local clears the shared lifecycle and user-sync keys, and _remove_node_sync publishes the remove message afterwards. Sibling workers still hold the node object at that moment. A sibling health check running in that window calls update_observed_lifecycle, and NatsNodeLifecycleCoordinator.update_observed recreates the lifecycle document when the document is missing and expected_epoch is None. The sibling's own clear_bridge_memory_for_node then removes it again, so the state converges, but a stale lifecycle document can exist briefly for a deleted node.

Publishing remove before clearing shared memory narrows the window.

🤖 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 625 - 631, Update _remove_node_sync to
publish the "remove" message immediately after _remove_node_local’s node removal
step, then clear bridge memory only after sibling workers have processed the
removal; adjust _remove_node_local or inline its cleanup so
clear_bridge_memory_for_node runs after publish_node_sync while preserving the
existing node_manager removal.

704-709: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Batch the bulk connect fan-out into one message.

_connect_nodes_bulk_sync publishes one connect message per node. Each sibling worker handles every message independently in handle_node_message, and app/node/manager_sync.py Line 58 opens a fresh GetDB() session and calls _get_core_users_map for each one. For N nodes and W sibling workers, a bulk restart produces N messages and N database sessions per worker.

_connect_nodes_bulk_remote at Line 713 already uses a batched payload with node_ids. Use the same shape here so siblings resolve cores and users once.

♻️ Proposed refactor sketch
     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)
+        node_ids = [
+            node.id
+            for node in nodes
+            if node is not None and node.status not in (NodeStatus.disabled, NodeStatus.limited)
+        ]
+        if node_ids:
+            await publish_node_sync_bulk("connect_bulk", node_ids)

This requires a connect_bulk branch in handle_node_message that resolves cores and users once for the whole set.

🤖 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_sync to publish one connect_bulk message containing the
eligible node IDs instead of one connect message per node, matching
_connect_nodes_bulk_remote’s payload shape. Add the corresponding connect_bulk
branch in handle_node_message and resolve cores and users once for the full node
set before processing it.

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

Raise the log level for attach failures.

_attach_if_running catches every exception and logs at debug. The caller cannot distinguish "the node is not running" from "the attach RPC failed". The 409 recovery path at Line 321 depends on this method succeeding, so a persistent attach failure produces a silent reconnect loop with no visible cause at the default log level.

Log at warning when the failure occurs after pg_node.info() returned a valid response, and keep debug for the expected not-running case.

🤖 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 256 - 258, Update _attach_if_running to
distinguish expected not-running failures from attach RPC failures: retain debug
logging when pg_node.info() does not return a valid response, but log at warning
when an exception occurs after a valid pg_node.info() response and the attach
operation fails. Preserve the existing exception handling and return None
behavior.
app/node/nats_memory.py (2)

255-297: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log CAS exhaustion in try_acquire.

try_acquire returns None for two different conditions: another worker holds a live lease, and 32 CAS attempts all conflicted. release, heartbeat, and update_observed each log a warning when their CAS loop is exhausted. try_acquire does not. A conflict storm is then invisible in the logs and appears as normal lease contention.

♻️ Proposed fix
             if await kv_cas_json(self._kv, key, doc, rev):
                 return lease
+        logger.warning("Lifecycle try_acquire CAS exhausted for node_id=%s key=%s", node_id, key)
         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/node/nats_memory.py` around lines 255 - 297, Update try_acquire’s CAS
retry path to emit a warning when all 32 attempts fail, distinguishing CAS
exhaustion from the existing early return for an active lease. Use the same
warning-log convention as release, heartbeat, and update_observed, then return
None as before.

119-128: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sequential per-key KV round trips across NatsUserSyncStore. Every method that touches multiple keys awaits one KV operation at a time. Each kv_put_json costs at least two round trips, and each guarded delete costs two. For a node with N pending users, a full sync or a full clear costs O(N) sequential NATS round trips on the user-sync path. The shared root cause is the absence of a bounded-concurrency helper for per-key operations; add one and reuse it in all four methods.

  • app/node/nats_memory.py#L119-L128: gather the kv_put_json calls in enqueue_users under a semaphore, after validating all value sizes up front.
  • app/node/nats_memory.py#L195-L206: gather the deletes in ack_users under the same semaphore, and drop the preceding read as noted separately.
  • app/node/nats_memory.py#L208-L223: gather the per-item requeue-then-delete pairs in requeue_users, keeping the write-before-delete order inside each pair.
  • app/node/nats_memory.py#L225-L241: gather the pending and claimed deletes in clear under the same semaphore.
🤖 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 119 - 128, Sequential KV operations in
NatsUserSyncStore need bounded concurrency. Add a shared semaphore-based per-key
operation helper and reuse it in app/node/nats_memory.py:119-128 (enqueue_users:
validate all value sizes first, then gather kv_put_json calls),
app/node/nats_memory.py:195-206 (ack_users: gather deletes and remove the
preceding read), app/node/nats_memory.py:208-223 (requeue_users: gather per-item
operations while preserving write-before-delete order), and
app/node/nats_memory.py:225-241 (clear: gather pending and claimed deletes).
app/node/__init__.py (1)

49-58: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log the failure in _shutdown_node.

The except Exception: pass block discards every error from set_health and stop(). A failed remote stop() leaves the remote core running and produces no record. This PR changes remote-stop behavior across workers, so a silent swallow here hides exactly the class of lifecycle bug the change introduces.

♻️ Proposed refactor
         try:
             await node.set_health(Health.INVALID)
             if remote_stop:
                 await node.stop()
-        except Exception:
-            pass
+        except Exception as exc:
+            self.logger.debug("Node shutdown failed (remote_stop=%s): %s", remote_stop, exc)
🤖 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 49 - 58, Update `_shutdown_node` to log
exceptions raised by `set_health` or the conditional remote `stop()` call
instead of silently swallowing them; preserve the existing shutdown flow and
exception handling while including enough context to identify the affected node
and operation.
app/nats/kv_cas.py (3)

108-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the test double with NATS KV semantics.

Two divergences exist between MemoryCasKv and a real NATS KV bucket:

  • Line 108: delete returns False for a missing key. NATS KV delete writes a delete marker and does not signal "missing". Tests that assert a False return will not match production behavior.
  • Line 124: keys(filters=...) uses substring matching (f in key). NATS treats filters as subject filters with . token wildcards. A test that passes a filter passes for the wrong reason.

Additionally, get after delete raises KeyNotFoundError here, while NATS raises KeyDeletedError. kv_get_json handles both, so the current callers are unaffected.

🤖 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 108 - 127, Update MemoryCasKv.delete to
match NATS semantics by recording a delete marker for missing or existing keys
instead of returning False, and ensure subsequent get raises KeyDeletedError for
deleted keys. Replace substring matching in keys with NATS subject-filter
matching using dot-delimited tokens and wildcards, while preserving NoKeysError
when nothing matches.

52-58: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Add a small backoff between CAS retries.

kv_put_json retries 32 times with no delay. Concurrent writers on the same key produce a tight loop of KV round trips. A short randomized sleep reduces contention and NATS load.

♻️ Proposed refactor
+import asyncio
+import random
+
 async def kv_put_json(kv: CasKv, key: str, value: dict[str, Any]) -> None:
     """Upsert JSON with CAS retries (latest value wins)."""
-    for _ in range(32):
+    for attempt in range(32):
         _, rev = await kv_get_json(kv, key)
         if await kv_cas_json(kv, key, value, rev):
             return
+        await asyncio.sleep(min(0.05, 0.002 * (attempt + 1)) * random.random())
     raise RuntimeError(f"failed to put NATS KV key={key} after CAS retries")
🤖 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 52 - 58, Add a short randomized asynchronous
sleep between failed CAS attempts in kv_put_json, while preserving the immediate
return on success and the existing retry limit and error behavior. Use the
module’s established async timing/randomness utilities if available.

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

Use a server-side key-listing filter.

kv_list_keys calls the protocol method without a prefix, then filters every bucket key in Python. Replace it with nats-py key listing using list_keys() or keys support that matches f"{prefix}>", then update MemoryCasKv to implement the same semantics instead of substring filtering.

🤖 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 request
prefix-filtered keys server-side using the nats-py list_keys or equivalent
support with the f"{prefix}>" pattern, while preserving the empty result for
NoKeysError. Update MemoryCasKv to implement the same prefix semantics and avoid
substring-based filtering.
🤖 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/core/hosts.py`:
- Around line 74-75: Normalize inbound_config.get("finalmask") through the
FinalMask model before building fms, so model and dict inputs serialize with the
same deterministic key set rather than omitting unset fields inconsistently.
Update the final_mask_settings/fms serialization flow while preserving compact
JSON in finalmask_link, and ensure cached NATS KV payloads are refreshed or
invalidated when hosts are re-added.

In `@app/nats/leader.py`:
- Around line 60-71: Update _ensure_kv so that when get_or_create_kv_bucket
returns None, it closes the client stored in _nc before returning, while
preserving the existing successful bucket path.

In `@app/node/__init__.py`:
- Around line 76-82: Update the node shutdown lifecycle around
NodeManager.__init__ and remove_node: retain each asyncio.create_task result in
a self._pending_shutdowns set, and register completion cleanup so finished tasks
are removed. Ensure _remove_node_local does not clear bridge memory until the
corresponding shutdown task has completed, preventing lifecycle state from being
recreated by late stop writes.

In `@app/node/nats_memory.py`:
- Around line 345-353: Update the docstring of has_active_lease to describe any
unexpired lifecycle lease, without implying ownership by another worker.
Preserve the existing lease lookup and expiration behavior used by
process_node_health_check.
- Around line 436-444: Update shutdown_bridge_memory to serialize shutdown with
_init_lock and suppress exceptions from _nc.close(), matching
ensure_bridge_memory’s guarded close behavior. Hold the lock while closing the
client and clearing _nc, _user_sync_kv, _lifecycle_kv, _user_sync_store, and
_lifecycle_coordinator so a concurrent ensure_bridge_memory cannot repopulate
them during shutdown.
- Around line 155-191: In claim_users, move the time.time() call inside the
pending-key loop so each claimed_value gets a fresh expires_at timestamp.
Replace the comment near kv_cas_json with one identifying delete(pending_key,
last=rev) as the revision-based exclusion that allows only one worker to claim a
pending entry; preserve that delete guard and existing cleanup behavior.
- Around line 119-128: Update enqueue_users to build and validate every
deduplicated user payload with _ensure_value_size before performing any writes,
so validation errors leave the batch unwritten. Then replace the sequential
kv_put_json loop with bounded-concurrency writes, reusing the existing key/value
construction and ensuring all scheduled writes are awaited before returning.

In `@app/operation/node.py`:
- Around line 260-266: Update _start_or_attach_node so it delegates the
lifecycle-state decision to _attach_if_running instead of restricting attachment
to observed HEALTHY. Remove the HEALTHY-only guard and invoke _attach_if_running
for the relevant state, preserving its existing predicate so STARTING nodes are
attached before attempting pg_node.start.
- Around line 644-664: Update connect_single in the bulk connection flow to
catch unexpected exceptions from node_manager.update_node and connect_node,
converting each failure into a per-node error result instead of allowing
asyncio.gather to abort. Preserve the existing NodeAPIError details where
available, provide a safe error message for other exceptions, and ensure results
still reach bulk_update_node_status so every node is processed and notified.

In
`@dashboard/src/features/subscriptions/components/subscription-settings-schema.ts`:
- Line 105: Update the announce_url schema validation around announce_url so
non-empty values are validated as URLs after template expansion, while
preserving acceptance of empty strings and optional values. Reuse the existing
_format_announce_url() behavior or its shared validation mechanism to reject
malformed URLs and disallowed schemes before persistence and publication.

---

Outside diff comments:
In @.github/workflows/test-database-migrations.yml:
- Line 16: Add a workflow-level permissions block immediately under the
top-level jobs configuration in the migration workflow, granting only the
minimal read access required by migration and test jobs and leaving repository
contents without write access.

In `@app/node/__init__.py`:
- Around line 60-72: Add a remote_stop parameter to update_node and pass it
through to _shutdown_node, preserving the current default behavior for existing
callers. In the upsert handler of manager_sync, call update_node with
remote_stop=False so sibling workers do not issue duplicate remote stops; the
originating worker remains responsible for remote shutdowns.

---

Nitpick comments:
In `@app/jobs/node_checker.py`:
- Around line 252-262: Add startup jitter to _interval_loop by importing random
and awaiting a random initial delay before entering its existing while loop,
using a bounded delay appropriate to the interval (seconds). Preserve the
current recurring execution, exception logging, and sleep behavior after the
initial stagger.
- Around line 137-162: Instrument the lifecycle-state retrieval around
node.get_lifecycle_state() with a metric tracking KV read volume, including
node/worker context if available. Keep the shared state available for its later
uses at the referenced lifecycle checks, and review or validate
core_health_check_interval against expected node and uvicorn worker counts so
the resulting read rate is acceptable.

In `@app/nats/kv_cas.py`:
- Around line 108-127: Update MemoryCasKv.delete to match NATS semantics by
recording a delete marker for missing or existing keys instead of returning
False, and ensure subsequent get raises KeyDeletedError for deleted keys.
Replace substring matching in keys with NATS subject-filter matching using
dot-delimited tokens and wildcards, while preserving NoKeysError when nothing
matches.
- Around line 52-58: Add a short randomized asynchronous sleep between failed
CAS attempts in kv_put_json, while preserving the immediate return on success
and the existing retry limit and error behavior. Use the module’s established
async timing/randomness utilities if available.
- Around line 61-66: Update kv_list_keys to request prefix-filtered keys
server-side using the nats-py list_keys or equivalent support with the
f"{prefix}>" pattern, while preserving the empty result for NoKeysError. Update
MemoryCasKv to implement the same prefix semantics and avoid substring-based
filtering.

In `@app/nats/leader.py`:
- Around line 29-30: Replace the module-level assert validating
HEARTBEAT_INTERVAL, HEARTBEAT_MAX_RETRIES, HEARTBEAT_RETRY_DELAY, and
DEFAULT_LEASE_SECONDS with an explicit runtime check that raises ValueError when
the lease timing invariant is violated. Keep the existing inequality and
validation at module initialization so it remains enforced under optimized
Python execution.

In `@app/nats/router.py`:
- Around line 15-17: Update _router_enabled to reuse the shared
is_multi_worker() and is_nats_enabled() helpers from app.nats instead of
recomputing runtime_settings and server_settings predicates; preserve the
existing combined gate behavior and remove the duplicated local expression.

In `@app/node/__init__.py`:
- Around line 49-58: Update `_shutdown_node` to log exceptions raised by
`set_health` or the conditional remote `stop()` call instead of silently
swallowing them; preserve the existing shutdown flow and exception handling
while including enough context to identify the affected node and operation.

In `@app/node/manager_sync.py`:
- Around line 46-72: Extend the GetDB session scope in both the upsert and
connect branches so db_node remains attached while node_manager.update_node and
NodeOperation.connect_node consume it. Keep the existing status checks and error
handling, and ensure the connect branch performs its node operations before the
async context exits.

In `@app/node/nats_memory.py`:
- Around line 255-297: Update try_acquire’s CAS retry path to emit a warning
when all 32 attempts fail, distinguishing CAS exhaustion from the existing early
return for an active lease. Use the same warning-log convention as release,
heartbeat, and update_observed, then return None as before.
- Around line 119-128: Sequential KV operations in NatsUserSyncStore need
bounded concurrency. Add a shared semaphore-based per-key operation helper and
reuse it in app/node/nats_memory.py:119-128 (enqueue_users: validate all value
sizes first, then gather kv_put_json calls), app/node/nats_memory.py:195-206
(ack_users: gather deletes and remove the preceding read),
app/node/nats_memory.py:208-223 (requeue_users: gather per-item operations while
preserving write-before-delete order), and app/node/nats_memory.py:225-241
(clear: gather pending and claimed deletes).

In `@app/operation/node.py`:
- Around line 321-332: In the 409 branch of the node start flow, add a short
asynchronous delay before invoking NodeOperation._attach_if_running. Keep the
existing single retry and success response unchanged after the delay, while
preserving the current error handling when attachment still fails.
- Around line 625-631: Update _remove_node_sync to publish the "remove" message
immediately after _remove_node_local’s node removal step, then clear bridge
memory only after sibling workers have processed the removal; adjust
_remove_node_local or inline its cleanup so clear_bridge_memory_for_node runs
after publish_node_sync while preserving the existing node_manager removal.
- Around line 704-709: Update _connect_nodes_bulk_sync to publish one
connect_bulk message containing the eligible node IDs instead of one connect
message per node, matching _connect_nodes_bulk_remote’s payload shape. Add the
corresponding connect_bulk branch in handle_node_message and resolve cores and
users once for the full node set before processing it.
- Around line 256-258: Update _attach_if_running to distinguish expected
not-running failures from attach RPC failures: retain debug logging when
pg_node.info() does not return a valid response, but log at warning when an
exception occurs after a valid pg_node.info() response and the attach operation
fails. Preserve the existing exception handling and return None 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: 0484732a-9e20-429c-b497-63e3b77b8a44

📥 Commits

Reviewing files that changed from the base of the PR and between b9bafa2 and 209a3db.

⛔ 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
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/operation/user.py
  • app/operation/subscription.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 (10)
app/core/hosts.py (1)

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Trace finalmask_link producers and consumers, and the dict-source branch.
set -euo pipefail

rg -n -C 5 'finalmask_link|finalmask' --glob '*.py'
rg -n -C 3 'finalmask' --glob '*.ts' --glob '*.tsx' --glob '*.json'

Repository: PasarGuard/panel

Length of output: 224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

echo "== hosts.py occurrences =="
rg -n -C 8 'finalmask_link|final_mask_settings|exclude_none|model_dump|ModelDump\(' -g '*.py' .

echo "== python source outline candidates =="
for f in $(git ls-files '*.py' | xargs grep -l 'finalmask_link|final_mask_settings|HostManager|finalmask' 2>/dev/null || true); do
  echo "--- $f"
  wc -l "$f"
  ast-grep outline "$f" 2>/dev/null | sed -n '1,120p' || true
done

echo "== relevant file slices =="
python3 - <<'PY'
import pathlib, subprocess
names = subprocess.check_output(['git','ls-files','*.py'], text=True).splitlines()
for n in names:
    if any(v in n for v in ['core/hosts.py','host','nats','config']):
        print('---', n)
        lines = pathlib.Path(n).read_text(errors='replace').splitlines()
        starts = [i for i,l in enumerate(lines,1) if 'finalmask' in l or 'HostManager' in l or 'nats' in l or 'finalmask_link' in l]
        for s in starts:
            lo=max(1,s-8); hi=min(len(lines),s+12)
            print(f'@@ lines {lo}-{hi}')
            for ii in range(lo,hi+1):
                print(f'{ii:4}: {lines[ii-1]}')
PY

Repository: PasarGuard/panel

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
from dataclasses import dataclass, asdict
from enum import Enum

class FinalMaskTcpType(str, Enum):
    FRAGMENT = "fragment"
    REALM = "realm"

`@dataclass`
class FinalMaskTcpLayer:
    type: FinalMaskTcpType
    settings: dict

class FinalMaskTcpSettings:
    def model_dump(self, **kwargs):
        return asdict(self)

`@dataclass`
class FinalMaskTcpLayerNew:
    type: FinalMaskTcpType
    settings: FinalMaskTcpSettings

old_format = {
    "tcp": [{
        "type": "fragment",
        "settings": {
            "packets": "tlshello",
            "length": "10-15",
            "interval": "5-5",
        }
    }]
}

new_format = FinalMaskTcpLayerNew(
    type=FinalMaskTcpType.FRAGMENT,
    settings=FinalMaskTcpSettings()
).model_dump()
new_format["tcp"] = new_format.pop("settings").model_dump()

old = FinalMaskTcpLayer(
    type=FinalMaskTcpType.FRAGMENT,
    settings=new_format["tcp"].copy()
)

new = FinalMaskTcpLayerNew(
    type=FinalMaskTcpType.FRAGMENT,
    settings=new_format["tcp"].copy()
)

print("old dict includes omitted fields:", repr(json.dumps(old_format, separators=(",", ":"))))
print("old dict excludes omitted fields:", repr(json.dumps({k:v for k,v in old_format.items() if k != "tcp"} + [{"tcp": [{k:v for k,v in old_format["tcp"][0].items() if k != "length"}]}]), separators=(",", ":"))))
PY

echo "== app/core/hosts.py relevant sections =="
sed -n '1,140p' app/core/hosts.py
sed -n '420,460p' app/core/hosts.py
sed -n '600,640p' app/core/hosts.py

echo "== SubscriptionInboundData definition =="
rg -n -C 20 'class SubscriptionInboundData|finalmask_link|finalmask' app/db/crud/core.py app/d

Repository: PasarGuard/panel

Length of output: 643


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
# Behavioral probe of the changed producer branch.
from enum import Enum
import json
from typing import Any

class FinalMaskTcpType(str, Enum):
    FRAGMENT = "fragment"

class FinalMaskTcpSettings:
    def model_dump(self, **kwargs):
        # Pydantic exclude_none=True removes None attributes;
        # this represents the model branch with None settings omitted.
        return {}

class FinalMaskTcpLayer:
    type: FinalMaskTcpType
    settings: FinalMaskTcpSettings

    def model_dump(self, **kwargs):
        out: dict[str, Any]
        if kwargs.get("by_alias") is True:
            out = {}
        else:
            out = {}
        out["type"] = self.type.value if isinstance(self.type, Enum) else self.type
        sub = self.settings.model_dump(**kwargs)
        out["settings"] = sub
        if not kwargs.get("exclude_none") or any(v is not None for v in out.values()):
            pass

    def model_dump(self, *, by_alias: bool = False, exclude_none: bool = False, ...):
        kwargs = {k: v for k, v in globals().items() if k not in ("self", "kwargs", "...")}
        return self.model_dump(by_alias=by_alias, exclude_none=exclude_none)

old_dict_branch = {
    "tcp": [{
        "type": "fragment",
        "settings": {
            "packets": "tlshello",
            "length": "10-15",
            "interval": "5-5",
        }
    }]
}

class FinalMaskTcpSettingsWithNone:
    def model_dump(self, *, by_alias=False, exclude_none=False, **extra):
        sub = {"packets": "tlshello", "length": "10-15", "interval": "5-5"}
        if exclude_none is True:
            return {}
        return sub

class FinalMaskTcpLayerWithNone:
    def model_dump(self, *, by_alias=False, exclude_none=False, **extra):
        sub = self.settings.model_dump(by_alias=by_alias, exclude_none=exclude_none, **extra)
        return {"settings": sub, "type": "fragment"}

final_mask_settings_model_branch = FinalMaskTcpLayerWithNone(settings=FinalMaskTcpSettingsWithNone())
fms_model = final_mask_settings_model_branch.model_dump(by_alias=False, exclude_none=True)
fms_dict = old_dict_branch

print("model branch with exclude_none=True:", fms_model)
print("dict branch with exclude_none=False:", fms_dict)
print("model branch JSON:", json.dumps(fms_model, separators=(",", ":")))
print("dict branch JSON:", json.dumps(fms_dict, separators=(",", ":")))
PY

echo "== app/core/hosts.py relevant sections =="
sed -n '62,122p' app/core/hosts.py
sed -n '438,456p' app/core/hosts.py

echo "== SubscriptionInboundData definition =="
rg -n -C 25 'class SubscriptionInboundData|finalmask_link|finalmask' app/db/crud/core.py app/db/crud/host.py app/models/subscription.py

Repository: PasarGuard/panel

Length of output: 376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic key-set behavior =="
python3 - <<'PY'
import json

model_branch = {}       # FinalMask model branch omits all None finalmask fields
dict_branch = {
    "tcp": [{
        "type": "fragment",
        "settings": {
            "packets": "tlshello",
            "length": "10-15",
            "interval": "5-5",
        }
    }]
}

for name, value in [("model branch", model_branch), ("dict branch", dict_branch)]:
    print(f"{name}: {json.dumps(value, separators=(',', ':'))}")
PY

echo "== app/core/hosts.py relevant sections =="
sed -n '62,122p' app/core/hosts.py
sed -n '438,456p' app/core/hosts.py

echo "== SubscriptionInboundData definition =="
rg -n -C 30 'class SubscriptionInboundData|finalmask_link|finalmask' app/db/crud/core.py app/models/subscription.py

Repository: PasarGuard/panel

Length of output: 10702


Ensure finalmask_link remains stable for both model and dict inputs.

Line 72 checks if final_mask_settings, and line 74 only calls model_dump for FinalMask; if the inbound config supplies finalmask as a dict, fms stays a dict, but the model branch now omits missing finalmask fields from the JSON. Make the key set deterministic across both sources or normalize inbound_config.get("finalmask") with pydantic before serialization. Also account for cached NATS KV state that can carry the pre-change payload until hosts are re-added.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 74-74: use jsonify instead of json.dumps for JSON output
Context: json.dumps(fms, separators=(",", ":"))
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 `@app/core/hosts.py` around lines 74 - 75, Normalize
inbound_config.get("finalmask") through the FinalMask model before building fms,
so model and dict inputs serialize with the same deterministic key set rather
than omitting unset fields inconsistently. Update the final_mask_settings/fms
serialization flow while preserving compact JSON in finalmask_link, and ensure
cached NATS KV payloads are refreshed or invalidated when hosts are re-added.
app/nats/leader.py (1)

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

Close the NATS client when bucket creation fails.

_ensure_kv assigns _nc before it creates the bucket. If get_or_create_kv_bucket returns None, _kv stays None but the connection stays open. The reclaim loop in app/app_factory.py calls start_job_leader every HEARTBEAT_INTERVAL seconds, so try_become_leader re-enters _ensure_kv and overwrites _nc with a new client each time. Each overwrite leaks the previous connection.

🔒️ Proposed fix to release the client on failure
     _nc = await create_nats_client()
     if _nc is None:
         return None
     js = await get_jetstream_context(_nc)
     _kv = await get_or_create_kv_bucket(js, nats_settings.scheduler_leader_kv_bucket)
+    if _kv is None:
+        with contextlib.suppress(Exception):
+            await _nc.close()
+        _nc = None
     return _kv
🤖 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 60 - 71, Update _ensure_kv so that when
get_or_create_kv_bucket returns None, it closes the client stored in _nc before
returning, while preserving the existing successful bucket path.
app/node/__init__.py (1)

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

Retain the shutdown task and consider the ordering against bridge-memory clearing.

Two points:

  • asyncio.create_task result is not stored. The event loop keeps only a weak reference to a task, so a task can be garbage collected before it completes. Keep a reference until the task finishes.
  • _remove_node_local in app/operation/node.py Line 625 awaits remove_node and then calls clear_bridge_memory_for_node. Because the shutdown runs detached, the lifecycle key is deleted while stop() may still be running. NatsNodeLifecycleCoordinator.update_observed recreates the document when the document is missing and expected_epoch is None, so a late write can resurrect lifecycle state for a removed node.
🛡️ Proposed fix for the task reference
     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))
+        self._pending_shutdowns.add(task)
+        task.add_done_callback(self._pending_shutdowns.discard)

Add self._pending_shutdowns: set[asyncio.Task] = set() to __init__.

🤖 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 the node shutdown
lifecycle around NodeManager.__init__ and remove_node: retain each
asyncio.create_task result in a self._pending_shutdowns set, and register
completion cleanup so finished tasks are removed. Ensure _remove_node_local does
not clear bridge memory until the corresponding shutdown task has completed,
preventing lifecycle state from being recreated by late stop writes.
app/node/nats_memory.py (4)

119-128: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Enqueue users concurrently and validate sizes before writing.

Two concerns exist in enqueue_users:

  • The loop awaits kv_put_json once per user. Each call performs at least one get plus one create/update round trip. A full sync of N users costs 2N sequential NATS round trips on the user-sync hot path.
  • _ensure_value_size raises RuntimeError inside the loop. One oversized user aborts the call after part of the batch is already written. The remaining users are dropped without a retry path.

Validate all values first, then write with a bounded concurrency.

♻️ Proposed refactor
     async def enqueue_users(self, node_id: str, users: list[User]) -> None:
         if not users:
             return
         # Latest payload per email wins (dedupe across the batch first).
         by_email = {user.email: user for user in users}
-        for email, user in by_email.items():
-            key = self._pending_key(node_id, email)
-            value = {"email": email, "user": _b64_user(user)}
-            self._ensure_value_size(key, value)
-            await kv_put_json(self._kv, key, value)
+        entries: list[tuple[str, dict[str, Any]]] = []
+        for email, user in by_email.items():
+            key = self._pending_key(node_id, email)
+            value = {"email": email, "user": _b64_user(user)}
+            self._ensure_value_size(key, value)
+            entries.append((key, value))
+
+        sem = asyncio.Semaphore(16)
+
+        async def _write(key: str, value: dict[str, Any]) -> None:
+            async with sem:
+                await kv_put_json(self._kv, key, value)
+
+        await asyncio.gather(*(_write(key, value) for key, value in entries))
📝 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 enqueue_users(self, node_id: str, users: list[User]) -> None:
        if not users:
            return
        # Latest payload per email wins (dedupe across the batch first).
        by_email = {user.email: user for user in users}
        entries: list[tuple[str, dict[str, Any]]] = []
        for email, user in by_email.items():
            key = self._pending_key(node_id, email)
            value = {"email": email, "user": _b64_user(user)}
            self._ensure_value_size(key, value)
            entries.append((key, value))

        sem = asyncio.Semaphore(16)

        async def _write(key: str, value: dict[str, Any]) -> None:
            async with sem:
                await kv_put_json(self._kv, key, value)

        await asyncio.gather(*(_write(key, value) for key, value in entries))
🤖 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 119 - 128, Update enqueue_users to
build and validate every deduplicated user payload with _ensure_value_size
before performing any writes, so validation errors leave the batch unwritten.
Then replace the sequential kv_put_json loop with bounded-concurrency writes,
reusing the existing key/value construction and ensuring all scheduled writes
are awaited before returning.

155-191: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Correct the exclusion comment and refresh the lease timestamp per claim.

Two points in claim_users:

  • Line 177 states that create-only prevents two workers from claiming the same token key. That is not the mechanism. token embeds a fresh uuid4() per iteration, so kv_cas_json(self._kv, claimed_key, claimed_value, 0) can never collide across workers. The real exclusion is await self._kv.delete(pending_key, last=rev) on Line 180, where only one worker wins the revision. Keep that delete, and fix the comment so a later change does not remove the guard that actually works.
  • Line 155 captures now once before the loop. Every claim in the batch derives expires_at from that single timestamp. A slow loop shortens the effective lease for the later claims. Read the clock per iteration.
♻️ Proposed fix
         result: list[ClaimedUser] = []
-        now = time.time()
         for pending_key in await kv_list_keys(self._kv, self._pending_prefix(node_id)):
@@
             token = f"{worker_id}:{uuid4()}"
             claimed_key = self._claimed_key(node_id, token)
             claimed_value = {
                 "token": token,
                 "email": email,
                 "user": user_b64,
-                "expires_at": now + lease_seconds,
+                "expires_at": time.time() + lease_seconds,
             }
             self._ensure_value_size(claimed_key, claimed_value)
             try:
-                # Create-only so two workers cannot claim into the same token key.
+                # Token is unique per iteration, so this create always succeeds.
+                # Exclusion comes from the revision-guarded delete of pending_key below:
+                # only one worker can delete at revision `rev`.
                 if not await kv_cas_json(self._kv, claimed_key, claimed_value, 0):
                     continue
                 await self._kv.delete(pending_key, last=rev)
🤖 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 155 - 191, In claim_users, move the
time.time() call inside the pending-key loop so each claimed_value gets a fresh
expires_at timestamp. Replace the comment near kv_cas_json with one identifying
delete(pending_key, last=rev) as the revision-based exclusion that allows only
one worker to claim a pending entry; preserve that delete guard and existing
cleanup behavior.

345-353: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the has_active_lease docstring.

The docstring says "another worker still holds an unexpired lifecycle lease". The method does not compare the lease owner against the calling worker. It returns True for a lease held by the calling worker as well. process_node_health_check in app/jobs/node_checker.py relies on this to skip a reconnect while a start is in flight, including a start owned by the same process, so the behavior is correct. Only the wording is wrong.

📝 Proposed fix
     async def has_active_lease(self, node_id: str) -> bool:
-        """True when another worker still holds an unexpired lifecycle lease."""
+        """True when any worker, including this one, holds an unexpired lifecycle lease."""
📝 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 has_active_lease(self, node_id: str) -> bool:
        """True when any worker, including this one, holds an unexpired lifecycle lease."""
        doc, _ = await kv_get_json(self._kv, self._key(node_id))
        if doc is None:
            return False
        lease_data = doc.get("lease")
        if not lease_data:
            return False
        return float(lease_data.get("expires_at", 0)) > time.time()
🤖 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 345 - 353, Update the docstring of
has_active_lease to describe any unexpired lifecycle lease, without implying
ownership by another worker. Preserve the existing lease lookup and expiration
behavior used by process_node_health_check.

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

Guard the client close during shutdown.

shutdown_bridge_memory calls await _nc.close() without exception handling. app/jobs/node_checker.py Line 320 registers this function as an on_shutdown hook. If close() raises, for example when the connection is already closed or is draining, the exception propagates out of the shutdown hook. ensure_bridge_memory already wraps the same call in contextlib.suppress(Exception) on its failure path, so the two paths behave differently for the same operation.

The function also does not take _init_lock. A concurrent ensure_bridge_memory can repopulate the globals after this function clears them, which leaks the client.

🛡️ Proposed fix
 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
+    async with _init_lock:
+        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
📝 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 shutdown_bridge_memory() -> None:
    global _nc, _user_sync_kv, _lifecycle_kv, _user_sync_store, _lifecycle_coordinator
    async with _init_lock:
        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
🤖 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 serialize shutdown with _init_lock and suppress
exceptions from _nc.close(), matching ensure_bridge_memory’s guarded close
behavior. Hold the lock while closing the client and clearing _nc,
_user_sync_kv, _lifecycle_kv, _user_sync_store, and _lifecycle_coordinator so a
concurrent ensure_bridge_memory cannot repopulate them during shutdown.
app/operation/node.py (2)

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

Attach on STARTING as well, not only on HEALTHY.

_attach_if_running already accepts a broader predicate. Lines 238-242 skip attach only when the state is neither HEALTHY nor STARTING and the desired state is not HEALTHY. _start_or_attach_node narrows this at Line 263 to state.observed is LifecycleStatus.HEALTHY.

Consequence: when worker A holds a START lease and the observed state is STARTING, worker B skips attach and calls pg_node.start(...). That collides with A's lease. The collision is recovered only through the 409 handler at Line 321, so the normal concurrent-start case always takes an error path.

Pass the decision to _attach_if_running, which already encodes the correct predicate.

🐛 Proposed fix
     async def _start_or_attach_node(pg_node: PasarGuardNode, db_node: Node, core, users: list, backend_type):
         state = await pg_node.get_lifecycle_state()
-        if state is not None and state.observed is LifecycleStatus.HEALTHY:
+        if state is not None and (
+            state.observed in (LifecycleStatus.HEALTHY, LifecycleStatus.STARTING)
+            or state.desired is LifecycleStatus.HEALTHY
+        ):
             attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
             if attached is not None:
                 return attached
📝 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.

    `@staticmethod`
    async def _start_or_attach_node(pg_node: PasarGuardNode, db_node: Node, core, users: list, backend_type):
        state = await pg_node.get_lifecycle_state()
        if state is not None and (
            state.observed in (LifecycleStatus.HEALTHY, LifecycleStatus.STARTING)
            or state.desired is LifecycleStatus.HEALTHY
        ):
            attached = await NodeOperation._attach_if_running(pg_node, db_node.name)
            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 - 266, Update _start_or_attach_node
so it delegates the lifecycle-state decision to _attach_if_running instead of
restricting attachment to observed HEALTHY. Remove the HEALTHY-only guard and
invoke _attach_if_running for the relevant state, preserving its existing
predicate so STARTING nodes are attached before attempting pg_node.start.

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

One unexpected exception aborts the whole bulk connect.

connect_single handles NodeAPIError from node_manager.update_node, but nothing else. asyncio.gather at Line 664 runs without return_exceptions=True, so any other exception propagates out of _connect_nodes_bulk_local. bulk_update_node_status at Line 695 then never runs, and no node in the batch receives a status update or a notification.

This matters more now: node_manager.update_node builds kwargs through _create_node_kwargs, which adds user_sync_store, lifecycle_coordinator, and worker_id. A mismatch in the create_node signature raises TypeError, not NodeAPIError.

🛡️ Proposed fix
             async with sem:
                 try:
                     await node_manager.update_node(node)
                 except NodeAPIError as e:
                     return {
                         "node_id": node.id,
                         "status": NodeStatus.error,
                         "message": e.detail,
                         "xray_version": "",
                         "node_version": "",
                         "old_status": node.status,
                     }
+                except Exception as exc:
+                    logger.exception("Unexpected error updating node %s during bulk connect", node.id)
+                    return {
+                        "node_id": node.id,
+                        "status": NodeStatus.error,
+                        "message": str(exc)[:1024],
+                        "xray_version": "",
+                        "node_version": "",
+                        "old_status": node.status,
+                    }
 
                 core_id = node.core_config_id or 1
                 return await self.connect_node(node, cores_by_id.get(core_id), users_by_core.get(core_id, []))
 
-        results = await asyncio.gather(*[connect_single(node) for node in nodes])
+        gathered = await asyncio.gather(*[connect_single(node) for node in nodes], return_exceptions=True)
+        results = []
+        for item in gathered:
+            if isinstance(item, BaseException):
+                logger.error("Bulk connect task failed: %s", item)
+                continue
+            results.append(item)
🤖 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 644 - 664, Update connect_single in the
bulk connection flow to catch unexpected exceptions from
node_manager.update_node and connect_node, converting each failure into a
per-node error result instead of allowing asyncio.gather to abort. Preserve the
existing NodeAPIError details where available, provide a safe error message for
other exceptions, and ensure results still reach bulk_update_node_status so
every node is processed and notified.
dashboard/src/features/subscriptions/components/subscription-settings-schema.ts (1)

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

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Expect: every announce_url consumer validates rendered values or explicitly accepts arbitrary strings.
rg -n -C 10 --glob '*.py' --glob '*.ts' --glob '*.tsx' \
  'announce_url|announceUrl' app dashboard tests

Repository: PasarGuard/panel

Length of output: 30944


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== Locate schema and backend settings files =="
git ls-files | rg '(^|/)subscription-settings-schema\.ts$|(^|/)settings\.py$|(^|/)api\.ts$|(^|/)settings' | sed -n '1,120p'

echo
echo "== inspect schema =="
cat -n dashboard/src/features/subscriptions/components/subscription-settings-schema.ts | sed -n '1,150p'

echo
echo "== backend SubSettings definition =="
cat -n app/models/settings.py | sed -n '280,340p'

echo
echo "== backend operation header usage and URL validation imports =="
cat -n app/operation/subscription.py | sed -n '1,70p'
cat -n app/operation/subscription.py | sed -n '140,310p'

echo
echo "== Search for custom URL validators/imported URL libraries =="
rg -n --glob '*.py' --glob '*.ts' --glob '*.tsx' 'from urllib\.parse|urlparse|urljoin|strval|Base64|Base64Url|url_validator|url\s*\(|Field\([^=]*url|url=' app dashboard tests | sed -n '1,200p'

Repository: PasarGuard/panel

Length of output: 38627


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== URL validation behavior in backend validators =="
python3 - <<'PY'
from urllib.parse import urlparse

def validate_url(value: str | None) -> str | None:
    if value is None:
        return None
    url = urlparse(value)
    if not url.scheme or not url.netloc:
        raise ValueError("Must be a valid URL")
    if url.scheme == "web+":
        path = urlparse(urlparse(f"{url.scheme}:{url.url}").netloc).path
        return f"web+{path}{urlparse(urlparse(f'{url.scheme}:{url.url}').netloc).path}{urlparse(urlparse(f'{url.scheme}:{url.url}').netloc).path}{value[5:]}" if value[5:5] == "/" else value
    return value

for value in ["https://status.example.com/{USERNAME}", "not-a-url", "http:", "//host/path", "ftp://example.com"]:
    try:
        print(value, "=>", validate_url(value))
    except Exception as e:
        print(value, "=> raises", type(e).__name__, str(e))
PY

echo
echo "== Search for URL validation on announce_url specifically =="
rg -n --glob '*.py' --glob '*.ts' --glob '*.tsx' \
  'announce_url:.*validate|validate.*announce_url|announce_url\b|subscribe.*announce|announcement.*url|announce_url=' app dashboard | sed -n '1,240p'

echo
echo "== settings submit/update code =="
cat -n app/db/crud/settings.py | sed -n '1,220p'
cat -n app/routers/settings.py | sed -n '1,180p'

Repository: PasarGuard/panel

Length of output: 347


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== URL validation behavior in backend validators =="
python3 - <<'PY'
# Reconstructs the relevant backend validator shape from app/models/validators.py.
from urllib.parse import urlparse

def validate_url(value):
    if value is None:
        return None
    url = urlparse(value)
    if not url.scheme or not url.netloc:
        raise ValueError("Must be a valid URL")
    if url.scheme == "web+":
        parsed = urlparse(f"{url.scheme}:{url.url}")
        path = urlparse(parsed.netloc).path
        return ("web+" + parsed.scheme[5:] + ":" + path) or value
    return value

for value in ["https://status.example.com/{USERNAME}", "not-a-url", "http:", "//host/path", "ftp://example.com"]:
    try:
        print(value, "=>", validate_url(value))
    except Exception as e:
        print(value, "=> raises", type(e).__name__, str(e).splitlines()[0] if str(e) else "")
PY

echo
echo "== Announce URL references and validators =="
rg -n --glob '*.py' --glob '*.ts' --glob '*.tsx' \
  'announce_url:.*validate|validate.*announce_url|announce_url\b|subscribe.*announce|announcement.*url|announce_url=' app dashboard | sed -n '1,260p'

echo
echo "== Settings CRUD outline =="
cat -n app/db/crud/settings.py | sed -n '1,220p'

echo
echo "== Settings router outline =="
cat -n app/routers/settings.py | sed -n '1,220p'

Repository: PasarGuard/panel

Length of output: 4788


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== Settings schema and Settings base model =="
cat -n app/models/settings.py | sed -n '1,120p'
cat -n app/models/settings.py | sed -n '330,450p'

echo
echo "== Settings operation update flow =="
cat -n app/operation/settings.py | sed -n '1,220p'

echo
echo "== Static analyzer for field validators around announce_url =="
rg -n --glob '*.py' --glob '*.ts' --glob '*.tsx' \
  '`@field_validator`|model_validator|validator|announce_url|Subscription(BaseModel)' app/models/settings.py app/operation/settings.py

Repository: PasarGuard/panel

Length of output: 11924


Validate announce_url server-side after template expansion.

Subscription.announce_url is an unvalidated blank string, and _format_announce_url() returns the rendered value unchanged before announce-url is sent. The dashboard schema only accepts every string, so malformed values and non-URL schemes can still persist and be published in subscription responses. Add template-aware URL validation for non-empty announce_url inputs.

🤖 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, Update the announce_url schema validation around announce_url so
non-empty values are validated as URLs after template expansion, while
preserving acceptance of empty strings and optional values. Reuse the existing
_format_announce_url() behavior or its shared validation mechanism to reject
malformed URLs and disallowed schemes before persistence and publication.

@Rerowros
Rerowros force-pushed the codex/security-csv-10-49 branch from fe7e108 to 3a1c456 Compare August 10, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants