Skip to content

v3.16.0

Choose a tag to compare

@fabriziosalmi fabriziosalmi released this 28 Oct 00:42
· 151 commits to main since this release
Immutable release. Only release title and notes can be modified.
a9545a5

Egress can follow the IP ranges a provider publishes, the dashboard session
moves to an HttpOnly cookie that no script in the page can read, and with
GDPR mode on client addresses are masked everywhere they leave the backend.
Two API changes are BREAKING under X-API-Version: 1: read the Upgrade
notes and Removed first.

Upgrade notes

  • Take a backup first. On the first start the backend runs schema
    versions 7 (creates egress_providers and egress_provider_entries) and 8
    (archives three retired settings into the Audit log and deletes them, see
    below), each once; PRAGMA user_version is 8 afterwards.

  • The dashboard signs in with an HttpOnly session cookie (spm_session)
    instead of keeping the token in the browser's localStorage. Everyone
    signed in to the dashboard signs in once more after the upgrade; a token an
    earlier version left in localStorage is revoked and removed on the first
    load. API clients are unchanged: POST /api/auth/login without
    X-SPM-Session: cookie still returns access_token and refresh_token, and
    Basic and bearer authentication work as before. An external reverse proxy
    in front of the dashboard must pass the Cookie and X-Requested-With
    headers through (nginx and Caddy do by default).

  • The backend service joins the proxy group: group_add: ["13"] in
    docker-compose.yml and deploy/docker-compose.prod.yml. A deployment that
    keeps its own compose file should add it to the backend service; without it
    the backend logs a warning on each proxy password change and new
    credentials get 407 until the proxy reloads, as before.

  • Schema version 8 archives and deletes allowed_networks, waf_custom_rules
    and waf_block_threshold from settings
    (#444). If any of the three keys
    had a row in settings, the migration copies its previous value into the
    Audit log (action = retire_setting_value, username = system, target set
    to the key name, details holding {"value": "..."}, truncated at 64 KiB
    with "truncated": true when longer) before deleting the row. retire_setting_value
    rows follow the Audit log retention of 365 days (AuditRetentionDays), so
    values can be recovered from the Audit log for a year or from a database
    export at any time. waf_custom_rules in the database was never read by the
    WAF: active custom rules live in /config/waf_custom_rules.txt.

Removed

  • BREAKING (X-API-Version: 1): the settings allowed_networks,
    waf_custom_rules and waf_block_threshold, which nothing in the backend,
    proxy or WAF read
    (#444). Schema version 8 archives their values into
    audit_log (retire_setting_value) and deletes their rows; POST /api/settings skips them, PUT /api/settings/{name} answers 400, and
    POST /api/maintenance/restore-config lists them in skipped_settings.

Added

  • scripts/local-ci.sh runs the 26 required CI gates locally, including the
    Docker stack suites, Playwright and Trivy, and writes results.txt with
    one line per gate. It runs only in a linked git worktree, since the suites
    wipe config/, data/ and logs/ and rewrite .env
    (SPM_LOCAL_CI_ALLOW_RESET=1 accepts a disposable clone), and holds a lock
    so two runs on one Docker host never collide; a lock left by a process that
    no longer runs is taken over. See tests/README.md.

  • The global search (Cmd+K / Ctrl+K) also searches Allowed IPs, Allowed
    domains and the egress allowlist, and a result opens its own tab of
    Blacklists, or the Egress page, with the searched term in the filter. The
    "Domain blacklist" line of Analytics opens the Blocked domains tab.

  • Managed egress destination provider lists (GET /api/egress-providers,
    PUT /api/egress-providers/{name}, POST /api/egress-providers/url,
    DELETE /api/egress-providers/url/{id},
    POST /api/egress-providers/{name}/refresh,
    POST /api/egress-providers/{name}/apply-held) and the Provider lists
    section on the Egress allowlist page: operators can enable IPv4 and IPv6 CIDR
    ranges from a fixed 12-provider catalog (amazon, cloudflare,
    digitalocean, facebook, github, google, linode, microsoft,
    oracle, telegram, twitter, vultr, plus all) fetched at a pinned
    commit of lord-alfred/ipranges, or add custom CIDR list URLs.
    Shared-network providers require explicit confirmation (confirm_shared: true, with a confirmation dialog in the UI and a danger dialog showing the
    IPv4 space percentage for all). Updates are validated as a whole before
    replacing the previous list (rejecting malformed lines, prefixes wider than
    /8 in IPv4 or /16 in IPv6, any non-public range, or lists over 50,000
    entries), held for manual approval when covered addresses grow by more than
    25% or when a previously empty address family appears from zero while the
    other family already had entries, refreshed every
    egress_provider_refresh_hours hours (default 24, from 6 to 168, configurable
    under Settings, Filtering), recorded in the Audit log with commit SHA and
    counts, and exported into dst_allow_ip.txt as a deduplicated union without
    touching the manual egress allowlist.

  • The observability profile starts: infra/ holds the Prometheus
    configuration (scraping backend:5000/metrics and waf:8080/metrics), six
    alert rules in infra/prometheus/rules/spm.rules.yml and the Grafana
    Prometheus datasource. A CI job starts the profile and checks both.

  • GET /api/status and GET /api/security/cve report cves_checked, false
    for a Squid major version the CVE list does not cover (the Squid 7 the image
    ships); the dashboard shows "Not checked for Squid 7.x" instead of a zero
    count.

Changed

  • Dashboard sessions moved from localStorage to an HttpOnly cookie.
    Script in the page, an injected one included, can no longer read the
    session token. The cookie is SameSite=Strict, Path=/api, Secure over
    https, and lasts JWT_EXPIRE_DURATION. A state-changing request
    authenticated by the cookie alone needs X-Requested-With: spm and a
    same-site Origin, or answers 403. GET /api/session reports the session
    (username, via, expires_at), and logout and password changes clear or
    renew the cookie. CORS no longer names the dashboard's host over plain
    http://.

  • The notification queue reads and decrypts the notification settings once
    for a burst of alerts instead of once per alert (measured: about 28 µs and
    119 allocations per read on an Apple M4, 31 ns for a cached one). A save
    through the API, a config restore or a database reset applies to the next
    alert; a change made outside the API reaches the queue within 2 seconds.

  • The UI no longer shows the global "Failed to load data" toast on top of the
    error each page already reports in place. Signing in reads
    GET /api/settings once instead of twice, and the active Blacklists tab and
    the Egress page take the list count from the page they already download
    instead of a second ?limit=1 request. The sign-in screen, the setup wizard
    and the global search load as separate chunks: the entry chunk goes from
    76.3 kB to 35.7 kB (23.1 kB to 11.0 kB gzip). Unused CSS, components and the
    zod dependency are gone.

  • The setup wizard writes only the settings it shows: it no longer sets
    enable_proxy_auth, ssl_bump_enabled, enable_time_restrictions and
    enable_bandwidth_limits to false.

  • BREAKING (X-API-Version: 1): with gdpr_mode on, client endpoints
    and log filters operate on /24 (IPv4) and /64 (IPv6) networks instead of
    single addresses
    (#459). GET /api/clients/statistics and top_clients in
    GET /api/dashboard/summary aggregate rows by network (ip_address / ip
    masked as 192.168.1.x or 2001:db8:1:2::x, network CIDR, distinct
    addresses count, and "mixed" classification when a network mixes internal
    and external addresses); GET /api/clients/{ip}/details and the source_ip
    filter on GET /api/logs and GET /api/logs/export accept /24 and /64
    CIDRs (both URL-encoded %2F and /api/clients/{ip}/{prefix}/details) and
    refuse single addresses with 400 while gdpr_mode is on; GET /api/database/export adds "contains_unmasked_addresses": true when
    gdpr_mode is on.

  • The sign-in page, the setup wizard and the device setup panel use the design
    system the other pages use (tokens, shared buttons and notices, both
    themes); a test fails if a legacy colour or shadcn class returns under
    ui/src. The unused IpBadge and Card components are removed.

  • WAF_FAIL_OPEN reaches the WAF as well as the proxy in both compose files,
    as their comment said; the config contract check fails if a shared variable
    is missing from either service.

  • A new install stores a 2000 MB disk cache, the size Squid already used until
    the first save; an install that saved 1000 keeps 1000.

Fixed

  • Signing out with unsaved Settings changes asks first ("Sign out and lose 3
    unsaved changes?", naming the sections); before, the changes were dropped
    without a word.

  • A database reset now clears the cached gdpr_mode, which kept its old
    value for up to 30 seconds after the reset emptied the settings.

  • Proxy login: a changed proxy password works at once. The backend writes
    proxy_users.htpasswd with the proxy group (gid 13) itself, also on the
    file's first write, as a member of that group through group_add and with
    no capabilities added. Before, the file kept the backend's group until the
    proxy's generator reset it on the next reload, and Squid answered 407 to
    the new credentials in between. scripts/check-config-contract.sh fails if
    a compose file loses group_add, and the ACL matrix fails if the proxy
    image's proxy user moves off gid 13.

  • Accessibility: the password reveal button sits inside the field and keeps
    one name ("Show password") with a pressed state; the expanded Search button's
    name matches its visible text; the sidebar is an aside landmark; the global
    search is a combobox with a listbox, aria-activedescendant and a spoken
    result count; tables keep a keyboard-focusable scroll box without repeating
    the name of the section around them as a second region.

  • A settings refetch that fails after the first load (after a save, say) no
    longer replaces the whole UI with the "backend did not answer" screen and
    its unsaved drafts; a sign-in screen or wizard chunk that fails to load
    shows an error instead of a blank page.

  • With gdpr_mode on, GET /api/dashboard/summary (last_24h_clients and
    last_24h_internal_sources) counts distinct /24 and /64 networks via
    privacy.NetworkPrefix instead of distinct unmasked IP addresses, as
    GET /api/clients/statistics already does (#459); Clients
    labels the search box (Filter clients by network or IP address) and lifetime
    detail note (for this network) for network rows, and Logs converts masked
    192.168.1.x and 2001:db8:1:2::x addresses to /24 and /64 CIDRs (Show this network's requests) instead of hiding the filter action.

  • POST /api/maintenance/restore-config no longer adds restored_egress_providers
    into restored_entries; Maintenance states in the Backup description that
    enabled egress providers and custom URLs are backed up without their downloaded
    entries, lists egress providers in the pre-restore confirmation and post-restore
    result (8 list entries and 1 egress provider), notes when provider lists are
    downloading in the background (egress_providers_refreshing), and invalidates
    the query cache after a restore; egress_provider_refresh_hours uses its own
    provider_cycle apply mode (StartEgressProviderRefresh sleeps for the full
    interval even when egress_default_deny is off), and ChangePassword uses
    sentence case on its field labels and submit button.

  • First-run sign-in, setup wizard and client setup report what actually
    happened: Login distinguishes 401 (Invalid username or password), 429
    rate-limit responses (showing the backend's detail), 5xx or unreachable
    backend errors, and missing tokens, and adds a password reveal toggle (also
    added to Confirm New Password in Settings); App shows a retry notice instead
    of skipping the setup wizard when GET /api/settings fails on initial load;
    SetupWizard removes the misleading devices step, describes every setup and
    strictness option by the exact settings it enables without turning on
    bandwidth limits or aggressive_caching_enabled, invalidates the settings
    query cache after saving, and reports the real applyToProxy() outcome
    (applied, pending, refused, failed) referencing Settings, Admin and backup, Reload the proxy; POST /api/maintenance/reload-config answers 500
    (code: trigger_write_failed) instead of 200 success when .reload-squid
    cannot be written; ClientSetup awaits navigator.clipboard.writeText before
    reporting a copy, shows Authentication: Username and password on iOS when
    enable_proxy_auth is on, and removes the duplicate Connect Your Devices
    heading.

  • gdpr_mode (Settings > Logs and privacy > Mask client IPs) masks client
    addresses everywhere they leave the backend: GET /api/clients/statistics,
    GET /api/clients/{ip}/details, GET /api/dashboard/summary (top_clients
    and recent_blocks), GET /api/logs, GET /api/logs/export, /api/ws/logs,
    the MCP server and outbound WAF alert notifications (formatted messages, raw
    webhooks and digests), instead of leaving full addresses in client statistics,
    the dashboard and alerts; IPv6 addresses are masked to their /64 prefix
    (2001:db8:1:2::x) instead of only the last 16-bit group (#459).

  • Squid serves distinct branded error pages for DNS lookup failures
    (ERR_DNS_FAIL), refused or unreachable destinations (ERR_CONNECT_FAIL),
    interrupted reads (ERR_READ_ERROR), socket creation failures
    (ERR_SOCKET_FAILURE), unreachable ICAP security inspection when
    WAF_FAIL_OPEN=0 (ERR_ICAP_FAILURE), and every other Squid 7 error
    template, instead of showing "Access Denied" for network errors or stock
    Squid pages with ERROR: loading file lines in cache.log; the Access
    Denied page shows the client IP address instead of "-" (#443).

  • POST /api/database/reset clears the tables in one transaction and answers
    500 naming the table that failed, instead of reporting success after errors;
    its own audit row is written after the wipe, so it is the first row of the
    new log.

  • GET /api/docs is listed as requiring authentication, as it does.

  • The installer's port check and its hint read compose.prod.yml, the file
    the production stack runs.