So str(exc) passthrough on scripts, batch, sync, and schedule routes
returns Chinese without relying only on the HTTP detail translation layer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Stop exposing raw reason codes like admin_not_found in TOTP setup errors;
auth routes and JWT guard now use Chinese literals with auth_failure_detail.
Co-authored-by: Cursor <cursoragent@cursor.com>
Unify upgrade-agent with install.sh via agent_deploy; auto-sync Git web/app after
Docker upgrade to prevent vite hash drift; extend prod_probe for Beijing TZ checks.
Co-authored-by: Cursor <cursoragent@cursor.com>
Apply sync/install/auth/schedule/retry/agent/settings fixes from full
code review; document accepted WS and Agent legacy risks for solo ops.
Co-authored-by: Cursor <cursoragent@cursor.com>
The updated_at secondary check in _verify_token() was a "defense in
depth" mechanism, but it caused a race condition: login updates admin
(last_login, onupdate→updated_at) AFTER creating the JWT, so the JWT's
"updated" claim is immediately stale. Any DB admin update (including
innocuous ones like last_login) would invalidate all existing tokens.
The token_version primary check already covers all security scenarios
(password change, TOTP disable, token reuse detection) and is the
correct mechanism for invalidating sessions. Removed the updated_at
check from both _verify_token() and get_optional_admin().
Also reordered login/refresh flows to create JWT AFTER admin update,
so the token captures the latest updated_at (belt and suspenders).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Direct DB inserts (e.g. install wizard, manual MySQL) may leave
token_version as NULL. JWT payload had tv:null which failed the
strict != comparison against DB value 0. Normalize both sides
with `or 0` for defensive coding.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Data model (separate storage):
- login_allowlist_enabled: 'true'/'false' master switch
- login_subscription_url: URL to fetch every 2h
- login_subscription_ips: last fetch result (replaced entirely each refresh)
- login_manual_ips: manually added (never overwritten by refresh)
- login_allowed_ips: combined (used by auth for legacy compat)
ip_allowlist_refresh.py:
- Saves to login_subscription_ips (replace) + rebuilds login_allowed_ips
auth_service.py:
- Login check: only runs when LOGIN_ALLOWLIST_ENABLED is 'true'
- Combines subscription_ips + manual_ips at auth time (no stale combined key needed)
settings.py:
- POST /ip-allowlist/toggle: quick enable/disable endpoint
- GET /ip-allowlist: returns subscription_ips, manual_ips separately
- POST /ip-allowlist: saves manual_ips + optional subscription_url + optional enabled
settings.html:
- Toggle switch: enable/disable with warning if list is empty
- Green notice when enabled (warns about self-lockout risk)
- Grey notice when disabled
- Subscription IPs section: read-only, auto-refresh badge
- Manual IPs section: deletable per-IP, clear all button
Co-authored-by: Cursor <cursoragent@cursor.com>
F4a-01 script_callback_rate: INCR+EXPIRE were non-atomic; a crash between
the two could leave the rate-limit key without a TTL, permanently
blocking future callbacks for that job/IP. Fix: use Redis pipeline
so INCR+EXPIRE are sent in one roundtrip and EXPIRE always executes.
F4a-02 sync_service: add comment documenting that StrictHostKeyChecking=no
is a legacy issue in the dead-code SyncService shim; main sync path
(SyncEngineV2) honours SSH_STRICT_HOST_CHECKING from settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
F3h-01 script_service._build_kill_command: shlex.quote(log_path) wraps the
path in single-quotes; embedding that inside "..." produced a filename
with literal single-quote chars that cat/kill could not find.
Fix: use the shlex-quoted string directly without extra double-quotes.
F3h-02 script_service._substitute_db_vars: non-atomic string replacement
allowed nested substitution when a credential value contained another
$DB_* pattern. Fix: replace with null-byte sentinels first, then
substitute actual values in a second pass.
server_service.push_to_servers: add comment documenting that audit_repo and
retry_repo are None (internal compat shim — not used by routes).
Co-authored-by: Cursor <cursoragent@cursor.com>
F3g-01 sync_commands line 195: server_results is a list; unpacking it
with ** raises TypeError, silently caught by gather(return_exceptions=True),
so S2 command sync and S3 config sync returned empty results for all
servers. Fixed by using a proper dict: {"server_name": ..., "results": ...}.
F3g-02 sync_config: config value containing newline chars was not stripped
before shlex.quote; echo inside single-quotes would write multiple lines
to the sysctl config file, injecting arbitrary keys. Strip \n\r\x00 from
value before quoting (requires admin auth, defense-in-depth fix).
scripts.py, settings.py: CLEAN (no changes needed).
Co-authored-by: Cursor <cursoragent@cursor.com>
F3e-01 auth_service.py:180 - jwt_token_expires (MySQL tz-naive) vs
datetime.now(timezone.utc) caused TypeError on token refresh;
strip tzinfo before comparison when expires is tz-naive.
F3e-02 auth_service.py:271 - TOTP provisioning URI was not URL-encoding
issuer or username; special chars (spaces etc.) broke QR scan.
Use urllib.parse.quote() on both fields.
Co-authored-by: Cursor <cursoragent@cursor.com>