Files
fuel-alert/docs/ops/nginx-log-masking.md
Ovidiu U df4ebdb7c6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
2026-06-10 10:55:34 +01:00

7.3 KiB

Runbook — Mask lat/lng coordinates in server logs (IONOS VPS)

Goal: stop precise search coordinates (lat/lng) from being written to server logs, while keeping GET-based shareable search URLs and full debugging value (IP, status, path, fuel_type all stay readable).

Applies to: the production Nginx + PHP-FPM box on IONOS. None of this lives in the app repo — it is server config you apply over SSH. The app already stores only a ~1km-rounded location bucket + a SHA-256 IP hash in the searches table; this runbook covers the raw coordinates that transit the server in the URL.

Scope — what this does and does NOT cover

Sink Covered here?
Nginx access log Step 1 (request line + referer)
Nginx error log Step 2 (scrub, since log_format can't touch it)
PHP-FPM access log Step 3 (check / disable)
Laravel app logs Nothing to do — stock config logs no URL, no error tracker installed
IONOS edge (CDN / WAF / managed LB) Cannot be redacted from your side — see "Honest limit"

If you ever add Sentry/Flare/Bugsnag later, they capture the full request URL by default — you'd need a before_send scrubber there too.


Step 1 — Mask the Nginx access log

1a. Create /etc/nginx/conf.d/fuel-alert-log-masking.conf

# Redact lat/lng from the access log — request line AND referer.
# Every other field and query param is left intact.

# --- request line (e.g. GET /api/stations?lat=..&lng=.. HTTP/1.1) ---
map $request $fa_req_1 {
    default                                    $request;
    "~^(?<rqa>.*[?&])lat=[^& ]*(?<rqb>.*)$"    "${rqa}lat=***${rqb}";
}
map $fa_req_1 $fa_request_masked {
    default                                    $fa_req_1;
    "~^(?<rqc>.*[?&])lng=[^& ]*(?<rqd>.*)$"    "${rqc}lng=***${rqd}";
}

# --- referer header (e.g. https://fuel-alert.co.uk/?lat=..&lng=..) ---
map $http_referer $fa_ref_1 {
    default                                    $http_referer;
    "~^(?<rfa>.*[?&])lat=[^&]*(?<rfb>.*)$"     "${rfa}lat=***${rfb}";
}
map $fa_ref_1 $fa_referer_masked {
    default                                    $fa_ref_1;
    "~^(?<rfc>.*[?&])lng=[^&]*(?<rfd>.*)$"     "${rfc}lng=***${rfd}";
}

# A copy of the standard "combined" format, but using the masked values.
log_format fuelalert_masked
    '$remote_addr - $remote_user [$time_local] '
    '"$fa_request_masked" $status $body_bytes_sent '
    '"$fa_referer_masked" "$http_user_agent"';

The map blocks must sit in the http {} context. Files in /etc/nginx/conf.d/ are included there by default — if your setup doesn't include conf.d/*.conf, paste the block inside http {} in /etc/nginx/nginx.conf instead.

1b. Point the site at the masked format

Find your site's server block and its current access_log line:

sudo nginx -T | grep -nE "server_name|access_log|root" | grep -i fuel
ls /etc/nginx/sites-enabled/          # the site file is usually here

Inside that server { … } block, set:

access_log /var/log/nginx/fuel-alert.access.log fuelalert_masked;

(Keep the path the same as whatever it currently is; only the trailing format name fuelalert_masked is the change.)


Step 2 — Scrub the Nginx error log

Error-log lines are generated internally by Nginx and do not pass through log_format, so they can't be masked at write time. They include request: and referrer: fields that carry the coordinates. Two parts:

2a. Reduce how often request lines are written

In the same server { … } block (or globally), lower the level so routine warn/error/info entries that embed the request line are dropped:

error_log /var/log/nginx/fuel-alert.error.log crit;

2b. Scrub whatever still gets written

Create /usr/local/bin/scrub-fuel-logs.sh:

#!/bin/sh
# Redact lat/lng values in the Nginx error log. GNU sed (Linux). Verified portable expr.
sed -E -i 's/([?&])(lat|lng)=[^ &"]*/\1\2=***/g' /var/log/nginx/fuel-alert.error.log
sudo chmod +x /usr/local/bin/scrub-fuel-logs.sh

Run it every minute via a systemd timer. /etc/systemd/system/scrub-fuel-logs.service:

[Unit]
Description=Scrub lat/lng from Nginx error log

[Service]
Type=oneshot
ExecStart=/usr/local/bin/scrub-fuel-logs.sh

/etc/systemd/system/scrub-fuel-logs.timer:

[Unit]
Description=Run lat/lng scrub every minute

[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=10s

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now scrub-fuel-logs.timer

Trade-off: a coordinate from an errored request can sit on disk for up to ~60s before the scrub runs. Error entries are low-volume, so the surface is small but not zero. For a zero-window setup (nothing un-redacted ever hits disk) you'd route error_log to syslog and scrub at the syslog layer, or tail a RAM-backed raw file with Vector and write only the redacted copy — more setup; ask if you want it.


Step 3 — Check PHP-FPM

grep -nE '^\s*(access\.log|access\.format)' /etc/php/*/fpm/pool.d/*.conf
  • No output / commented out → FPM isn't logging requests. Nothing to do.
  • access.log is enabled → either comment it out, or remove the %r, %q, %Q tokens from access.format, then sudo systemctl reload php*-fpm.

(FPM slowlog and error log record the PHP script/backtrace, not the query string — no action needed there.)


Step 4 — Apply, test, verify

# 1. Validate config BEFORE reloading — catches typos safely.
sudo nginx -t
#    If this FAILS, do NOT reload. Fix the error first.

# 2. Reload Nginx (zero downtime).
sudo systemctl reload nginx

# 3. Trigger a real search in the app, then inspect the freshest lines:
sudo tail -n 5 /var/log/nginx/fuel-alert.access.log
sudo tail -n 5 /var/log/nginx/fuel-alert.error.log
#    Expect:  lat=***&lng=***   — IP, status, path, fuel_type all still present.

Honest limit — IONOS edge

Masking only reaches logs you control (your Nginx, FPM, app). It cannot touch anything IONOS runs in front of the box:

  • Plain IONOS VPS / Cloud Server (root server): there is no HTTP-layer edge — your Nginx is the first HTTP hop, and IONOS network logging is L3/L4 (IPs/ports/bytes, no URLs). In this case this runbook covers everything that exists.
  • Managed hosting / Deploy Now / CDN / managed WAF or LB: those terminate HTTPS and log full URLs with coordinates, on their retention, and you cannot redact them. Levers: ask IONOS what they log + for how long, or stop putting coords in the URL.

The only way coords never reach any HTTP log (yours or theirs) is to keep them out of the URL. To preserve shareable links without coords in the URL, use opaque share tokens: store the search server-side, share /s/<token>, resolve token → coords on the server. Coords then appear in zero logs and live in one DB row you control (set its precision + expiry). This is an app change, tracked separately if wanted.


Rollback

  1. In the site server {} block, restore the original access_log (drop the fuelalert_masked name) and error_log lines.
  2. sudo rm /etc/nginx/conf.d/fuel-alert-log-masking.conf
  3. sudo nginx -t && sudo systemctl reload nginx
  4. sudo systemctl disable --now scrub-fuel-logs.timer (optional).