Files
Clément Fiere 15c0c35df6 feat(auth): resolve session hostnames (#2741)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-04 13:06:33 +00:00

22 KiB

slug, title, status
slug title status
session-hostname-resolution Optional reverse-DNS hostnames for account sessions draft

Optional reverse-DNS hostnames for account sessions

Problem

The account security page (/settings/account/security) lists signed-in devices with their IP address. IPs are opaque; users often want to know which hostname an IP belongs to (their own machines, a corporate proxy, a cloud host) without leaving the page. Resolving every session IP unconditionally would leak per-IP DNS traffic and column width for users who do not need it, so the feature is an opt-in toggle, off by default.

Goal

  • A per-user toggle Resolve device hostnames on the account security page, disabled by default.
  • When enabled, the sessions table gains a Hostname column. The backend reverse-resolves each session IP through the OS-configured DNS resolver (never an external API or hardcoded resolver) in the background and streams results to the page as they complete.
  • IPs with no PTR answer, or with only a synthesized generic <reversed-ip>.in-addr.arpa / .ip6.arpa name, display N/A.
  • Disabling hides the column. Re-enabling restores it immediately with cached results, then the backend re-runs the lookups and streams only values that changed since the cache.

Non-goals

  • No DNS lookups happen while the toggle is off. Hostname data is rendered only on the account security page (no other UI surface reads or shows it). Transport note: streamed notifications are delivered to the authenticated user's own WebSocket connections (all their tabs), because the gateway fan-outs per user; other tabs retain the in-memory map but never render it, which is what makes re-enable and cross-tab navigation show cached results instantly. A page-scoped subscription that delivers only to the security page is a possible future narrowing, not part of this change.
  • No external DNS-over-HTTPS, geolocation, or enrichment APIs. Only the OS resolver (net.LookupAddr) is used.
  • No change to how sessions are created, stored, or revoked.
  • No per-session hostname persistence: the cache is keyed by IP (reverse DNS is an IP property, identical for every session that shares it).

User stories

  1. Alice signs in from 192.0.2.10. On the security page she enables the toggle. The Hostname column appears; the row shows N/A (the empty value) until the lookup completes, then the hostname replaces it without a reload.
  2. Bob enables the toggle while one of his devices is behind a NAT that has no PTR record. The row shows N/A.
  3. Alice disables the toggle. The column disappears. She enables it again five minutes later: the column reappears with the previously resolved hostnames immediately, then re-resolves in the background; only rows whose hostname changed update.
  4. Carol signs in from a new IP while the toggle is already on. The next time the security page's session list refreshes (window focus returns or the page reloads) the new session's row appears and its hostname resolves in the background and streams into the row. Live discovery of sessions created elsewhere while the page stays focused is out of scope (no session-created push event).

Contracts

User setting

  • Wire field: resolve_session_hostnames: boolean (top-level user settings, snake_case on the wire).
  • Frontend state: userSettings.resolveSessionHostnames: boolean.
  • Default: false for missing stored values and initial payloads.
  • PATCH semantics: omitted field preserves the current value; explicit true/false is durable. Changing it to true triggers background resolution for the user's session IPs (fire-and-forget; the PATCH response is unaffected by lookup results).
  • The setting is broadcast in the complete user.settings.updated WebSocket event so other tabs and the boot hydration path stay in sync through the existing shared mapper.

Sessions API

GET /api/v1/auth/sessions (unchanged auth/permission requirements) adds two fields per session:

{ "id": "...", "ip": "192.0.2.10", "hostname": "mail.example.com", "hostname_resolved_at": "2026-08-16T12:00:00.123456789Z", ... }
  • hostname is always present: the cached value when known, "" when unknown or when the toggle is off. When the toggle is off the resolution path is gated end to end: no lookups are scheduled and the returned map is empty, so no cached hostname ever leaks into the response.
  • hostname_resolved_at is the cache row's resolved_at when a cache row exists, null otherwise. It is formatted with the same fixed 9-digit fractional-second RFC3339 UTC layout used everywhere (2006-01-02T15:04:05.000000000Z07:00, e.g. "2026-08-16T12:00:00.123456789Z"), so the field is lexicographically comparable in chronological order and lets the frontend order list snapshots against streamed events (see Frontend).
  • A background resolution pass is scheduled for any of the user's session IPs that are not cached, not in flight, and not resolved in the last recentResolveWindow, but only when the toggle is on.

Reverse-DNS cache (backend)

  • New SQLite table auth_hostname_cache, owned by the hostnames cache store (apps/backend/internal/auth/hostnames/store.go), which runs its own CREATE TABLE IF NOT EXISTS in its initSchema (a new table needs no ADD COLUMN migration); it is the sole consumer of the table: ip TEXT PRIMARY KEY, hostname TEXT NOT NULL DEFAULT '', resolved_at TEXT NOT NULL.
  • Global by IP (PTR answers do not vary per user). hostname == "" means "no answer" and is itself cached so re-enables do not re-spin a spinner; cache presence (a row exists) is what distinguishes "resolved to no answer" from "never resolved", and resolved_at drives the recentResolveWindow re-query guard.
  • Cache rows never expire for display. Re-resolution happens when the toggle is turned on and when new IPs appear on the sessions list.

Lookup pipeline

  • Session IPs are validated with net.ParseIP and canonicalized (ip.String(): v4-mapped IPv6 becomes dotted-quad, IPv6 is lowercased) before they become cache keys, job keys, or net.LookupAddr inputs. Invalid or empty IPs are skipped entirely.
  • net.LookupAddr (OS resolver) per IP, using a resolver-owned context canceled by Close(), never the triggering request's context (which ends when the HTTP response completes). Request/service contexts are used only for synchronous cache and settings reads.
  • Records are examined in resolver order; the first record that normalizes to a valid hostname is used (see normalization below). If none does, the IP has no answer.
  • Normalization: trim trailing dot; a name that is empty or ends in .in-addr.arpa / .ip6.arpa (case-insensitive) is not a valid hostname.
  • Bounded background workers (constant lookupWorkers = 4) process a pending set that workers drain; admission never drops an IP. Pending entries carry the observed cache value the trigger read. One job per IP; in-flight IPs are deduplicated; a second trigger for the same IP records the requesting user as interested instead of starting a second lookup. Immediately before calling LookupAddr, the worker re-reads the settings gate for every interested user (fail closed on error) and drops users who disabled the toggle since admission; if no interested user remains enabled, the job is skipped — DNS never runs while the toggle is off, even for work admitted while it was on. When a worker finds an IP already fresh within recentResolveWindow, it skips the DNS query but compares the current cache value against the observed value and publishes the current value to the job's interested users when they differs — a user who scheduled from a stale snapshot must still learn of the change since their read (a read → concurrent completion → enqueue interleaving must never silently drop the update). The diff compares the cached hostname (not the timestamp, which now refreshes on every successful lookup).
  • Per-lookup timeout (lookupTimeout = 3s). Every successful lookup — valid records, and *net.DNSError with IsNotFound (NXDOMAIN, no PTR records) as a successful negative answer — persists its result and refreshes resolved_at even when the hostname is unchanged, so the recentResolveWindow re-query guard actually engages (a stale row that never refreshes its timestamp would be re-queried on every trigger). Publishing stays changed-only (hostname value differs). DNS errors are classified:
    • IsNotFound → hostname = "", persisted as a cached no-answer;
    • timeout, temporary, and server errors are treated as no answer for the UI but are not persisted. They are streamed as "" only when the IP has no cache entry at all; when a durable cache entry exists (hostname or no-answer), a transient failure is logged and nothing is published, so a transient blip can never mask an existing cached hostname in the client (the durable value stands until a real change or the next successful lookup).
  • recentResolveWindow = 30s: an IP resolved within this window is not re-queried on a new trigger (anti-thrash for rapid toggle cycles; the OS resolver's own cache makes repeated lookups cheap but unnecessary).

Streaming

  • New event auth.session.hostname.resolved on the event bus, payload {user_id, ip, hostname, resolved_at} (fixed 9-digit RFC3339 UTC string for a persisted result, null for a non-persisted transient result), published only when the resolved hostname value differs from the cache (a first resolution with an empty cache counts as a change; a failed lookup that yields "" against an absent cache also counts as a change). resolved_at travels with the event so the gateway and frontend can preserve newer-wins ordering; dropping it breaks reconciliation.
  • Key contract: cache keys and job keys are the canonical IP (net.ParseIP(...).String()); the event's ip is the raw stored session IP of the triggering user's session (the same string the frontend already holds as AuthSession.ip), so the frontend matches events to rows without any IP parsing. When several users — or one user with several sessions — trigger the same canonical IP with different raw spellings, each (user, raw spelling) pair receives an event carrying that raw spelling. The resolver canonicalizes internally for job/cache keys but returns raw-input-keyed results: the sessions handler looks up m[session.IP] directly with no canonicalization of its own, so a non-canonical stored spelling (e.g. ::ffff:192.0.2.10) still finds its cached hostname.
  • New WebSocket notification action auth.session.hostname.resolved, payload {ip, hostname, resolved_at} (the gateway strips user_id), routed to every user that triggered a lookup for that IP (a user-triggered lookup registers the user as interested with their raw IP spelling; all interested users receive the update). resolved_at is the RFC3339 UTC time the result was persisted with a fixed 9-digit fractional-second field (e.g. 2026-08-16T12:00:00.123456789Z, lexicographically comparable in chronological order), or null only for a transient DNS error/timeout streamed over an IP with no cache entry (an empty-string event with nothing durable to mask). A failed cache write publishes nothing, so null never denotes a failed-write hostname.
  • The frontend merges streamed values into a per-IP map keyed by the raw session IP and re-renders matching rows.

Frontend

  • Toggle card on /settings/account/security: label Resolve device hostnames, description stating that lookups use the system DNS resolver and apply to the IPs of signed-in devices. Saves immediately through PATCH /api/v1/user/settings (page-local save, consistent with the rest of the account page).
  • Sessions table: the Hostname column renders only while the toggle is on. Cell value resolution compares the streamed map entry ({hostname, resolvedAt} — camelCase frontend state field; the wire field resolved_at is reserved for the WS payload/API) with the session's response entry (hostname, hostname_resolved_at): newer resolvedAt wins; a durable entry (non-null resolvedAt) beats a transient/unknown one (null); ties break to the non-empty value; both empty → N/A. This is what makes list snapshots authoritative over stale streamed entries after a WebSocket gap (the cache's resolved_at is newer than the retained map value) while still letting a fresh streamed event beat an in-flight stale response. There is no pending spinner state: an empty value is indistinguishable from a cached no-answer, so it always renders N/A, and a streamed event replaces it as soon as the lookup completes (an unchanged cached no-answer row renders N/A until the row's freshness expires and a retry finds a valid answer — see recentResolveWindow; it does not stay N/A forever by construction). The streamed map survives disable/enable cycles in the client store, which is what makes re-enable show cached results before the backend re-query streams deltas.
  • The sessions list refreshes when the window regains focus (and on mount / manual reload), so sessions created elsewhere appear without a full reload; their IPs are then resolved by the existing list-fetch trigger. No session-created push event is added.
  • When the toggle transitions to on — same tab or another tab (the user.settings.updated push updates the store) — the sessions list is refetched once. This is what renders backend-cached hostnames when the list was last loaded while the toggle was off (those responses carried hostname: ""), and it also discovers sessions created meanwhile.
  • Identity policy for the in-memory streamed map: it survives disable/enable cycles and same-identity refreshes, and is cleared when the authenticated identity changes — logout, a different user logging in, or the 401/session-expiry path (clearAuthenticated) — so one user's session IPs never seed another user's rows. Because the gateway strips user_id from streamed events, the stream is fenced on identity transitions: a handler-generation epoch guard ignores events from a prior identity, and the WS client is recreated on identity change so transport-queued frames are dropped — a delayed old-user event can never repopulate the cleared map.
  • N/A is localized copy (account:hostnameNotAvailable); all new copy goes through t() and every locale catalog including pseudo.

Mobile

  • The toggle row is a standard 44 px+ touch target; the sessions table remains the sole internal scroll owner with no document-level horizontal overflow when the Hostname column is added.
  • The account security page is reachable on the Pixel 5 path; a mobile-*.spec.ts in the mobile-chrome project covers enable/disable, column presence, and N/A/cached rendering (same env-robust assertions as the desktop spec).

Permissions

  • The toggle lives in per-user settings; PATCH /api/v1/user/settings is already identity-scoped.
  • The sessions endpoint and the new streaming action are user-scoped: the gateway routes auth.session.hostname.resolved with Hub.BroadcastToUser to the interested user's own connections only; the payload never contains a user_id.
  • The cache is populated only from the user's own session IPs; no endpoint exposes the cache directly.

State machine

  • Toggle off: no lookups, no column, sessions response carries hostname: "".
  • Toggle on: sessions list triggers a resolution pass; per-IP jobs run once (dedup by in-flight + recentResolveWindow); each changed result is streamed; the column renders cached + streamed values.
  • Toggle off: lookups already in flight complete and update the cache, but their events are only sent to interested users; the column hides.
  • Toggle on again: cached values render immediately from the client store and the sessions response; a new resolution pass streams only deltas.

Failure modes

  • Settings-gate read error: the resolver cannot confirm the toggle is on, so it fails closed — returns an empty hostname map, schedules no lookups, and logs. DNS queries must never run while opt-in is unknown.
  • DNS timeout/temporary/server error: cell shows N/A only when nothing durable is known; nothing persisted; streamed "" only when the IP has no cache entry, never over an existing cached value; the next trigger (new session list load or toggle) retries. A *net.DNSError with IsNotFound is NOT an error: it is a successful negative answer persisted as hostname = "" (cached no-answer).
  • Cache read error on the sessions API path: the affected IPs return "" (no hostname), no lookups are scheduled for them (baseline unknown), the request is not failed, and the error is logged.
  • Cache read error on lookup completion: the baseline is unknown, so the result is neither persisted nor published (a change cannot be verified); the error is logged and the next trigger retries.
  • Cache write error on lookup completion: logged; nothing is published — the value was not persisted, so changed-only semantics do not admit an event (a transient publish could outlive its own retry and the frontend ordering would treat it inconsistently). The next trigger retries the persist; a later successful Set that changes the cache publishes normally. resolved_at: null remains reserved for transient DNS errors streamed over an absent cache (empty-string events), never for failed writes.
  • Event bus unavailable (nil): no stream; cached values still flow through the sessions response.
  • Invalid/empty session IP (e.g. sessions recorded before IP capture): skipped, cell shows N/A.
  • Multi-user instance: user A's lookup for an IP is shared with user B only when B also triggered a lookup for that same IP (B is registered as interested, with B's raw IP spelling); the cache itself is shared by IP, which is safe because PTR answers are not user-specific.

Persistence guarantees

  • The toggle is durable in the user settings JSON blob (default false).
  • The hostname cache is durable in auth_hostname_cache and survives backend restarts, which is what makes "re-enable shows cached results" hold even after a restart.
  • The frontend streamed map is in-memory only; a page reload re-hydrates from the sessions response (backend cache) and the next lookup pass.

Data model

CREATE TABLE IF NOT EXISTS auth_hostname_cache (
    ip          TEXT PRIMARY KEY,           -- canonical IP literal
    hostname    TEXT NOT NULL DEFAULT '',   -- "" = no answer (N/A)
    resolved_at TEXT NOT NULL               -- RFC3339 UTC text; drives recentResolveWindow
);

resolved_at is stored as RFC3339 UTC text with a fixed 9-digit fractional-second field (dialect-portable across the SQLite/Postgres db.Pool) and parsed explicitly on read; fixed width keeps the strings lexicographically comparable, so changes within the same second — or the same millisecond — still order correctly.

API surface

  • PATCH /api/v1/user/settings accepts resolve_session_hostnames: boolean.
  • GET /api/v1/auth/sessions per-session response gains hostname: string and hostname_resolved_at: string | null.
  • WS notification auth.session.hostname.resolved: {ip, hostname, resolved_at}.
  • No new HTTP endpoints.

Acceptance criteria

  1. Toggle defaults to off for new users, existing settings blobs, and initial boot payloads; toggling persists and survives reload.
  2. Enabling shows the Hostname column immediately (cached values or N/A), and streamed results fill in rows without a reload; there is no pending spinner state (empty always renders N/A). Enabling also refetches the sessions list so backend-cached hostnames render even when the list was last loaded while the toggle was off (same tab or another tab).
  3. Disabling hides the column; re-enabling within the same session shows cached values immediately and streams only changed values.
  4. Sessions created elsewhere appear on the next list refresh (window focus, the enable-transition refetch, or reload) and their hostnames then resolve and stream into the new rows.
  5. The in-memory hostname map survives disable/enable and same-identity refreshes and is cleared when the authenticated identity changes (logout, different user, or the 401/clearAuthenticated path).
  6. No-answer IPs and generic .in-addr.arpa / .ip6.arpa answers render N/A.
  7. Lookups use the OS resolver only: the lookup function is injectable and defaults to net.DefaultResolver.LookupAddr; no HTTP/API calls exist in the lookup path.
  8. GET /api/v1/auth/sessions includes hostname (cached value or "") without auth-mode changes.
  9. Streaming events are routed to the triggering user's own connections only and never include user_id.
  10. Streamed event ip values match the raw session IP spelling the frontend already holds, so rows update without client-side IP parsing; the sessions handler looks up cached hostnames by the raw stored spelling (the resolver canonicalizes internally), so non-canonical spellings still resolve, and equivalent IP spellings still share one cache row and one lookup job.
  11. Cache read/write failures degrade per the Failure modes section without failing the sessions API or panicking (nil event bus included).
  12. A transient DNS failure never masks a durable cached hostname: the backend publishes transient "" only for IPs with no cache entry, and the frontend compares resolved_at (durable beats transient; newer wins), so a stale-cache + transient-error + re-enable cycle still restores the cached value.
  13. After a WebSocket gap, a newer sessions response reconciles the retained streamed map: an entry whose cache resolved_at is newer than the retained map value replaces it (unit regression: event → disconnect → list refresh), and a fresh streamed event still beats an in-flight stale response. resolved_at uses fractional-second precision so two changes within one second still order correctly.
  14. A user who scheduled resolution from a stale snapshot still receives the current cache value when it changed since their read (admission observed-value diff), so a read → concurrent completion → enqueue interleaving never silently drops the streamed update.
  15. New frontend copy is localized (all catalogs + pseudo) and passes i18n:ratchet.