Skip to content

Commit 3752f5f

Browse files
release: v2.2.1
Secure loopback HTTPS admin access (Caddy 8443 + HTTP/2), dashboard performance work (SQLite metadata queries, log-accounting, session rollups), and DataTable column-resizing fixes. Co-Authored-By: Drew Michael <dmichael@fastly.com>
1 parent f57959e commit 3752f5f

41 files changed

Lines changed: 929 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Caddyfile

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
# range, refresh this list. A stale list means legitimate traffic from a
2727
# new POP is treated as direct (untrusted) until Caddy reloads.
2828
{
29-
# No auto-HTTPS — Fastly handles TLS termination at the edge.
30-
auto_https off
29+
# Disable automatic HTTP-to-HTTPS redirects. We serve HTTP explicitly on port 80
30+
# behind Fastly, but manage TLS for other explicit blocks (port 8443).
31+
auto_https disable_redirects
3132

3233
# Rate limit module (provided by custom Caddy image, see caddy/Dockerfile).
3334
# 5 share-login attempts / minute per Fastly client IP.
@@ -349,3 +350,51 @@ Policy: https://github.com/fastly/fastly-log-analytics/blob/main/SECURITY.md
349350
level INFO
350351
}
351352
}
353+
354+
# Loopback HTTPS block on port 8443 for secure direct SSH-tunnel admin connections.
355+
# This terminates TLS using Caddy's internal self-signed certificate authority,
356+
# enabling the browser to negotiate HTTP/2 (multiplexing) over the tunnel.
357+
# This prevents client-side TCP connection starvation (tab freezing) while keeping
358+
# admin access bound strictly to the local loopback/SSH boundary.
359+
https://localhost:8443, https://127.0.0.1:8443 {
360+
# Caddy only restricts the listener's bind address when every address in
361+
# a site block is a literal IP — "localhost" is a hostname, so without
362+
# this directive Caddy binds :8443 on ALL interfaces (verified via
363+
# `caddy adapt`), not just loopback, despite the comment above. Pin the
364+
# listener itself to loopback so the X-Forwarded-For pin below can't be
365+
# reached by anything but a genuine same-host connection.
366+
bind 127.0.0.1 ::1
367+
368+
tls internal
369+
370+
encode zstd gzip
371+
request_body {
372+
max_size 25MB
373+
}
374+
375+
import security_headers
376+
377+
# API → backend (preserve Host so backend's DNS-rebinding gate matches the
378+
# registered public_endpoint). Pin X-Forwarded-For to 127.0.0.1 so the
379+
# backend classifies the operator as the local admin.
380+
@api path /api/*
381+
reverse_proxy @api 127.0.0.1:8000 {
382+
header_up X-Forwarded-For 127.0.0.1
383+
flush_interval -1
384+
transport http {
385+
response_header_timeout 120s
386+
read_timeout 120s
387+
}
388+
}
389+
390+
# Everything else → Next.js frontend.
391+
reverse_proxy 127.0.0.1:3000 {
392+
flush_interval -1
393+
}
394+
395+
log {
396+
output stdout
397+
format json
398+
level INFO
399+
}
400+
}

backend/core/_duckdb_status.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ def update_top_values(con: duckdb.DuckDBPyConnection, source: dict):
584584
"pop",
585585
]
586586

587-
schema_cols = {f["name"] for f in get_schema(con, source)}
587+
schema_cols = {f["name"] for f in get_schema(con, source, stats=False)}
588588
fields = [f for f in fields if f in schema_cols or (f == "waf_sig_ind" and "waf_sig" in schema_cols)]
589589

590590
if not fields:
@@ -813,8 +813,8 @@ def delete_ingested_files(
813813
}
814814

815815

816-
_schema_cache: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {}
817-
# (source_name, table_name) -> (timestamp, schema_list)
816+
_schema_cache: dict[tuple[str, str, bool], tuple[float, list[dict[str, Any]]]] = {}
817+
# (source_name, table_name, stats) -> (timestamp, schema_list)
818818
# The heavy refresh_config_status path fires SUMMARIZE every 60 s. With the
819819
# previous 60 s TTL the cache aged out at exactly the heavy-tick interval —
820820
# now-ts hit 60.0 right when the next call landed, so we missed every time
@@ -838,14 +838,18 @@ def _clear_schema_cache(source_name: str | None = None):
838838
_schema_cache = {}
839839

840840

841-
def get_schema(con: duckdb.DuckDBPyConnection, source: dict | None = None) -> list[dict]:
841+
def get_schema(
842+
con: duckdb.DuckDBPyConnection,
843+
source: dict | None = None,
844+
stats: bool = True,
845+
) -> list[dict]:
842846
"""Return column names and types for a source's table."""
843847
src = source or _db_main._DEFAULT_SOURCE
844848
source_name = src["name"]
845849
table_name = _safe_table_name(source_name)
846850

847851
now = time.time()
848-
cache_key = (source_name, table_name)
852+
cache_key = (source_name, table_name, stats)
849853
if cache_key in _schema_cache:
850854
ts, schema = _schema_cache[cache_key]
851855
if now - ts < _SCHEMA_CACHE_TTL:
@@ -864,6 +868,13 @@ def get_schema(con: duckdb.DuckDBPyConnection, source: dict | None = None) -> li
864868
if not table_exists:
865869
return []
866870

871+
if not stats:
872+
# SRE-22: Instant catalog schema reflection via DESCRIBE bypasses heavy data scans
873+
result = con.execute(f"DESCRIBE {table_name}").fetchall()
874+
schema = [{"name": r[0], "type": r[1]} for r in result]
875+
_schema_cache[cache_key] = (now, schema)
876+
return schema
877+
867878
# Use SUMMARIZE to get rich metadata instead of just DESCRIBE.
868879
# 10_000 rows is enough sample for the precision the UI displays
869880
# (null % to 1 decimal, approx_unique relative error ~3%); the prior

backend/core/duckdb.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,25 @@ def get_connection(
10691069
# to keep stderr clean during the dashboard's RO query path.
10701070
logger.debug("[duckdb] update_iceberg_view skipped on RO connection: %s", e)
10711071

1072+
# Pre-attach NGWAF bot cache if configured and exists to eliminate request-time ATTACH latency (1.2s overhead)
1073+
try:
1074+
from backend import config as svcconfig
1075+
1076+
ngwaf_db = svcconfig.ngwaf_db_path()
1077+
if ngwaf_db and os.path.exists(ngwaf_db):
1078+
existing = con.execute(
1079+
"SELECT database_name FROM duckdb_databases() WHERE database_name IN ('ngwaf_top', 'ngwaf_cache')"
1080+
).fetchall()
1081+
attached_aliases = {row[0] for row in existing}
1082+
1083+
ngwaf_db_escaped = ngwaf_db.replace("'", "''")
1084+
if "ngwaf_top" not in attached_aliases:
1085+
con.execute(f"ATTACH '{ngwaf_db_escaped}' AS ngwaf_top (TYPE SQLITE, READ_ONLY)")
1086+
if "ngwaf_cache" not in attached_aliases:
1087+
con.execute(f"ATTACH '{ngwaf_db_escaped}' AS ngwaf_cache (TYPE SQLITE, READ_ONLY)")
1088+
except Exception as e:
1089+
logger.warning("[duckdb] Failed to pre-attach NGWAF bot cache: %s", e)
1090+
10721091
# Operational metadata (alerts, views, audit, cron, sources, ingested_files,
10731092
# asn_names, usage_log) lives in per-service SQLite — see backend.core.metadata package.
10741093
# DuckDB now holds nothing but session-scoped Iceberg views and temp tables.

backend/core/metadata/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
close_all_connections,
7171
db_path,
7272
get_con,
73+
get_con_readonly,
7374
teardown,
7475
)
7576

@@ -232,6 +233,7 @@ def __setattr__(self, name: str, value) -> None:
232233
# Connection / schema (public)
233234
"db_path",
234235
"get_con",
236+
"get_con_readonly",
235237
"close_all_connections",
236238
"teardown",
237239
# Alerts

backend/core/metadata/asn_cache.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,23 @@
44

55
from datetime import UTC, datetime, timedelta
66

7-
from backend.core.metadata.base import get_con
7+
from backend.core.metadata.base import get_con, get_con_readonly
88
from backend.utils.date_utils import iso_z, iso_z_now
99

1010

1111
def lookup_asn_names(service_id: str, asns: list[int], max_age_days: int = 30) -> dict[int, str]:
1212
"""Return cached {asn: name} for the requested ASNs that are still fresh."""
1313
if not asns:
1414
return {}
15-
con = get_con(service_id)
15+
import contextlib
16+
1617
fresh_cutoff = iso_z(datetime.now(UTC) - timedelta(days=max_age_days))
1718
placeholders = ",".join("?" * len(asns))
18-
rows = con.execute(
19-
f"SELECT asn, name FROM asn_names WHERE asn IN ({placeholders}) AND fetched_at >= ?",
20-
list(asns) + [fresh_cutoff],
21-
).fetchall()
19+
with contextlib.closing(get_con_readonly(service_id)) as con:
20+
rows = con.execute(
21+
f"SELECT asn, name FROM asn_names WHERE asn IN ({placeholders}) AND fetched_at >= ?",
22+
list(asns) + [fresh_cutoff],
23+
).fetchall()
2224
return {int(r["asn"]): r["name"] for r in rows}
2325

2426

@@ -41,9 +43,11 @@ def asn_ints_for_search(service_id: str, name_ilike: str) -> list[int]:
4143
Used by the dashboard ASN search to pre-fetch matching ASNs and inline them
4244
into a DuckDB IN clause (avoids cross-engine JOINs).
4345
"""
44-
con = get_con(service_id)
45-
rows = con.execute(
46-
"SELECT asn FROM asn_names WHERE name LIKE ? COLLATE NOCASE",
47-
(name_ilike,),
48-
).fetchall()
46+
import contextlib
47+
48+
with contextlib.closing(get_con_readonly(service_id)) as con:
49+
rows = con.execute(
50+
"SELECT asn FROM asn_names WHERE name LIKE ? COLLATE NOCASE",
51+
(name_ilike,),
52+
).fetchall()
4953
return [int(r["asn"]) for r in rows]

backend/core/metadata/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ def get_con(service_id: str) -> sqlite3.Connection:
188188
return _pool.get(service_id)
189189

190190

191+
def get_con_readonly(service_id: str) -> sqlite3.Connection:
192+
"""Return a short-lived read-only SQLite connection for the given service.
193+
194+
This connection is not pooled and should be closed immediately.
195+
"""
196+
if not os.path.exists(db_path(service_id)):
197+
get_con(service_id)
198+
return _pool.open_readonly(service_id)
199+
200+
191201
def close_all_connections() -> None:
192202
"""Close every connection opened by ``get_con`` in any thread.
193203
@@ -316,6 +326,8 @@ def teardown(service_id: str) -> None:
316326
# TEMP B-TREE sort over the full table because `idx_cron_task_started`
317327
# requires a leading-`task` predicate to satisfy the ORDER BY.
318328
"CREATE INDEX IF NOT EXISTS idx_cron_started ON cron_runs(started_at DESC)",
329+
# Covers status polls and startup reap_running_cron_runs without a task filter
330+
"CREATE INDEX IF NOT EXISTS idx_cron_status ON cron_runs(status)",
319331
"""CREATE TABLE IF NOT EXISTS asn_names (
320332
asn INTEGER PRIMARY KEY,
321333
name TEXT NOT NULL,

backend/core/metadata/cron_log.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from collections.abc import Callable
1616
from datetime import UTC, datetime, timedelta
1717

18-
from backend.core.metadata.base import _ORPHAN_THRESHOLD_MINS, _TASK_ORPHAN_THRESHOLD_MINS, get_con
18+
from backend.core.metadata.base import _ORPHAN_THRESHOLD_MINS, _TASK_ORPHAN_THRESHOLD_MINS, get_con, get_con_readonly
1919
from backend.utils.date_utils import iso_z, iso_z_now, parse_iso_utc
2020

2121
logger = logging.getLogger(__name__)
@@ -443,10 +443,12 @@ def get_cron_run_status(service_id: str, run_id: int) -> str | None:
443443
on any DB failure so list_active_runs falls back to the in-memory
444444
signal (we'd rather show a false in-flight than miss a real one).
445445
"""
446+
import contextlib
447+
446448
try:
447-
con = get_con(service_id)
448-
row = con.execute("SELECT status FROM cron_runs WHERE id = ?", (run_id,)).fetchone()
449-
return row["status"] if row else None
449+
with contextlib.closing(get_con_readonly(service_id)) as con:
450+
row = con.execute("SELECT status FROM cron_runs WHERE id = ?", (run_id,)).fetchone()
451+
return row["status"] if row else None
450452
except sqlite3.Error as e:
451453
logger.debug("[metadata_db] get_cron_run_status(%s, %s) failed: %s", service_id, run_id, e)
452454
return None
@@ -459,12 +461,14 @@ def get_cron_run_result(service_id: str, run_id: int) -> dict | None:
459461
460462
Distinct from ``get_cron_run_status`` because the SSE stream also
461463
needs the log_output to replay the run's terminal lines."""
464+
import contextlib
465+
462466
try:
463-
con = get_con(service_id)
464-
row = con.execute("SELECT status, log_output FROM cron_runs WHERE id = ?", (run_id,)).fetchone()
465-
if row is None:
466-
return None
467-
return {"status": row["status"], "log_output": row["log_output"]}
467+
with contextlib.closing(get_con_readonly(service_id)) as con:
468+
row = con.execute("SELECT status, log_output FROM cron_runs WHERE id = ?", (run_id,)).fetchone()
469+
if row is None:
470+
return None
471+
return {"status": row["status"], "log_output": row["log_output"]}
468472
except sqlite3.Error as e:
469473
logger.debug("[metadata_db] get_cron_run_result(%s, %s) failed: %s", service_id, run_id, e)
470474
return None

0 commit comments

Comments
 (0)