Skip to content

Audit Logs

The Audit Logs page is your forensic record. Every state change made by a human user — admin, reseller, sub-reseller, support, or collector — is recorded in the audit_logs table with a precise description of what changed, what the old value was, what the new value is, who did it, from where, and when. Read-only after the fact: rows are append-only and not editable from the UI.

This page is the answer to “who deleted that subscriber?”, “did anyone change the price?”, “when did this reseller’s permission group change?”, and the regulatory question “show me a record of every action taken in your billing system for the last 12 months.”

  • SidebarAudit (clipboard icon).
  • Direct URL: /audit.

Admins only by default. A reseller can be granted audit.view to see entries scoped to their own subscribers/sub-resellers — useful for parent companies that need to oversee a sub-reseller without giving them full admin rights.

ColumnSource
Timecreated_at — UTC, rendered in panel timezone.
Userusername + user_type (admin / reseller / collector).
ActionOne of create, update, delete, restore, login, logout, login_failed, bulk_action, impersonate.
EntityThe thing that changed: subscriber, service, nas_device, reseller, invoice, user, permission_group, etc.
DescriptionHuman-readable summary, with old/new values inline where applicable.
IPReal client IP (see below).
User-agentBrowser or API client.

Filters at the top: user, action type, entity type, date range, IP address, free-text search across the description. The CSV export emits exactly what the table shows after filtering.

ProxPanel runs behind nginx in production. Without configuration, Fiber would see the Docker bridge IP (172.18.0.x) on every request, making audit logs useless for forensic work. The fix is two-part:

  1. Fiber config in cmd/api/main.go sets ProxyHeader: "X-Real-IP" and trusts the internal Docker subnets (172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16).
  2. nginx forwards the real client IP via X-Real-IP and X-Forwarded-For headers.

Result: audit log entries show the actual public IP of the operator’s browser, even when the panel is behind multiple proxies (e.g., Cloudflare → nginx → API). The middleware also parses X-Forwarded-For to handle chained proxies correctly.

If you ever see Docker IPs in audit logs, either nginx is misconfigured or the API container started before its config was updated — restart the API after fixing nginx.

The audit middleware is in backend/internal/middleware/audit.go. It wraps every authenticated handler and:

  1. Captures the request path, method, and parameters before c.Next().
  2. Lets the handler complete normally.
  3. Reads three locals the handler may have set: audit_description, audit_entity_id, audit_entity_name.
  4. Builds a single audit_logs row with the user identity, IP, user-agent, description, and entity reference.

Why locals and not automatic struct diffs? Because Go’s empty strings ("") are rejected by PostgreSQL jsonb columns, an early attempt at auto-diffing silently dropped audit rows. The handler now constructs the human-readable change message itself — e.g., for a subscriber update:

Updated subscriber "info1633": Status: Active → Inactive, Price: $0.00 → $25.00

The diff helper (buildSubscriberChanges()) compares old vs. new field by field and renders only the changed fields. This same pattern is used for services, resellers, NAS devices, permission groups, and settings.

Passwords are never written to the audit log in plaintext. Subscriber password changes log only "Password changed" (and only when the value actually differs from the previously-stored encrypted value — a fix from v1.0.256 stopped logging “Password changed” on every save). Reseller and admin passwords are handled the same way.

API keys, two-factor secrets, RADIUS secrets, and MikroTik API passwords are likewise replaced with *** in audit descriptions.

Bulk operations from the Bulk Operations page log a single audit row, not one per subscriber. The row’s description summarises the action and the counts:

ChangeBulk: Bulk renewed for 124 subscribers (3 failed)

Each affected subscriber’s last_modified_by field is updated, so you can trace from the subscriber back to the bulk action’s audit row by timestamp + user. This keeps the audit table manageable at scale — a single bulk action on 50,000 subscribers writes one row, not 50,000.

login, logout, and login_failed actions are written by the auth handler:

EventWhen it fires
loginSuccessful authentication. Description includes 2FA usage if enabled.
login_failedWrong password, expired account, deactivated reseller, or token blacklist hit. The reason is in the description so you can spot brute-force attempts.
logoutUser clicks logout. The JWT is blacklisted (see Settings → Security).
impersonateAn admin uses Login as Reseller to assume that reseller’s session. The original admin identity is preserved in the row so you can trace the chain.

A flood of login_failed from a single IP is a red flag — combine with the brute-force lockout in Settings → Security for automated mitigation.

The handler supports:

  • User filter — exact username; combine multiple by Ctrl-clicking.
  • Action filter — multi-select.
  • Entity filtersubscriber, service, nas_device, reseller, invoice, user, permission_group, settings, backup, cluster, etc.
  • Date range — defaults to last 7 days; widen for compliance pulls.
  • IP filter — exact match or CIDR (192.168.1.0/24 matches a whole subnet).
  • Search — case-insensitive substring across the description.

The query is index-backed on (user_id, created_at) and (entity_type, entity_id, created_at) so filters return in milliseconds even at multi-million-row scale.

The Export CSV button runs the same query without pagination and streams the result. Columns match what is shown on screen. The filename embeds the filter — e.g., audit-2026-04-15_to_2026-05-12_user-admin.csv — so compliance archives don’t blur together.

For very long retention windows, see Backups — full audit history is backed up alongside the rest of the database.

  1. Open Audit, filter action = delete and entity type = subscriber.
  2. Search the username in the description box.
  3. The row tells you the operator, IP, and time.
  4. If it was a bulk delete, the row says ChangeBulk: Delete for N subscribers — the bulk action history table inside the subscriber’s archived record points back here.

Confirm a price change wasn’t tampered with

Section titled “Confirm a price change wasn’t tampered with”
  1. Filter entity = service and action = update.
  2. Scan descriptions for Price: — every price change shows the old and new value inline.
  3. If a row shows an unexpected actor or IP, cross-reference with the same window’s login events to confirm the session was genuine.

Investigate a possible brute-force attempt

Section titled “Investigate a possible brute-force attempt”
  1. Filter action = login_failed.
  2. Group by IP (sort descending by count). High counts from a single IP is the signal.
  3. Check whether the security lockout in Settings is enabled.
  4. If needed, add the offending IP to the API rate-limit denylist (nginx) and to the security alerts in the license server.
PermissionWhat it gates
audit.viewOpen the page and run queries.
audit.exportUse the CSV export button.

A reseller granted audit.view sees only entries where the actor is themselves or one of their sub-resellers, and where the entity belongs to their scope. Admins see everything.