Skip to content

Logs and Analytics API ​

All endpoints require authentication (Basic or JWT). Responses use the envelope {"status": "success", "data": ...} unless noted.


Get log entries ​

GET /api/logs

Returns access log rows matching the filters, with pagination metadata.

Query parameters:

ParameterDefaultDescription
limit25Rows to return, 1 to 500
offset0Pagination offset
sorttimestamptimestamp, source_ip, destination, status, bytes, method or id; anything else is timestamp
orderdescasc or desc
source_ipOne source address, exact, or a /24 (IPv4) or /64 (IPv6) network CIDR. With gdpr_mode on, a single address is refused with 400; pass the network CIDR. A named source is listed even when it is internal
outcomeallowed or blocked (all or empty: both), from the blocked flag
reasonThe rule that blocked the request (a reason value below); selects blocked rows only
qA substring of destination, case-insensitive for ASCII; % and _ match themselves. At most 256 bytes
include_internalfalsetrue includes the stack's own traffic (see below)
before_idCursor: only rows with a lower id. Sets the order to id descending and ignores offset
since, untilA time range in Unix seconds on unix_timestamp (indexed): since included, until left out. Either may be absent; until not later than since answers 400

A malformed filter value is refused with 400.

Internal sources. The backend classifies each source address: loopback, the backend itself, the proxy, the Docker host and any other address on the networks the backend shares with the proxy are the stack's own traffic, not a device (the same classification as the client list). They are left out unless include_internal=true or source_ip names one of them; meta.internal then counts the matching rows that were left out.

Response:

json
{
  "status": "success",
  "data": [
    {
      "id": 1234,
      "timestamp": "2026-03-25 14:32:10",
      "source_ip": "192.168.1.10",
      "method": "CONNECT",
      "destination": "sink.example:443",
      "status": "TCP_DENIED/403",
      "bytes": 4862,
      "blocked": true,
      "reason": "domain_blacklist_dns",
      "reason_label": "Domain blacklist (DNS sinkhole)",
      "internal": false,
      "kind": "Private network address",
      "resolved_ip": null,
      "event_id": null
    }
  ],
  "meta": { "total": 15420, "limit": 25, "offset": 0, "internal": 2880 },
  "total": 15420,
  "limit": 25,
  "offset": 0
}

meta.total counts the rows the filters match. kind says what the source is where the backend can tell (for example "Internal: the backend", "Internal: this host", "Internal: the proxy", "Private network address", "Public address"), and is empty otherwise. resolved_ip is the server address the proxy connected to (from the %<a field of Squid's access log, IPv6 in short form), or null when it connected to none, as for a denied request, and for rows logged before the backend recorded it. event_id is the WAF's event ID on a request the WAF blocked, null otherwise: GET /api/waf/events/{id} has the rules behind the block.

To page back through the log, pass the id of the last row received as before_id; a page shorter than limit is the last one.

With gdpr_mode on (Settings > Logs and privacy > Mask client IPs), source_ip is masked to /24 for IPv4 (192.168.1.x) and /64 for IPv6 (2001:db8:1:2::x); the source is classified before it is masked. The top-level total, limit and offset are deprecated copies of meta.

reason is the rule that blocked the request, recorded by Squid or the WAF when the request was logged (see Block reasons): ip_blacklist, domain_blacklist, domain_blacklist_dns, direct_ip, egress_policy, waf, proxy_auth, proxy_auth_unset (proxy login is on and no proxy credentials are set, so every client is refused), time_restriction, content_filter, source_gate, port_policy, protocol_policy, lists_unverified (the fail-closed lists gate: a blocklist the backend's manifest did not vouch for at boot, so the proxy denied every client request until a verified copy was loaded), other; none when it was not blocked; unclassified for a row logged before reasons were recorded; domain_blacklist_dns_reconstructed for such a row that schema version 3 reassigned at start-up: a /503 to a name on the domain blacklist, logged after the name was added. With include_internal=true the list also holds the DNS sinkhole's own rows (method DNS, source the proxy container), which no statistic counts.

Cost, measured on 1,000,000 rows. TestLogFilters1M in backend-go/internal/handlers/logs_plan_test.go (SPM_LOGS_1M=1): 20 devices, two internal sources holding 10% of the rows, 10% blocked over five rules, limit=100, best of three. Run on 2026-10-25 on an Apple M4 with go1.27.2:

FilterPlan (page; count)Time
none (internal sources left out)scan; covering index count minus idx_proxy_logs_source_ip17.4 ms
include_internal=truescan; covering index count12.4 ms
source_ipidx_proxy_logs_source_ip2.7 ms
reason=waf (1% of rows)idx_proxy_logs_blocked_reason10.2 ms
reason=ip_blacklist (0.1%)idx_proxy_logs_blocked_reason1.2 ms
outcome=blockedidx_proxy_logs_blocked70.6 ms
outcome=allowedscan; every row less the blocked ones88.0 ms
before_id (deep)rowid range17.2 ms
qno index serves LIKE '%x%': the table is read208 to 287 ms

Creating idx_proxy_logs_blocked_reason, which start-up does once after an upgrade, took 126 ms on the same rows.


Export log entries ​

GET /api/logs/export

Streams, as CSV, every row GET /api/logs selects for the same filters (source_ip, outcome, reason, q, include_internal, before_id, since, until), newest first, read 2,000 rows at a time. limit, offset and sort do not apply. The file is named access-log-<date>.csv. Columns: id, timestamp_utc, source_ip, internal, kind, method, destination, status, bytes, blocked, reason, reason_label, resolved_ip (empty when there is none). A cell that starts with =, +, -, @, a tab or a carriage return is written with a leading apostrophe, so a spreadsheet shows it as text rather than running it as a formula (a method logged as - is exported as '-). With GDPR mode on, source_ip is masked here too.


Log statistics ​

GET /api/logs/stats

Response:

json
{
  "status": "success",
  "data": {
    "total_count": 15420,
    "blocked_count": 342,
    "ip_blocks_count": 18,
    "blocked_by_reason": [
      { "reason": "domain_blacklist_dns", "label": "Domain blacklist (DNS sinkhole)", "count": 300 },
      { "reason": "domain_blacklist", "label": "Domain blacklist (proxy)", "count": 24 },
      { "reason": "direct_ip", "label": "Direct IP", "count": 18 }
    ],
    "last_import": "2026-03-25 14:32:10"
  }
}

Counts are client requests: the DNS sinkhole's own rows are left out. blocked_count is the number of blocked requests (rows logged before reasons were recorded keep the flag they were given then, from the status). ip_blocks_count is the subset with reason direct_ip, plus older rows whose URL targeted a raw IPv4 address. blocked_by_reason breaks blocked_count down by reason. last_import is the newest row's timestamp.


Traffic timeline ​

GET /api/logs/timeline

Requests and blocks per hour, client requests only, internal sources left out. The hours are those of tz: time is the hour as that zone's clock reads it, start the epoch second it begins. In a zone with a half-hour offset, such as Asia/Kolkata, the hours begin at :00 there, which is :30 UTC.

ParameterDefaultRange
hours241 to 720; a value outside the range is 24
tzUTCAn IANA time zone name; any other value answers 400
json
{
  "status": "success",
  "data": [
    { "time": "2026-03-25 13:00:00", "start": 1774443600, "total": 45, "blocked": 3 },
    { "time": "2026-03-25 14:00:00", "start": 1774447200, "total": 62, "blocked": 7 }
  ]
}

Clear all logs ​

POST /api/logs/clear

Deletes every row in proxy_logs and records the action in the audit log. The UI offers it as "Clear the access log" in Settings, Maintenance, beside "Reset statistics", which is POST /api/counters/reset.

json
{ "status": "success", "message": "All logs cleared" }

POST /api/counters/reset also deletes every row, and resets the WAF's counters; its data says how many rows went (logs_cleared) and whether the WAF reset (waf_reset).


Clear old logs ​

POST /api/logs/clear-old?days=30

Deletes rows older than days days (default 30; a value below 1 is 30). It does not read the log_retention_days setting, which drives the daily automatic pruning.

json
{ "status": "success", "message": "Cleared old logs", "deleted": 1234 }

Security score (retired) ​

GET /api/security/score

Answers 410 Gone. The score added points for four settings that nothing enforced (enable_ip_blacklist, enable_domain_blacklist, block_direct_ip, enable_waf): Squid always denies the blacklists and direct-IP URLs and the WAF is always on the ICAP path. Settings > Overview lists what needs attention, worked out from the running configuration.


Audit log ​

GET /api/audit-log?category=changes&limit=50&offset=0

Newest first, 50 rows a page by default (limit up to 200). category is all (default), changes or signins (the actions login, login_failed, logout), and q matches text, as typed and up to 200 characters, in the user, the action, the target or the details. Both are applied on the server, so meta.total and the pages are those of the filtered log. meta.retention_days is how long a row is kept (365). Every change to a list (add, remove, bulk remove, clear, import, removal of a source, restore) writes a row. Sign-in, failed sign-in and sign-out rows carry the client address in target.

A settings save (update_settings for POST /api/settings, update_setting for PUT /api/settings/{name}) writes a row only when it changed something, after the change is stored: target is the changed keys, comma-separated, and details gives each one's old and new value. A secret is never written, only whether it was set or cleared; a value longer than 512 characters is cut and marked truncated.

json
{ "changes": { "enable_notifications": { "from": "false", "to": "true" } }, "secrets": { "telegram_bot_token": "set" } }
json
{
  "status": "success",
  "data": [
    { "id": 812, "username": "admin", "action": "add_domain_blacklist", "target": "ads.example", "details": "", "timestamp": "2026-10-25 08:00:00" }
  ],
  "meta": { "total": 812, "limit": 50, "offset": 0, "retention_days": 365 }
}

Other analytics endpoints ​

These return JSON in the standard envelope, in the shape the UI renders:

PathPurpose
GET /api/statusProxy reachability (proxy_status, proxy_host, proxy_port), version, commit, today's client request count (requests_count), proxy_auth state when recorded, update availability (update_available, update_url), and Squid CVE state (squid_version, squid_cves, cves_checked: false when the major version has no table in cve_checker.go, such as Squid 7.x)
GET /api/security/cveThe Squid version (version), whether its major version has an advisory table (cves_checked), and the matched advisories (cves)
GET /api/dashboard/summaryThe dashboard's data in one call (with gdpr_mode on, top_clients are aggregated by /24 or /64 network and recent_blocks[].source_ip is masked)
GET /api/traffic/statistics?period=dayRequests and blocks per bucket (hour, day, week, month)
GET /api/clients/statisticsTop 50 clients (or /24 and /64 networks with gdpr_mode on, including network CIDR and distinct addresses count), then the internal sources
GET /api/clients/{ip}/details?window=7dOne client's or network CIDR's totals, top domains, recent requests and blocks by rule. window=7d counts the last 7 days, as GET /api/clients/statistics does; window=all, the default, counts every request logged; any other value answers 400. The answer says which in window. With gdpr_mode on, only /24 or /64 CIDRs are accepted and a single address answers 400
GET /api/domains/statisticsTop 50 domains
GET /api/analytics/shadow-itRequests of the last 7 days to known services, by host
GET /api/analytics/user-agentsRequests of the last 7 days by method and kind of service
GET /api/analytics/file-extensionsRequests of the last 7 days by file extension
GET /api/analytics/top-domainsTop 50 domains
GET /api/analytics/blocked-by-dayBlocked requests per day by rule for 7 days (tz, default UTC)
GET /api/analytics/coverageWhat protection is on now
GET /api/waf/statsWAF statistics, from the WAF. When there are none it answers 502 (503 while the circuit breaker is open) with waf_status, the reason in the words of dashboard/summary: unreachable, auth_failed (the WAF refused the backend's credential) or error
GET /api/waf/statusWhat the WAF runs: status as above; rule and category counts, with those only logged (categories_observed, rules_observed); and running, from the WAF's /health: engine (hybrid adds the OWASP Core Rule Set), threshold (the score that blocks), regex_engine, regex_shadow, heuristics, policy_mode (block, or observe for Log only), policy_exceptions, policy_reload_failed and policy_observe_rules (single rules the policy makes log-only; a category it observes counts in categories_observed). running is null when /health did not answer
GET /api/waf/categoriesWAF rule categories and whether each is on

See the API reference for the full list.

Released under the MIT License. · Privacy & legal