From a28b61511cdbd45f33f2dc778ce3d8d4b9e2659f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 10 Jun 2026 17:02:47 +0200 Subject: [PATCH] security: enforce real plugin secret, fix proxy auth bypass, loopback DB ports, nightly backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SHARED_SECRET now read from env and fail-closed: unset/placeholder refuses ALL plugin connections (constant-time compare). The old hardcoded 'your_shared_secret' in this public repo was no auth at all. Dockerfile default removed; generate_data.py reads the env var. - SECRET_KEY fails closed at startup (main.py and agent/auth.py) instead of falling back to a publicly-known signing key; agent systemd unit now requires /etc/overlord/agent.env (no '-' prefix). - AuthMiddleware + /ws/live: replace the 172.x source-IP trust (which every nginx-proxied internet request satisfied via docker-proxy — full session bypass and unauthenticated in-game command injection) with private-source AND no X-Forwarded-For, i.e. only genuinely internal callers (overlord-agent on the host, compose-network services). Invariant documented in nginx/overlord.conf: every tracker-bound location must set X-Forwarded-For. - /character-stats/test endpoints gated behind admin (they upsert real rows). - docker-compose: bind 5432/5433 to 127.0.0.1 (both DBs were internet- reachable; active brute-force observed in dereth-db logs). - discord-rare-monitor: drop dead SHARED_SECRET constant. - scripts/backup-databases.sh + docs/backups.md: nightly pg_dump of both DBs (telemetry/spawn hypertable data excluded), 10MB canary, umask 077, TimescaleDB restore procedure. - Remove stray mangled-path css file from repo root. Adversarially reviewed pre-deploy (3-lens workflow): ship verdict; deploy- sequencing blockers addressed (secret staged before enforcement, exec bit set, cron uses bash). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- ...riknsourcereposdereth-workspacestyle_old.css | 2538 ----------------- Dockerfile | 5 +- agent/auth.py | 11 +- agent/overlord-agent.service | 6 +- discord-rare-monitor/discord_rare_monitor.py | 1 - docker-compose.yml | 9 +- docs/backups.md | 102 + generate_data.py | 7 +- main.py | 100 +- nginx/overlord.conf | 6 + scripts/backup-databases.sh | 53 + 12 files changed, 261 insertions(+), 2579 deletions(-) delete mode 100644 CUserseriknsourcereposdereth-workspacestyle_old.css create mode 100644 docs/backups.md create mode 100755 scripts/backup-databases.sh diff --git a/CLAUDE.md b/CLAUDE.md index 526c69d4..d143163a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Dereth Tracker is a real-time telemetry platform for Asheron's Call world tracki - Connection pool: `min_size=5, max_size=100, command_timeout=120` (`db_async.py:21`). Postgres `max_connections` is the default 100, shared with Grafana and the agent's read-only role — don't widen the pool further. - Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only. - Read-only agent role `overlord_agent_ro` is provisioned manually via `agent/sql/0001_overlord_agent_ro.sql` (SELECT-only). -- There is **no backup mechanism** — durability is the two Docker volumes (`timescale-data`, `inventory-data`). +- Backups: nightly cron on the host runs `scripts/backup-databases.sh` (pg_dump both DBs to `/home/erik/backups/postgres/`, 7-day retention; telemetry/spawn hypertable data deliberately excluded). Restore procedure: `docs/backups.md` — TimescaleDB needs `timescaledb_pre_restore()/post_restore()`. - `db.py` is a dead legacy SQLite layer — nothing imports it. All persistence goes through `db_async.py`. ## Route conventions diff --git a/CUserseriknsourcereposdereth-workspacestyle_old.css b/CUserseriknsourcereposdereth-workspacestyle_old.css deleted file mode 100644 index dbd6d346..00000000 --- a/CUserseriknsourcereposdereth-workspacestyle_old.css +++ /dev/null @@ -1,2538 +0,0 @@ -/* - * style.css - Core styles for Dereth Tracker Single-Page Application - * - * Defines CSS variables for theming, layout rules for sidebar and map, - * interactive element styling (buttons, inputs), and responsive considerations. - */ -/* CSS Custom Properties for theme colors and sizing */ -:root { - --sidebar-width: 400px; - --bg-main: #111; - --bg-side: #1a1a1a; - --card: #222; - --card-hov:#333; - --text: #eee; - --accent: #88f; -} -/* - * style.css - Styling for Dereth Tracker SPA frontend. - * Defines layout, theming variables, and component styles (sidebar, map, controls). - */ -/* Placeholder text in chat input should be white */ -.chat-input::placeholder { - color: #fff; - opacity: 0.7; -} - -html { - margin: 0; - height: 100%; - width: 100%; -} - -body { - margin: 0; - height: 100%; - display: flex; - overflow: hidden; - font-family: "Segoe UI", sans-serif; - background: var(--bg-main); - color: var(--text); - position: relative; -} - -.sort-buttons { - /* Container for sorting controls; uses flex layout to distribute buttons equally */ - display: flex; - gap: 2px; - margin: 12px 16px 8px; -} -.sort-buttons .btn { - /* Compact styling for sort buttons to fit 6 options */ - flex: 1; - padding: 4px 6px; - background: #333; - color: #ccc; - border: 1px solid #666; - border-radius: 3px; - text-align: center; - cursor: pointer; - user-select: none; - font-size: 0.75rem; - font-weight: 500; - transition: all 0.15s; - min-width: 0; - white-space: nowrap; - overflow: hidden; -} -.sort-buttons .btn:hover { - background: #444; - color: #fff; - border-color: #777; -} - -.sort-buttons .btn.active { - /* Active sort button highlighted with accent color */ - background: var(--accent); - color: #111; - border-color: var(--accent); - position: relative; -} - -.sort-buttons .btn.active:hover { - background: var(--accent); - color: #111; -} - -/* Sort direction indicators */ -.sort-buttons .btn.active::after { - content: ''; - position: absolute; - top: 2px; - right: 2px; - width: 0; - height: 0; - border-left: 3px solid transparent; - border-right: 3px solid transparent; -} - -/* Most sorts are descending (down arrow) */ -.sort-buttons .btn.active::after { - border-top: 4px solid #111; -} - -/* Name and KPR are ascending (up arrow) */ -.sort-buttons .btn.active[data-value="name"]::after, -.sort-buttons .btn.active[data-value="kpr"]::after { - border-top: none; - border-bottom: 4px solid #111; -} - -/* ---------- sidebar --------------------------------------------- */ -#sidebar { - width: var(--sidebar-width); - scrollbar-width: none; - background: var(--bg-side); - border-right: 2px solid #333; - box-sizing: border-box; - padding: 18px 16px; - overflow-y: auto; -} -#sidebar h2 { - margin: 8px 0 12px; - font-size: 1.25rem; - color: var(--accent); -} - -.total-rares-counter { - margin: 0 0 12px 0; - padding: 8px 12px; - background: linear-gradient(135deg, #2a2a2a, #1a1a1a); - border: 1px solid #444; - border-radius: 6px; - font-size: 0.95rem; - font-weight: 600; - color: #ffd700; - text-align: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.total-rares-counter #totalRaresCount { - color: #fff; - margin-left: 4px; -} - -.server-kph-counter { - margin: 0 0 12px 0; - padding: 9px 12px; - background: linear-gradient(135deg, #2a2a44, #1a1a33); - border: 2px solid #4466aa; - border-radius: 6px; - font-size: 1rem; - font-weight: 600; - color: #aaccff; - text-align: center; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); - position: relative; - animation: kph-border-glow 4s ease-in-out infinite; -} - -@keyframes kph-border-glow { - 0%, 100% { border-color: #4466aa; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); } - 50% { border-color: #6688cc; box-shadow: 0 3px 12px rgba(102, 136, 204, 0.3); } -} - -.server-kph-counter #serverKphCount { - color: #fff; - margin-left: 4px; - font-size: 1.1rem; - font-weight: 700; - text-shadow: 0 0 8px rgba(255, 255, 255, 0.3); - animation: kph-pulse 3s ease-in-out infinite; -} - -@keyframes kph-pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.02); } -} - -/* ULTRA MODE for KPH > 5000 */ -.server-kph-counter.ultra-epic { - background: linear-gradient(135deg, #6644ff, #4422cc, #6644ff); - background-size: 200% 200%; - animation: kph-border-glow 4s ease-in-out infinite, ultra-background 3s ease-in-out infinite; - border-color: #8866ff; - color: #eeeeff; - box-shadow: 0 4px 12px rgba(102, 68, 255, 0.5); -} - -@keyframes ultra-background { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -.server-kph-counter.ultra-epic #serverKphCount { - font-size: 1.3rem; - color: #ffffff; - text-shadow: 0 0 12px rgba(255, 255, 255, 0.7); - animation: kph-pulse 3s ease-in-out infinite, ultra-glow 2s ease-in-out infinite alternate; -} - -@keyframes ultra-glow { - from { text-shadow: 0 0 12px rgba(255, 255, 255, 0.7); } - to { text-shadow: 0 0 18px rgba(255, 255, 255, 0.9), 0 0 25px rgba(136, 102, 255, 0.5); } -} - -/* Server Status Styling */ -.server-status-container { - margin: 0 0 16px 0; - padding: 12px; - background: linear-gradient(135deg, #2a4a2a, #1a3a1a); - border: 2px solid #44aa44; - border-radius: 8px; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4); -} - -.server-status-container h3 { - margin: 0 0 10px 0; - font-size: 1.1rem; - color: #aaffaa; - text-align: center; - font-weight: 600; -} - -.status-indicator { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 8px; - font-weight: 600; - font-size: 1rem; -} - -.status-dot { - width: 12px; - height: 12px; - border-radius: 50%; - margin-right: 8px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); -} - -.status-dot.status-up { - background-color: #44ff44; - box-shadow: 0 0 8px rgba(68, 255, 68, 0.6); - animation: status-pulse-up 2s ease-in-out infinite; -} - -.status-dot.status-down { - background-color: #ff4444; - box-shadow: 0 0 8px rgba(255, 68, 68, 0.6); - animation: status-pulse-down 2s ease-in-out infinite; -} - -.status-dot.status-unknown, -.status-dot.status-error { - background-color: #ffaa44; - box-shadow: 0 0 8px rgba(255, 170, 68, 0.6); -} - -@keyframes status-pulse-up { - 0%, 100% { - box-shadow: 0 0 8px rgba(68, 255, 68, 0.6); - } - 50% { - box-shadow: 0 0 16px rgba(68, 255, 68, 0.9); - } -} - -@keyframes status-pulse-down { - 0%, 100% { - box-shadow: 0 0 8px rgba(255, 68, 68, 0.6); - } - 50% { - box-shadow: 0 0 16px rgba(255, 68, 68, 0.9); - } -} - -.status-details { - font-size: 0.85rem; - color: #ccc; - line-height: 1.6; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 16px; -} - -.status-details div { - display: flex; - align-items: center; - white-space: nowrap; -} - -.status-details span { - color: #fff; - font-weight: 500; - margin-left: 6px; -} - -.total-kills-counter { - margin: 0 0 12px 0; - padding: 8px 12px; - background: linear-gradient(135deg, #2a2a2a, #1a1a1a); - border: 1px solid #555; - border-radius: 6px; - font-size: 0.95rem; - font-weight: 600; - color: #ff6666; - text-align: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.total-kills-counter #totalKillsCount { - color: #fff; - margin-left: 4px; -} -#playerList { - list-style: none; - margin: 0; - padding: 0; -} -/* Filter input in sidebar for player list */ -.player-filter { - width: 100%; - padding: 6px 8px; - margin-bottom: 12px; - background: var(--card); - color: var(--text); - border: 1px solid #555; - border-radius: 4px; - font-size: 0.9rem; - box-sizing: border-box; -} -#playerList li { - margin: 4px 0; - padding: 6px 8px; - background: var(--card); - border-left: 4px solid #555; - cursor: pointer; -} -#playerList li:hover { - background: var(--card-hov); -} -#playerList li.selected { - background: #454545; -} - -/* ---------- map container --------------------------------------- */ -#mapContainer { - flex: 1; - min-width: 0; - min-height: 0; - position: relative; - overflow: hidden; - background: #000; - cursor: grab; -} -#mapContainer.dragging { - cursor: grabbing; -} -#mapGroup { - position: absolute; - top: 0; - left: 0; - transform-origin: 0 0; -} -#map { - display: block; - user-select: none; - pointer-events: none; -} - -/* ---------- dots ------------------------------------------------ */ -#dots { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} -.dot { - position: absolute; - width: 6px; - height: 6px; - border-radius: 50%; - border: 1px solid #000; - transform: translate(-50%, -50%); - - /* enable events on each dot */ - pointer-events: auto; - cursor: pointer; -} -.dot.highlight { - width: 10px; - height: 10px; - animation: blink 0.6s step-end infinite; -} -@keyframes blink { - 50% { opacity: 0; } -} - -/* ---------- tooltip --------------------------------------------- */ -.tooltip { - position: absolute; - display: none; - background: rgba(0, 0, 0, 0.8); - color: #fff; - padding: 4px 8px; - border-radius: 4px; - font-size: 0.8rem; - pointer-events: none; - white-space: nowrap; - z-index: 1000; -} - -/* ---------- coordinate display ---------------------------------- */ -.coordinates { - position: absolute; - display: none; - background: rgba(0, 50, 100, 0.9); - color: #fff; - padding: 3px 6px; - border-radius: 3px; - font-size: 0.75rem; - font-family: monospace; - pointer-events: none; - white-space: nowrap; - z-index: 999; - border: 1px solid rgba(100, 150, 200, 0.5); -} -/* make each row a flex container */ -/* 2-column flex layout for each player row */ -/* make each row a flex container */ -/* make each row a vertical stack */ -/* make each player row into a 3×2 grid */ -#playerList li { - display: grid; - grid-template-columns: 1fr auto auto auto auto auto; - grid-template-rows: auto auto auto auto auto; - grid-template-areas: - "name name name name name name" - "vitals vitals vitals vitals vitals vitals" - "kills totalkills kph kph kph kph" - "rares kpr meta meta meta meta" - "onlinetime deaths tapers tapers tapers tapers"; - gap: 6px 12px; - margin: 6px 0; - padding: 10px 15px; - background: var(--card); - border-left: 4px solid transparent; - transition: none; - font-size: 0.85rem; -} - -/* assign each span into its grid cell */ -.player-name { grid-area: name; font-weight: 600; color: var(--text); } -.coordinates-inline { font-size: 0.75rem; color: #aaa; font-weight: 400; margin-left: 8px; } - -.stat.kills { grid-area: kills; } -.stat.total-kills { grid-area: totalkills; } -.stat.kph { grid-area: kph; } -.stat.rares { grid-area: rares; } -.stat.kpr { grid-area: kpr; } -.stat.meta { grid-area: meta; } -.stat.onlinetime { grid-area: onlinetime; } -.stat.deaths { grid-area: deaths; } -.stat.tapers { grid-area: tapers; } - -.player-vitals { grid-area: vitals; } - -/* pill styling */ -#playerList li .stat { - background: rgba(255,255,255,0.1); - padding: 4px 8px; - border-radius: 12px; - display: inline-block; - font-size: 0.75rem; - white-space: nowrap; - color: var(--text); -} - -/* icons & suffixes */ -.stat.kills::before { content: "⚔️ "; } -.stat.total-kills::before { content: "🏆 "; } -.stat.kph::after { content: " KPH"; font-size:0.7em; color:#aaa; } -.stat.rares::before { content: "💎 "; } -.stat.rares::after { content: " Rares"; font-size:0.7em; color:#aaa; } -.stat.kpr::before { content: "📊 "; } -.stat.kpr::after { content: " KPR"; font-size:0.7em; color:#aaa; } -/* metastate pill colors are assigned dynamically: green for “good” states, red otherwise */ -#playerList li .stat.meta { - /* fallback */ - background: var(--accent); - color: #111; -} - -#playerList li .stat.meta.green { - background: #2ecc71; /* pleasant green */ - color: #111; -} - -#playerList li .stat.meta.red { - background: #e74c3c; /* vivid red */ - color: #fff; -} - -/* ---------- chat window styling ------------------------------- */ -.chat-btn, .stats-btn, .inventory-btn { - margin-top: 4px; - padding: 2px 6px; - background: var(--accent); - color: #111; - border: none; - border-radius: 3px; - font-size: 0.75rem; - cursor: pointer; -} - -/* Element pooling optimization containers */ -.grid-content { - display: contents; /* Makes container invisible to CSS Grid */ -} - -.buttons-container { - display: flex; - gap: 4px; - margin-top: 4px; -} - -.chat-window, .stats-window, .inventory-window, .character-window { - position: absolute; - top: 10px; - /* position window to start just right of the sidebar */ - left: calc(var(--sidebar-width) + 10px); - /* increase default size for better usability */ - width: 760px; /* increased width for larger terminal area */ - height: 300px; - background: var(--card); - border: 1px solid #555; - display: flex; - flex-direction: column; - z-index: 10000; -} - -.window-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - min-height: 0; -} - -.chat-header { - display: flex; - justify-content: space-between; - align-items: center; - background: var(--accent); - padding: 4px; - color: #111; - cursor: move; /* indicates the header is draggable */ -} - -.chat-close-btn { - background: transparent; - border: none; - font-size: 1.2rem; - line-height: 1; - cursor: pointer; -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 4px; - font-size: 0.85rem; - color: #fff; - /* reserve space so messages aren't hidden behind the input */ - padding-bottom: 40px; -} - -.chat-form { - display: flex; - border-top: 1px solid #333; - /* fix input area to the bottom of the chat window */ - position: absolute; - left: 0; - right: 0; - bottom: 0; - background: #333; - z-index: 10; -} - -.chat-input { - flex: 1; - padding: 4px 6px; - border: none; - background: #333; - color: #fff; - outline: none; -} - -/* Prevent text selection while dragging chat windows */ -body.noselect, body.noselect * { - user-select: none !important; -} -.stat.onlinetime::before { content: "🕑 "} -.stat.deaths::before { content: "💀 "} -.stat.tapers::before { - content: ""; - display: inline-block; - width: 16px; - height: 16px; - background-image: url('prismatic-taper-icon.png'); - background-size: contain; - background-repeat: no-repeat; - margin-right: 4px; - vertical-align: text-bottom; -} - -/* hover & selected states */ -#playerList li:hover { background: var(--card-hov); } -#playerList li.selected { background: #454545; } -/* trails paths */ -#trails { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} - -#portals { - position: absolute; - top: 0; - left: 0; - pointer-events: none; -} - -.portal-icon { - position: absolute; - width: 6px; - height: 6px; - font-size: 6px; - line-height: 1; - transform: translate(-50%, -50%); - z-index: 50; - opacity: 0.9; - text-shadow: 0 0 2px rgba(0, 0, 0, 0.8); -} - -.portal-icon::before { - content: '🌀'; - display: block; -} -.trail-path { - fill: none; - stroke-width: 2px; - stroke-opacity: 0.7; - stroke-linecap: round; - stroke-linejoin: round; -} -/* -------------------------------------------------------- */ -/* Stats window: 2×2 iframe grid and flexible height */ -.stats-window { - /* allow height to expand to fit two rows of panels */ - height: auto; -} -.stats-window .chat-messages { - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-auto-rows: auto; - gap: 10px; - padding: 10px; - overflow: visible; - background: #f7f7f7; - color: #000; -} -.stats-window iframe { - width: 350px; - height: 200px; - border: none; -} - -/* ---------- stats window time controls --------------------------- */ -.stats-controls { - display: flex; - gap: 8px; - padding: 10px 15px; - background: #333; - border-bottom: 1px solid #555; -} - -.time-range-btn { - padding: 6px 12px; - background: #444; - color: #ccc; - border: 1px solid #666; - border-radius: 4px; - font-size: 0.85rem; - cursor: pointer; - transition: all 0.2s; -} - -.time-range-btn:hover { - background: #555; - color: #fff; -} - -.time-range-btn.active { - background: var(--accent); - color: #111; - border-color: var(--accent); -} - -/* ---------- inventory window styling (AC Layout) ----------------------------- */ -.inventory-content { - flex: 1; - display: flex; - flex-direction: column; - background: none; - color: var(--ac-text); - overflow: hidden; - padding: 8px; -} - -.inventory-placeholder { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - font-size: 1.1rem; - color: #888; - font-style: italic; -} - -/* Inventory window specific styles */ -.inventory-window { - position: fixed; - top: 100px; - left: 400px; - width: 548px; - height: 520px; - background: rgba(20, 20, 20, 0.92); - backdrop-filter: blur(2px); - border: 2px solid var(--ac-gold); - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: inset 0 0 10px #000, 0 4px 15px rgba(0, 0, 0, 0.8); - z-index: 1000; - font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; - overflow: hidden; -} - -.inventory-loading { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - font-size: 1.1rem; - color: var(--ac-text-dim); -} - -.inv-top-section { - display: flex; - justify-content: flex-start; - height: 264px; - gap: 10px; -} - -.inv-equipment-grid { - position: relative; - width: 308px; - height: 264px; -} - -.inv-equip-slot { - position: absolute; - width: 36px; - height: 36px; - background: var(--ac-medium-stone); - border-top: 2px solid #3d4b5f; - border-left: 2px solid #3d4b5f; - border-bottom: 2px solid #12181a; - border-right: 2px solid #12181a; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -} - -.inv-equip-slot.equipped { - border: 2px solid var(--ac-cyan); - box-shadow: 0 0 5px var(--ac-cyan), inset 0 0 5px var(--ac-cyan); -} - -.inv-equip-slot.empty::before { - content: ""; - display: block; - width: 28px; - height: 28px; - background-image: url('/icons/06000133.png'); - background-size: contain; - opacity: 0.15; - filter: grayscale(100%); -} - -.inv-equip-slot .inventory-slot { - width: 100%; - height: 100%; -} - -.inv-sidebar { - width: 54px; - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - overflow: visible; - flex-shrink: 0; - margin-right: 2px; -} - -.inv-burden-bar { - width: 16px; - height: 40px; - background: #0a0a0a; - border: 1px solid var(--ac-border-light); - position: relative; - display: flex; - flex-direction: column-reverse; - margin-bottom: 2px; - margin-top: 12px; - flex-shrink: 0; -} - -.inv-burden-fill { - width: 100%; - background: var(--ac-green); - height: 0%; - transition: height 0.3s ease; -} - -.inv-burden-label { - position: absolute; - top: -18px; - width: 60px; - left: -22px; - text-align: center; - font-size: 11px; - color: var(--ac-gold); -} - -.inv-pack-list { - display: flex; - flex-direction: column; - gap: 2px; - width: 100%; - align-items: center; - flex: 1; - min-height: 0; -} - -.inv-pack-icon { - width: 32px; - height: 32px; - position: relative; - cursor: pointer; - border: 1px solid transparent; - display: flex; - align-items: center; - justify-content: center; - background: #000; - flex-shrink: 0; - margin-right: 0; -} - -.inv-pack-icon.active { - border: 1px solid var(--ac-green); - box-shadow: 0 0 4px var(--ac-green); -} - -.inv-pack-icon.active::before { - content: "▶"; - position: absolute; - left: -14px; - top: 10px; - color: var(--ac-gold); - font-size: 12px; -} - -.inv-pack-fill-container { - position: absolute; - bottom: -6px; - left: -1px; - width: 36px; - height: 4px; - background: #000; - border: 1px solid #333; -} - -.inv-pack-fill { - height: 100%; - background: var(--ac-green); - width: 0%; -} - -.inv-pack-icon img { - width: 28px; - height: 28px; - object-fit: contain; - image-rendering: pixelated; -} - -.inv-bottom-section { - flex: 1; - display: flex; - flex-direction: column; - margin-top: 8px; - margin-right: 0; - overflow: hidden; - min-height: 0; -} - -.inv-contents-header { - color: var(--ac-gold); - font-size: 14px; - margin-bottom: 4px; - text-align: center; - border-bottom: 1px solid var(--ac-border-light); - padding-bottom: 2px; -} - -.inv-item-section { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; -} - -.inv-item-grid { - display: grid; - grid-template-columns: repeat(6, 36px); - grid-auto-rows: 36px; - gap: 2px; - background: var(--ac-black); - padding: 4px; - border: 1px solid var(--ac-border-light); - flex: 1; - overflow-y: hidden; - min-height: 0; - align-content: start; - justify-content: start; -} - -.inv-mana-panel { - width: 162px; - min-width: 162px; - display: flex; - flex-direction: column; - background: rgba(6, 10, 18, 0.92); - border: 1px solid var(--ac-border-light); - padding: 3px; - min-height: 0; - height: 260px; - flex-shrink: 0; - overflow: hidden; -} - -.inv-mana-header { - color: var(--ac-gold); - font-size: 14px; - text-align: center; - border-bottom: 1px solid var(--ac-border-light); - padding-bottom: 2px; -} - -.inv-mana-summary { - color: var(--ac-text-dim); - font-size: 9px; - line-height: 1.2; - padding: 2px 0; - border-bottom: 1px solid rgba(255,255,255,0.08); - margin-bottom: 3px; -} - -.inv-mana-list { - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 2px; -} - -.inv-mana-row { - display: grid; - grid-template-columns: 18px 1fr 14px; - grid-template-rows: auto auto; - gap: 1px 4px; - align-items: center; - background: rgba(18, 24, 34, 0.9); - border: 1px solid rgba(255,255,255,0.08); - padding: 1px 2px; - min-height: 20px; -} - -.inv-mana-icon { - grid-row: 1 / span 2; - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot { - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot.mana-slot { - width: 16px; - height: 16px; -} - -.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite { - width: 14px; - height: 14px; -} - -.inv-mana-icon .inventory-slot.mana-slot .icon-underlay, -.inv-mana-icon .inventory-slot.mana-slot .icon-base, -.inv-mana-icon .inventory-slot.mana-slot .icon-overlay { - width: 14px; - height: 14px; -} - -.inv-mana-name { - color: #f2e6c9; - font-size: 9px; - line-height: 1.05; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - grid-column: 2; - grid-row: 1; -} - -.inv-mana-value, -.inv-mana-time { - font-size: 9px; - line-height: 1.1; -} - -.inv-mana-value { - color: #98d7ff; - grid-column: 2; - grid-row: 2; -} - -.inv-mana-time { - color: #cfe6a0; - grid-column: 3; - grid-row: 2; - text-align: right; - min-width: 34px; -} - -.inv-mana-state-dot { - grid-column: 3; - grid-row: 1; - width: 10px; - height: 10px; - border-radius: 50%; - justify-self: end; - align-self: start; - background: #97a1ad; - border: 1px solid rgba(0,0,0,0.65); - box-shadow: inset 0 0 1px rgba(255,255,255,0.2); -} - -.mana-state-active { - background: #76d17f; -} - -.mana-state-not_active { - background: #ff8e6f; -} - -.mana-state-unknown { - background: #d4c27a; -} - -.mana-state-not_activatable { - background: #97a1ad; -} - -.inv-mana-empty { - color: var(--ac-text-dim); - font-size: 11px; - text-align: center; - padding: 12px 6px; -} - -.inv-item-grid::-webkit-scrollbar { - width: 12px; -} -.inv-item-grid::-webkit-scrollbar-track { - background: #0a0a0a; - border: 1px solid #333; -} -.inv-item-grid::-webkit-scrollbar-thumb { - background: #0022cc; - border-top: 2px solid var(--ac-gold); - border-bottom: 2px solid var(--ac-gold); -} - -.inv-item-slot { - width: 36px; - height: 36px; - background: #0a0a0a; - border: 1px solid #222; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -} - -.inv-item-slot.occupied { - background: linear-gradient(135deg, #3d007a 0%, #1a0033 100%); - border: 1px solid #4a148c; -} - -/* Base slot styling used by createInventorySlot */ -.inventory-slot { - width: 36px; - height: 36px; - background: transparent; - border: none; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - padding: 0; - margin: 0; -} - -.inventory-slot:hover { - background: rgba(136, 136, 255, 0.3); -} - -.inventory-icon { - width: 36px; - height: 36px; - object-fit: contain; - image-rendering: pixelated; - border: none; - outline: none; -} - -/* Icon compositing */ -.item-icon-composite { - position: relative; - width: 36px; - height: 36px; - display: block; - background: transparent; - padding: 0; - margin: 0; -} - -.icon-underlay, -.icon-base, -.icon-overlay { - position: absolute; - top: 0; - left: 0; - width: 36px; - height: 36px; - border: none; - outline: none; - background: transparent; - padding: 0; - margin: 0; -} - -.icon-underlay { z-index: 1; } -.icon-base { z-index: 2; } -.icon-overlay { z-index: 3; } - -/* Item count (hidden in new AC layout, kept for compatibility) */ -.inventory-count { - display: none; -} - -/* Inventory tooltip */ -.inventory-tooltip { - position: fixed; - background: rgba(0, 0, 0, 0.95); - border: 1px solid #555; - border-radius: 4px; - padding: 10px; - pointer-events: none; - z-index: 20000; - display: none; - min-width: 200px; - max-width: 350px; - font-size: 0.9rem; -} - -.tooltip-name { - font-weight: bold; - color: var(--accent); - margin-bottom: 8px; - font-size: 1rem; -} - -.tooltip-section { - margin-bottom: 6px; -} - -.tooltip-section-title { - font-weight: bold; - color: #ffd700; - margin-bottom: 3px; - font-size: 0.85rem; - text-transform: uppercase; -} - -.tooltip-stats { - display: flex; - flex-direction: column; - gap: 3px; - font-size: 0.9rem; -} - -.tooltip-stat, -.tooltip-requirement, -.tooltip-property { - color: #ddd; - font-size: 0.85rem; - margin-left: 8px; -} - -.tooltip-requirement { - color: #ffaa00; -} - -.tooltip-property { - color: #88ff88; -} - -.tooltip-string { - color: #add8e6; - font-size: 0.8rem; - margin-left: 8px; -} - -.tooltip-spell { - color: #dda0dd; - font-size: 0.8rem; - margin-left: 8px; - margin-bottom: 2px; -} - -.spell-name { - color: #4a90e2; - font-weight: 500; -} - -.spell-school { - font-size: 11px; - color: #888; - font-style: italic; -} - -.tooltip-info { - color: #f0e68c; - font-size: 0.8rem; - margin-left: 8px; -} - -.tooltip-description { - color: #ccc; - font-style: italic; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid #444; -} - -.tooltip-value { - color: #4CAF50; -} - -.tooltip-burden { - color: #FFC107; -} - -.tooltip-source { - font-size: 10px; - color: #888; - margin-top: 4px; - text-align: center; -} - -/* ---------- inline vitals bars ---------------------------------- */ -.player-vitals { - grid-column: 1 / -1; - margin: 2px 0 4px 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.vital-bar-inline { - height: 5px; - background: #222; - border-radius: 3px; - overflow: hidden; - position: relative; -} - -.vitae-indicator { - font-size: 0.75rem; - color: #ff6666; - margin-left: 8px; - font-weight: 500; -} - -.vital-fill { - height: 100%; - transition: width 0.3s ease-out; - border-radius: 2px; -} - -.vital-fill.health { - background: linear-gradient(90deg, #ff4444, #ff6666); -} - -.vital-fill.stamina { - background: linear-gradient(90deg, #ffaa00, #ffcc44); -} - -.vital-fill.mana { - background: linear-gradient(90deg, #4488ff, #66aaff); -} - -/* Pulsing effects for low vitals */ -.vital-bar-inline.low-vital { - animation: pulse-bar-low 2s ease-in-out infinite; -} - -.vital-bar-inline.critical-vital { - animation: pulse-bar-critical 1s ease-in-out infinite; -} - -@keyframes pulse-bar-low { - 0%, 100% { background: #222; } - 50% { background: #332200; } -} - -@keyframes pulse-bar-critical { - 0%, 100% { background: #222; } - 50% { background: #440000; } -} - -/* ---------- epic rare notifications ------------------------------ */ -.rare-notifications { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - z-index: 10001; - pointer-events: none; -} - -.rare-notification { - background: linear-gradient(135deg, #ffd700, #ffed4e, #ffd700); - border: 3px solid #ff6600; - border-radius: 12px; - padding: 20px 30px; - margin-bottom: 10px; - text-align: center; - box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5); - animation: notification-slide-in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55), - epic-glow 2s ease-in-out infinite; - position: relative; - overflow: hidden; -} - -@keyframes notification-slide-in { - from { - transform: translateY(-100px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -@keyframes epic-glow { - 0%, 100% { - box-shadow: 0 8px 32px rgba(255, 215, 0, 0.5); - } - 50% { - box-shadow: 0 8px 48px rgba(255, 215, 0, 0.8); - } -} - -.rare-notification-title { - font-size: 1.2rem; - font-weight: 800; - color: #ff0044; - text-transform: uppercase; - margin-bottom: 8px; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); - animation: epic-text-pulse 1s ease-in-out infinite; -} - -@keyframes epic-text-pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.05); } -} - -.rare-notification-mob { - font-size: 1.5rem; - font-weight: 700; - color: #1a0033; - margin-bottom: 4px; - text-shadow: 2px 2px 4px rgba(255, 255, 255, 0.5); -} - -.rare-notification-finder { - font-size: 1rem; - color: #333; - font-style: italic; - margin-bottom: 4px; -} - -.rare-notification-character { - font-size: 1.3rem; - font-weight: 700; - color: #ff0044; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); -} - -/* Shine effect overlay */ -.rare-notification::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: linear-gradient(45deg, - transparent 30%, - rgba(255, 255, 255, 0.5) 50%, - transparent 70% - ); - transform: rotate(45deg); - animation: notification-shine 3s infinite; -} - -@keyframes notification-shine { - 0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); } - 100% { transform: translateX(100%) translateY(100%) rotate(45deg); } -} - -/* ---------- fireworks particles ---------------------------------- */ -.fireworks-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 9999; -} - -.firework-particle { - position: absolute; - width: 6px; - height: 6px; - border-radius: 50%; - pointer-events: none; - animation: firework-fly 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; -} - -@keyframes firework-fly { - 0% { - transform: translate(0, 0) scale(1); - opacity: 1; - } - 100% { - opacity: 0; - } -} - -/* Different particle colors */ -.particle-gold { background: #ffd700; box-shadow: 0 0 6px #ffd700; } -.particle-red { background: #ff4444; box-shadow: 0 0 6px #ff4444; } -.particle-orange { background: #ff8800; box-shadow: 0 0 6px #ff8800; } -.particle-purple { background: #cc00ff; box-shadow: 0 0 6px #cc00ff; } -.particle-blue { background: #00ccff; box-shadow: 0 0 6px #00ccff; } - -/* Character glow effect in player list */ -.player-item.rare-finder-glow { - animation: rare-finder-highlight 5s ease-in-out; - border-left-color: #ffd700 !important; - border-left-width: 6px !important; -} - -@keyframes rare-finder-highlight { - 0%, 100% { - background: var(--card); - box-shadow: none; - } - 50% { - background: rgba(255, 215, 0, 0.2); - box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); - } -} - -/* ---------- milestone celebration overlay ------------------------ */ -.milestone-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: radial-gradient(ellipse at center, rgba(255, 215, 0, 0.3), rgba(0, 0, 0, 0.8)); - z-index: 20000; - display: flex; - align-items: center; - justify-content: center; - animation: milestone-fade-in 0.5s ease-out; -} - -@keyframes milestone-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.milestone-content { - text-align: center; - animation: milestone-zoom 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55); -} - -@keyframes milestone-zoom { - from { - transform: scale(0); - } - to { - transform: scale(1); - } -} - -.milestone-number { - font-size: 8rem; - font-weight: 900; - color: #ffd700; - text-shadow: - 0 0 30px #ffd700, - 0 0 60px #ff6600, - 0 0 90px #ff0044, - 0 0 120px #ff0044; - margin-bottom: 20px; - animation: milestone-pulse 1s ease-in-out infinite alternate; -} - -@keyframes milestone-pulse { - from { - transform: scale(1); - text-shadow: - 0 0 30px #ffd700, - 0 0 60px #ff6600, - 0 0 90px #ff0044, - 0 0 120px #ff0044; - } - to { - transform: scale(1.1); - text-shadow: - 0 0 40px #ffd700, - 0 0 80px #ff6600, - 0 0 120px #ff0044, - 0 0 160px #ff0044; - } -} - -.milestone-text { - font-size: 3rem; - font-weight: 700; - color: #fff; - text-transform: uppercase; - letter-spacing: 0.2em; - text-shadow: 0 0 20px rgba(255, 255, 255, 0.8); - animation: milestone-text-glow 2s ease-in-out infinite; -} - -@keyframes milestone-text-glow { - 0%, 100% { - opacity: 0.8; - } - 50% { - opacity: 1; - } -} - -.milestone-subtitle { - font-size: 1.5rem; - color: #ffcc00; - margin-top: 20px; - font-style: italic; - animation: milestone-subtitle-slide 1s ease-out; -} - -@keyframes milestone-subtitle-slide { - from { - transform: translateY(50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* Milestone firework burst - larger particles */ -.milestone-particle { - position: absolute; - width: 12px; - height: 12px; - border-radius: 50%; - pointer-events: none; - background: #ffd700; - box-shadow: 0 0 12px #ffd700; -} - -/* Screen shake effect */ -@keyframes screen-shake { - 0%, 100% { transform: translate(0, 0); } - 10% { transform: translate(-5px, -5px); } - 20% { transform: translate(5px, -5px); } - 30% { transform: translate(-5px, 5px); } - 40% { transform: translate(5px, 5px); } - 50% { transform: translate(-3px, -3px); } - 60% { transform: translate(3px, -3px); } - 70% { transform: translate(-3px, 3px); } - 80% { transform: translate(3px, 3px); } - 90% { transform: translate(-1px, -1px); } -} - -.screen-shake { - animation: screen-shake 0.5s ease-in-out; -} - -/* ---------- Heat Map Canvas Layer ---------- */ -#heatmapCanvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - opacity: 0.85; - mix-blend-mode: screen; /* Additive blending for nice heat map effect */ -} - -/* Trails and dots use default positioning - no changes needed for layering */ - -/* Heat map toggle styling */ -.heatmap-toggle { - margin: 0 0 12px; - padding: 6px 12px; - background: var(--card); - border: 1px solid var(--accent); - border-radius: 4px; - font-size: 0.9rem; -} - -.heatmap-toggle input { - margin-right: 8px; - cursor: pointer; -} - -.heatmap-toggle label { - cursor: pointer; - user-select: none; -} - -.portal-toggle { - margin: 0 0 12px; - padding: 6px 12px; - background: var(--card); - border: 1px solid #9c4aff; - border-radius: 4px; - font-size: 0.9rem; -} - -.portal-toggle input { - margin-right: 8px; - cursor: pointer; -} - -.portal-toggle label { - cursor: pointer; - user-select: none; -} - -/* Inventory search link styling */ -.inventory-search-link { - margin: 0 0 12px; - padding: 8px 12px; - background: var(--card); - border: 1px solid #4a9eff; - border-radius: 4px; - text-align: center; -} - -.inventory-search-link a { - color: #4a9eff; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - display: block; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; -} - -.inventory-search-link a:hover { - color: #fff; - background: rgba(74, 158, 255, 0.1); - border-radius: 2px; - padding: 2px 4px; - margin: -2px -4px; -} - -.suitbuilder-link { - margin: 0 0 12px; - padding: 8px 12px; - background: var(--card); - border: 1px solid #ff6b4a; - border-radius: 4px; - text-align: center; -} - -.suitbuilder-link a { - color: #ff6b4a; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - display: block; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; -} - -.suitbuilder-link a:hover { - color: #fff; - background: rgba(255, 107, 74, 0.1); - border-radius: 2px; - padding: 2px 4px; - margin: -2px -4px; -} - -.debug-link { - margin: 0 0 12px; - padding: 8px 12px; - background: var(--card); - border: 1px solid #4aff6b; - border-radius: 4px; - text-align: center; -} - -.debug-link a { - color: #4aff6b; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - display: block; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; -} - -.debug-link a:hover { - color: #fff; - background: rgba(74, 255, 107, 0.1); - border-radius: 2px; - padding: 2px 4px; - margin: -2px -4px; -} - -.quest-status-link { - margin: 0 0 12px; - padding: 8px 12px; - background: var(--card); - border: 1px solid #ffab4a; - border-radius: 4px; - text-align: center; -} - -.quest-status-link a { - color: #ffab4a; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - display: block; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; -} - -.quest-status-link a:hover { - color: #fff; - background: rgba(255, 171, 74, 0.1); - border-radius: 2px; - padding: 2px 4px; - margin: -2px -4px; -} - -.player-dashboard-link { - margin: 0 0 12px; - padding: 8px 12px; - background: var(--card); - border: 1px solid #88f; - border-radius: 4px; - text-align: center; -} - -.player-dashboard-link a { - color: #88f; - text-decoration: none; - font-size: 0.9rem; - font-weight: 500; - display: block; - cursor: pointer; - user-select: none; - transition: all 0.2s ease; -} - -.player-dashboard-link a:hover { - color: #fff; - background: rgba(136, 136, 255, 0.1); - border-radius: 2px; - padding: 2px 4px; - margin: -2px -4px; -} - -/* Sortable column styles for inventory tables */ -.sortable { - cursor: pointer; - user-select: none; - position: relative; - padding-right: 20px \!important; -} - -.sortable:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -.results-table { - width: 100%; - border-collapse: collapse; - margin-top: 10px; -} - -.results-table th, -.results-table td { - padding: 8px 12px; - border-bottom: 1px solid #333; - text-align: left; -} - -.results-table th { - background-color: #222; - font-weight: bold; - color: #eee; -} - -.results-table tr:hover { - background-color: rgba(255, 255, 255, 0.05); -} - -.text-right { - text-align: right \!important; -} - -.results-info { - margin-bottom: 10px; - color: #ccc; - font-size: 14px; -} - -/* Spell/Cantrip column styling */ -.spells-cell { - font-size: 10px; - line-height: 1.2; - max-width: 200px; - word-wrap: break-word; - vertical-align: top; -} - -.legendary-cantrip { - color: #ffd700; - font-weight: bold; -} - -.regular-spell { - color: #88ccff; -} - -/* Error Toast */ -.error-toast { - position: fixed; - bottom: 20px; - right: 20px; - background: rgba(220, 38, 38, 0.9); - color: white; - padding: 12px 20px; - border-radius: 8px; - font-size: 13px; - z-index: 99999; - animation: toastFadeIn 0.3s ease; - max-width: 400px; -} - -@keyframes toastFadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ============================================ - Character Window - AC Game UI Replica - ============================================ */ -/* === TreeStats-themed Character Window === */ -.character-window { - width: 740px !important; - height: auto !important; - min-height: 300px; - max-height: 90vh; -} -.character-window .window-content { - background-color: #000022; - color: #fff; - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - overflow-y: auto; - padding: 10px 15px 15px; -} - -/* -- Character header (name, level, title, server, XP/Lum) -- */ -.ts-character-header { - margin-bottom: 10px; -} -.ts-character-header h1 { - margin: 0 0 2px; - font-size: 28px; - color: #fff; - font-weight: bold; -} -.ts-character-header h1 span.ts-level { - font-size: 200%; - color: #fff27f; - float: right; -} -.ts-character-header .ts-subtitle { - font-size: 85%; - color: gold; -} -.ts-xplum { - font-size: 85%; - margin: 6px 0 10px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0 20px; -} -.ts-xplum .ts-left { text-align: left; } -.ts-xplum .ts-right { text-align: right; } - -/* -- Tab containers (two side-by-side) -- */ -.ts-tabrow { - display: flex; - gap: 20px; - flex-wrap: wrap; -} -.ts-tabcontainer { - width: 320px; - margin-bottom: 15px; -} -.ts-tabbar { - height: 30px; - display: flex; -} -.ts-tab { - float: left; - display: block; - padding: 5px 5px; - height: 18px; - font-size: 12px; - font-weight: bold; - color: #fff; - text-align: center; - cursor: pointer; - user-select: none; -} -.ts-tab.active { - border-top: 2px solid #af7a30; - border-right: 2px solid #af7a30; - border-left: 2px solid #af7a30; - border-bottom: none; - background-color: rgba(0, 100, 0, 0.4); -} -.ts-tab.inactive { - border-top: 2px solid #000022; - border-right: 2px solid #000022; - border-left: 2px solid #000022; - border-bottom: none; -} -.ts-box { - background-color: black; - color: #fff; - border: 2px solid #af7a30; - max-height: 400px; - overflow-x: hidden; - overflow-y: auto; -} -.ts-box.active { display: block; } -.ts-box.inactive { display: none; } - -/* -- Tables inside boxes -- */ -table.ts-char { - width: 100%; - font-size: 13px; - border-collapse: collapse; - border-spacing: 0; -} -table.ts-char td { - padding: 2px 6px; - white-space: nowrap; -} -table.ts-char tr.ts-colnames td { - background-color: #222; - font-weight: bold; - font-size: 12px; -} - -/* Attribute cells */ -table.ts-char td.ts-headerleft { - background-color: rgba(0, 100, 0, 0.4); -} -table.ts-char td.ts-headerright { - background-color: rgba(0, 0, 100, 0.4); -} -table.ts-char td.ts-creation { - color: #ccc; -} - -/* Skill rows */ -table.ts-char td.ts-specialized { - background: linear-gradient(to right, #392067, #392067, black); -} -table.ts-char td.ts-trained { - background: linear-gradient(to right, #0f3c3e, #0f3c3e, black); -} - -/* Section headers inside boxes */ -.ts-box .ts-section-title { - background-color: #222; - padding: 4px 8px; - font-weight: bold; - font-size: 13px; - border-bottom: 1px solid #af7a30; -} - -/* Titles list */ -.ts-titles-list { - padding: 6px 10px; - font-size: 13px; -} -.ts-titles-list div { - padding: 1px 0; -} - -/* Properties (augmentations, ratings, other) */ -table.ts-props { - width: 100%; - font-size: 13px; - border-collapse: collapse; -} -table.ts-props td { - padding: 2px 6px; -} -table.ts-props tr.ts-colnames td { - background-color: #222; - font-weight: bold; -} - -/* -- Live vitals bars (inside Attributes tab) -- */ -.ts-vitals { - padding: 6px 8px; - display: flex; - flex-direction: column; - gap: 4px; - border-bottom: 2px solid #af7a30; -} -.ts-vital { - display: flex; - align-items: center; - gap: 6px; -} -.ts-vital-label { - width: 55px; - font-size: 12px; - color: #ccc; -} -.ts-vital-bar { - flex: 1; - height: 14px; - overflow: hidden; - position: relative; - border: 1px solid #af7a30; -} -.ts-vital-fill { - height: 100%; - transition: width 0.5s ease; -} -.ts-health-bar .ts-vital-fill { background: #cc3333; width: 0%; } -.ts-stamina-bar .ts-vital-fill { background: #ccaa33; width: 0%; } -.ts-mana-bar .ts-vital-fill { background: #3366cc; width: 0%; } -.ts-vital-text { - width: 80px; - text-align: right; - font-size: 12px; - color: #ccc; -} - -/* -- Allegiance section (below tabs) -- */ -.ts-allegiance-section { - margin-top: 5px; - border: 2px solid #af7a30; - background-color: black; - padding: 0; -} -.ts-allegiance-section .ts-section-title { - background-color: #222; - padding: 4px 8px; - font-weight: bold; - font-size: 13px; - border-bottom: 1px solid #af7a30; -} -table.ts-allegiance { - width: 100%; - font-size: 13px; - border-collapse: collapse; -} -table.ts-allegiance td { - padding: 2px 6px; -} -table.ts-allegiance td:first-child { - color: #ccc; - width: 100px; -} - -/* Awaiting data placeholder */ -.ts-placeholder { - color: #666; - font-style: italic; - padding: 10px; - text-align: center; -} - -/* Scrollbar styling for ts-box */ -.ts-box::-webkit-scrollbar { width: 8px; } -.ts-box::-webkit-scrollbar-track { background: #000; } -.ts-box::-webkit-scrollbar-thumb { background: #af7a30; } - -.char-btn { - background: #000022; - color: #af7a30; - border: 1px solid #af7a30; - padding: 2px 6px; - border-radius: 3px; - cursor: pointer; - font-size: 11px; -} -.char-btn:hover { - background: rgba(0, 100, 0, 0.4); - border-color: #af7a30; -} - - -/* ============================================== - Inventory Window Visual Fixes - AC Game Match - ============================================== */ - -.inventory-window, -.inventory-window * { - font-family: "Times New Roman", Times, serif !important; - text-shadow: 1px 1px 0 #000 !important; -} - -.inventory-window .chat-header { - background: #0e0c08 !important; - border-bottom: 1px solid #8a7a44 !important; - color: #d4af37 !important; - padding: 4px 6px !important; - box-shadow: none !important; - font-size: 11px !important; - font-weight: bold !important; - height: 22px !important; - box-sizing: border-box !important; - display: flex !important; - align-items: center !important; -} - -.inventory-window .window-content { - background: linear-gradient(180deg, #1a1814 0%, #0e0c0a 100%) !important; - border: 2px solid #8a7a44 !important; - padding: 4px !important; -} - -.inv-equipment-grid { - background: - radial-gradient(ellipse at 20% 50%, rgba(30, 28, 25, 0.6) 0%, transparent 70%), - radial-gradient(ellipse at 80% 30%, rgba(25, 23, 20, 0.4) 0%, transparent 60%), - radial-gradient(ellipse at 50% 80%, rgba(35, 30, 25, 0.5) 0%, transparent 50%), - linear-gradient(180deg, #0e0c0a 0%, #141210 50%, #0c0a08 100%) !important; -} - -.inv-equip-slot { - width: 36px !important; - height: 36px !important; - border-top: 1px solid #2a2a30 !important; - border-left: 1px solid #2a2a30 !important; - border-bottom: 1px solid #0a0a0e !important; - border-right: 1px solid #0a0a0e !important; - background: #14141a !important; -} - -.inv-equip-slot.equipped { - border: 1px solid #222 !important; - background: #14141a !important; - box-shadow: none !important; -} - -/* Equipment slot color categories - matching real AC - Real AC uses clearly visible colored borders AND tinted backgrounds per slot type */ -.inv-equip-slot.slot-purple { - border: 1px solid #8040a8 !important; - background: #2a1538 !important; -} -.inv-equip-slot.slot-blue { - border: 1px solid #3060b0 !important; - background: #141e38 !important; -} -.inv-equip-slot.slot-teal { - border: 1px solid #309898 !important; - background: #0e2828 !important; -} -.inv-equip-slot.slot-darkblue { - border: 1px solid #1e3060 !important; - background: #0e1428 !important; -} -/* Brighter tint when equipped (item present) */ -.inv-equip-slot.equipped.slot-purple { - border: 1px solid #9050b8 !important; - background: #341a44 !important; -} -.inv-equip-slot.equipped.slot-blue { - border: 1px solid #4070c0 !important; - background: #1a2844 !important; -} -.inv-equip-slot.equipped.slot-teal { - border: 1px solid #40a8a8 !important; - background: #143030 !important; -} -.inv-equip-slot.equipped.slot-darkblue { - border: 1px solid #283870 !important; - background: #141a30 !important; -} - -.inv-equip-slot.empty::before { - opacity: 0.15 !important; - filter: grayscale(100%) !important; -} - -.inv-item-grid { - background: #1a1208 !important; - gap: 2px !important; -} - -.inv-item-slot.occupied { - background: #442c1e !important; - border: 1px solid #5a3c28 !important; -} - -.inv-item-slot { - background: #2a1c14 !important; - border: 1px solid #3a2818 !important; -} - -.inv-contents-header { - font-size: 10px !important; - font-family: "Times New Roman", Times, serif !important; - color: #ffffff !important; - border-bottom: none !important; - text-align: center !important; - padding-bottom: 2px !important; - margin-bottom: 2px !important; - text-transform: none !important; - letter-spacing: 0 !important; -} - -.inv-sidebar { - width: 52px !important; - align-items: center !important; - overflow: visible !important; -} - -.inv-pack-icon { - width: 32px !important; - height: 32px !important; - border: 1px solid #1a1a1a !important; - margin-bottom: 2px !important; - overflow: visible !important; - margin-right: 8px !important; -} - -.inv-pack-icon img { - width: 28px !important; - height: 28px !important; -} - -.inv-pack-icon.active { - border: 1px solid #8a7a44 !important; - position: relative !important; - box-shadow: none !important; -} - -.inv-pack-icon.active::before { - content: '' !important; - position: absolute !important; - left: -8px !important; - top: 50% !important; - transform: translateY(-50%) !important; - width: 0 !important; - height: 0 !important; - border-top: 6px solid transparent !important; - border-bottom: 6px solid transparent !important; - border-left: 7px solid #d4af37 !important; - display: block !important; -} - -.inv-pack-fill-container { - position: absolute !important; - right: -6px !important; - top: 0 !important; - bottom: auto !important; - left: auto !important; - width: 4px !important; - height: 32px !important; - background: #000 !important; - border: 1px solid #333 !important; - display: flex !important; - flex-direction: column-reverse !important; -} - -.inv-pack-fill { - width: 100% !important; - background: #00ff00 !important; - transition: height 0.3s ease !important; -} - -.inv-item-grid::-webkit-scrollbar { - width: 14px; -} -.inv-item-grid::-webkit-scrollbar-track { - background: #0e0a04; - border: 1px solid #8a7a44; -} -.inv-item-grid::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, #2244aa 0%, #1a3399 50%, #2244aa 100%); - border: 1px solid #8a7a44; -} -.inv-item-grid::-webkit-scrollbar-button:vertical:start:decrement, -.inv-item-grid::-webkit-scrollbar-button:vertical:end:increment { - background: #8a2020; - border: 1px solid #b89a30; - height: 14px; - display: block; -} - -.inv-burden-bar { - width: 14px !important; - height: 40px !important; - margin-top: 20px !important; -} - -.inv-burden-label { - position: absolute !important; - top: -20px !important; - width: 60px !important; - left: -22px !important; - text-align: center !important; - font-size: 9px !important; - color: #fff !important; - font-weight: normal !important; - line-height: 1.1 !important; -} - -.inventory-count { - display: block !important; - position: absolute; - top: 1px; - right: 1px; - bottom: auto; - left: auto; - font-size: 8px !important; - color: #fff !important; - background: #1a3399 !important; - padding: 0 2px !important; - line-height: 12px !important; - min-width: 8px !important; - text-align: center !important; - pointer-events: none; - z-index: 10; - text-shadow: none !important; -} - -.inventory-window { - border: 2px solid #8a7a44 !important; - background: #0e0c08 !important; - resize: none !important; - width: 548px !important; -} - -.inv-top-section { - justify-content: flex-start !important; - gap: 10px !important; -} - -.inv-bottom-section { - flex-direction: column !important; - margin-right: 0 !important; -} - -.inv-mana-panel { - width: 162px !important; - min-width: 162px !important; - height: 260px !important; - background: #111014 !important; - border: 1px solid #5a4a24 !important; - overflow: hidden !important; -} - -.inv-mana-header { - font-size: 10px !important; - color: #ffffff !important; - border-bottom: none !important; - padding-bottom: 2px !important; -} - -.inv-mana-summary { - font-size: 9px !important; - color: #d4af37 !important; -} - -.inv-mana-row { - grid-template-columns: 18px 1fr 14px !important; - grid-template-rows: auto auto !important; - gap: 1px 4px !important; - padding: 1px 2px !important; - background: #1a1208 !important; - border: 1px solid #3a2818 !important; -} - -.inv-mana-icon { - grid-row: 1 / span 2 !important; - width: 16px !important; - height: 16px !important; -} - -.inv-mana-icon .inventory-slot { - width: 16px !important; - height: 16px !important; -} - -.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite, -.inv-mana-icon .inventory-slot.mana-slot .icon-underlay, -.inv-mana-icon .inventory-slot.mana-slot .icon-base, -.inv-mana-icon .inventory-slot.mana-slot .icon-overlay { - width: 14px !important; - height: 14px !important; -} - -.inv-mana-name { - font-size: 9px !important; - line-height: 1.05 !important; - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; -} - -.inv-mana-value, -.inv-mana-time { - font-size: 9px !important; -} - -.inv-mana-state-dot { - width: 10px !important; - height: 10px !important; -} - -/* Custom resize grip for inventory window */ -.inv-resize-grip { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 6px; - cursor: ns-resize; - z-index: 100; - background: transparent; - border-top: 1px solid #8a7a44; -} - -.inv-resize-grip::after { - content: ''; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: 30px; - height: 2px; - border-top: 1px solid #5a4a24; - border-bottom: 1px solid #5a4a24; -} diff --git a/Dockerfile b/Dockerfile index e35f5dde..1fc2530f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,13 +36,14 @@ ARG BUILD_VERSION=dev ENV APP_VERSION=$BUILD_VERSION ## Default environment variables for application configuration +## NOTE: no SHARED_SECRET default here on purpose — main.py fails closed +## (refuses plugin connections) unless a real value arrives via compose/.env. ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \ DB_MAX_SIZE_MB=2048 \ DB_RETENTION_DAYS=7 \ DB_MAX_SQL_LENGTH=1000000000 \ DB_MAX_SQL_VARIABLES=32766 \ - DB_WAL_AUTOCHECKPOINT_PAGES=1000 \ - SHARED_SECRET=your_shared_secret + DB_WAL_AUTOCHECKPOINT_PAGES=1000 ## Launch the FastAPI app using Uvicorn CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8765","--workers","1","--no-access-log","--log-level","warning"] diff --git a/agent/auth.py b/agent/auth.py index 2928bed4..1974ee1d 100644 --- a/agent/auth.py +++ b/agent/auth.py @@ -12,8 +12,15 @@ import os from fastapi import HTTPException, Request, status from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer -# Mirror main.py:996-998 -SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please") +# Mirror main.py — and fail closed like it does: starting with a known +# default key would let anyone forge a valid session cookie. +SECRET_KEY = os.getenv("SECRET_KEY", "") +if not SECRET_KEY or SECRET_KEY == "change-me-in-production-please": + raise RuntimeError( + "SECRET_KEY env var must be set (shared with dereth-tracker; see " + "/etc/overlord/agent.env) — refusing to start with a forgeable " + "session-signing key" + ) SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days _serializer = URLSafeTimedSerializer(SECRET_KEY) diff --git a/agent/overlord-agent.service b/agent/overlord-agent.service index 6f40f5dd..20861a1b 100644 --- a/agent/overlord-agent.service +++ b/agent/overlord-agent.service @@ -20,8 +20,10 @@ WorkingDirectory=/home/erik/MosswartOverlord # HOME explicitly set so claude reads /var/lib/overlord-agent/.claude/* # instead of trying /home/erik/.claude/* (which is now 0700, locked out). Environment="HOME=/var/lib/overlord-agent" -# Secrets file (root:overlord-agent 0640). -EnvironmentFile=-/etc/overlord/agent.env +# Secrets file (root:overlord-agent 0640). REQUIRED (no leading '-'): +# a missing secrets file must abort startup, not fail open — auth.py also +# refuses to start without SECRET_KEY. +EnvironmentFile=/etc/overlord/agent.env # Run inside the venv populated by install.sh. ExecStart=/home/erik/MosswartOverlord/agent/.venv/bin/python -m agent.service Restart=on-failure diff --git a/discord-rare-monitor/discord_rare_monitor.py b/discord-rare-monitor/discord_rare_monitor.py index 6d9ae66c..5c58e1fb 100644 --- a/discord-rare-monitor/discord_rare_monitor.py +++ b/discord-rare-monitor/discord_rare_monitor.py @@ -34,7 +34,6 @@ logger = logging.getLogger(__name__) # Configuration from environment variables DISCORD_TOKEN = os.getenv('DISCORD_RARE_BOT_TOKEN') WEBSOCKET_URL = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/live') -SHARED_SECRET = 'your_shared_secret' ACLOG_CHANNEL_ID = int(os.getenv('ACLOG_CHANNEL_ID', '1349649482786275328')) COMMON_RARE_CHANNEL_ID = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014')) GREAT_RARE_CHANNEL_ID = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211')) diff --git a/docker-compose.yml b/docker-compose.yml index 43ff02a5..aaf4d9f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,11 @@ services: volumes: - timescale-data:/var/lib/postgresql/data ports: - - "5432:5432" + # Loopback only — Docker-published ports bypass ufw, and this host is + # internet-facing (active brute-force on the open port observed June + # 2026). In-stack consumers use the compose network; host-side tools + # (psql, overlord-agent) use 127.0.0.1. + - "127.0.0.1:5432:5432" restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] @@ -104,7 +108,8 @@ services: volumes: - inventory-data:/var/lib/postgresql/data ports: - - "5433:5432" + # Loopback only — see db service note. + - "127.0.0.1:5433:5432" restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U inventory_user"] diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 00000000..a7010211 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,102 @@ +# Database backups + +Nightly logical backups of both databases, taken by +[`scripts/backup-databases.sh`](../scripts/backup-databases.sh) via a cron +job on the live host (user `erik`, who is in the `docker` group — no sudo +needed). Install with: + +``` +mkdir -p /home/erik/backups # MUST exist before the first run — + # cron opens the log redirect before + # the script's own mkdir executes +crontab -e # add the line below +15 3 * * * bash /home/erik/MosswartOverlord/scripts/backup-databases.sh >> /home/erik/backups/backup.log 2>&1 +``` + +Dumps land in `/home/erik/backups/postgres/` as `dereth-YYYYMMDD-HHMM.dump` +and `inventory-YYYYMMDD-HHMM.dump` (pg_dump custom format, compressed, +mode 0600). Retention: ~8 days of dailies (`-mtime +7`), pruned by the +script itself only after a successful run. The nightly `backup.log` will +contain pg_dump circular-FK warnings about hypertable chunks — those are +normal; the canary to watch is the printed dump sizes (a healthy dereth +dump is ~50 MB, and the script aborts if it drops below 10 MB). + +## What is and isn't included + +- **dereth** (TimescaleDB): everything EXCEPT the row data of the + `telemetry_events` and `spawn_events` hypertables (their chunk data in + `_timescaledb_internal._hyper_*` is excluded). That data is ~12 GB and + expires through retention policies within 7–30 days anyway. The + irreplaceable tables — `users`, `char_stats`, `rare_stats`, + `rare_stats_sessions`, `rare_events`, `combat_stats`, + `combat_stats_sessions`, `portals`, `character_stats`, `server_status` — + are fully included. Table *schemas* for the excluded hypertables are + still dumped, so a restore recreates them empty. +- **inventory_db**: full dump (items, combat stats, enhancements, spells, + requirements, ratings, raw JSON). + +⚠ The `_timescaledb_internal._hyper_*` exclusion drops the chunk data of +**every** hypertable, present and future. If an irreplaceable table is ever +converted to a hypertable (or a continuous aggregate is added), revisit the +exclusion list — otherwise its data silently disappears from backups. + +## Off-host copies (recommended, not yet automated) + +The dumps live on the same disk as the databases. Sync them off-host +periodically, e.g. from another machine: + +``` +rsync -av erik@overlord.snakedesert.se:backups/postgres/ ./overlord-backups/ +``` + +## Restore + +### inventory_db (plain Postgres) + +```bash +docker exec -i inventory-db pg_restore -U inventory_user -d inventory_db --clean --if-exists < inventory-.dump +``` + +### dereth (TimescaleDB — needs pre/post restore calls) + +TimescaleDB requires putting the extension into restore mode around the +`pg_restore`, otherwise catalog rows fail: + +```bash +# 1. Create a fresh DB (or use --clean against the existing one) +docker exec dereth-db psql -U postgres -c "CREATE DATABASE dereth_restore;" +docker exec dereth-db psql -U postgres -d dereth_restore -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" + +# 2. Pre-restore mode +docker exec dereth-db psql -U postgres -d dereth_restore -c "SELECT timescaledb_pre_restore();" + +# 3. Restore the dump +docker exec -i dereth-db pg_restore -U postgres -d dereth_restore --no-owner < dereth-.dump + +# 4. Post-restore mode (re-enables background workers, validates catalog) +docker exec dereth-db psql -U postgres -d dereth_restore -c "SELECT timescaledb_post_restore();" +``` + +Notes: +- Step 3 reports one ignorable error — the dump's `CREATE EXTENSION + timescaledb` collides with the extension pre-created in step 1 + ("already exists", `errors ignored on restore: 1`). That is expected, + not a failed restore. +- The TimescaleDB **version** at restore time must be the **same** as at + dump time (restore first, then `ALTER EXTENSION timescaledb UPDATE` if + upgrading). Same-container restores with the image pinned in + docker-compose.yml (`timescale/timescaledb:2.19.3-pg14`) are fine. + +Then either point `DATABASE_URL` at the restored DB or rename databases. +The `telemetry_events`/`spawn_events` hypertables come back empty (by +design); retention/compression policies are part of the dump and reattach. + +## Verifying a backup + +```bash +pg_restore --list dereth-.dump | head # table of contents +pg_restore --list dereth-.dump | grep -c 'TABLE DATA' +``` + +A dump that suddenly shrinks dramatically (check `backup.log` sizes) is the +canary for silent failure. diff --git a/generate_data.py b/generate_data.py index e8838cd6..dc6abe0d 100644 --- a/generate_data.py +++ b/generate_data.py @@ -7,6 +7,7 @@ fabricated TelemetrySnapshot payloads at regular intervals. Useful for: - Demonstrating real-time map updates without a live game client """ import asyncio # Async event loop and sleep support +import os import websockets # WebSocket client for Python import json # JSON serialization of payloads from datetime import datetime, timedelta, timezone @@ -32,8 +33,10 @@ async def main() -> None: # Starting coordinates (E/W and N/S) ew = 0.0 ns = 0.0 - # WebSocket endpoint for plugin telemetry (include secret for auth) - uri = "ws://localhost:8000/ws/position?secret=your_shared_secret" + # WebSocket endpoint for plugin telemetry. The secret must match the + # backend's SHARED_SECRET env var (no insecure default anymore). + secret = os.environ["SHARED_SECRET"] + uri = f"ws://localhost:8000/ws/position?secret={secret}" # Connect to the plugin WebSocket endpoint with authentication # Establish WebSocket connection to the server async with websockets.connect(uri) as websocket: diff --git a/main.py b/main.py index ab896428..f54f98c3 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,9 @@ endpoints for browser clients to retrieve live and historical data, trails, and from collections import defaultdict from datetime import datetime, timedelta, timezone +import hmac import html as _html +import ipaddress import json import logging import os @@ -990,10 +992,25 @@ live_equipment_cantrip_states: Dict[str, dict] = {} live_nearby_objects: Dict[str, dict] = {} dungeon_map_cache: Dict[str, dict] = {} # landblock hex string -> dungeon map data -# Shared secret used to authenticate plugin WebSocket connections (override for production) -SHARED_SECRET = "your_shared_secret" -# Secret key for signing session cookies (override via SECRET_KEY env var) -SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please") +# Shared secret used to authenticate plugin WebSocket connections. +# MUST come from the environment — this repo is public, so a hardcoded value +# is no auth at all. When unset (or left at the old placeholder) we fail +# closed: every plugin connection is refused until it is configured. +SHARED_SECRET = os.getenv("SHARED_SECRET", "") +_SHARED_SECRET_OK = bool(SHARED_SECRET) and SHARED_SECRET != "your_shared_secret" +if not _SHARED_SECRET_OK: + logger.critical( + "SHARED_SECRET env var is unset or still the placeholder — " + "refusing ALL plugin WebSocket connections until it is set in .env" + ) +# Secret key for signing session cookies. Fail closed: running with a +# publicly-known default would let anyone forge admin sessions. +SECRET_KEY = os.getenv("SECRET_KEY", "") +if not SECRET_KEY or SECRET_KEY == "change-me-in-production-please": + raise RuntimeError( + "SECRET_KEY env var must be set to a strong random value — " + "session cookies are signed with it" + ) SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days in seconds _serializer = URLSafeTimedSerializer(SECRET_KEY) @@ -1024,6 +1041,19 @@ _PUBLIC_PATHS = {"/login", "/logout"} _PUBLIC_PREFIXES = ("/ws/position",) # Plugin WS uses X-Plugin-Secret +def _is_private_addr(host: str) -> bool: + """True when `host` is a private/loopback address (RFC1918, 127/8, ::1). + + Used by the internal-trust rule: a private TCP peer WITHOUT an + X-Forwarded-For header cannot have come through nginx and therefore + cannot originate from the internet. + """ + try: + return ipaddress.ip_address(host).is_private + except ValueError: + return False + + class AuthMiddleware(BaseHTTPMiddleware): """Redirect unauthenticated requests to /login.""" @@ -1046,20 +1076,20 @@ class AuthMiddleware(BaseHTTPMiddleware): if path.startswith("/ws/live"): return await call_next(request) - # Trust internal connections (Docker network gateway + loopback). The - # tracker port (8765) is bound to 127.0.0.1 in docker-compose.yml and - # only the host or other compose-network containers can reach it. - # This lets host-side helpers (overlord-agent, discord-rare-monitor, - # etc.) call any endpoint without forging a session cookie. - # - # IMPORTANT: We still try to decode the session cookie if present, so - # that endpoints like /me which check `request.state.user` work for - # real authenticated browsers proxied through nginx → docker-proxy - # (which makes them look like they're coming from 172.x). Without - # this, /me returned 401 even for logged-in users, silently - # disabling the admin-only UI on the dashboard. + # Trust genuinely internal callers only. The tracker port (8765) is + # published on 127.0.0.1, so host-side helpers (overlord-agent) and + # compose-network containers reach it directly — but so does ALL + # external browser traffic, via nginx → docker-proxy, which makes it + # arrive with a 172.x source IP. Source IP alone therefore proves + # nothing. The distinguishing signal is X-Forwarded-For: nginx sets + # it on every proxied request, while direct internal calls have no + # proxy in front of them and lack the header. A request with a + # private source AND no X-Forwarded-For cannot have come through + # nginx, i.e. cannot originate from the internet. client_host = request.client.host if request.client else "" - if client_host.startswith("172.") or client_host in ("127.0.0.1", "::1", "localhost"): + if _is_private_addr(client_host) and "x-forwarded-for" not in request.headers: + # Still decode the cookie if present so request.state.user works + # for internal tools that do log in. token = request.cookies.get("session") if token: user = verify_session_cookie(token) @@ -2945,9 +2975,13 @@ async def ws_receive_snapshots( """ global _plugin_connections - # Authenticate plugin connection using shared secret - key = secret or x_plugin_secret - if key != SHARED_SECRET: + # Authenticate plugin connection using shared secret (constant-time + # compare; refuse everything when the secret is not configured). + key = secret or x_plugin_secret or "" + # compare bytes: compare_digest(str, str) raises TypeError on non-ASCII + if not _SHARED_SECRET_OK or not hmac.compare_digest( + key.encode("utf-8", "replace"), SHARED_SECRET.encode("utf-8") + ): # Reject without completing the WebSocket handshake logger.warning( f"Plugin WebSocket authentication failed from {websocket.client}" @@ -3693,11 +3727,16 @@ async def ws_live_updates(websocket: WebSocket): Manages a set of connected browser clients; listens for incoming command messages and forwards them to the appropriate plugin client WebSocket. """ - # Require valid session cookie for browser WebSocket. - # Internal Docker network connections (172.x.x.x) are trusted — this allows - # the Discord bot and other internal services to connect without a cookie. + # Require a valid session cookie for browser WebSockets. Internal + # services (discord-rare-monitor connects over the compose network) are + # identified by a private source IP WITHOUT an X-Forwarded-For header — + # nginx-proxied browser traffic always carries X-Forwarded-For, so an + # internet client can never satisfy this check (same rule as + # AuthMiddleware; see comment there). client_host = websocket.client.host if websocket.client else "" - is_internal = client_host.startswith("172.") or client_host in ("127.0.0.1", "::1", "localhost") + is_internal = ( + _is_private_addr(client_host) and "x-forwarded-for" not in websocket.headers + ) if not is_internal: token = websocket.cookies.get("session") if not token or not verify_session_cookie(token): @@ -3865,15 +3904,18 @@ async def get_stats(character_name: str): @app.post("/character-stats/test") -async def test_character_stats_default(): - """Inject mock character_stats data for frontend development.""" - return await test_character_stats("TestCharacter") +async def test_character_stats_default(request: Request): + """Inject mock character_stats data for frontend development (admin only).""" + _require_admin(request) + return await test_character_stats("TestCharacter", request) @app.post("/character-stats/test/{name}") -async def test_character_stats(name: str): +async def test_character_stats(name: str, request: Request): """Inject mock character_stats data for a specific character name. - Processes through the same pipeline as real plugin data.""" + Processes through the same pipeline as real plugin data — it OVERWRITES + the real character_stats row for {name}, hence admin-only.""" + _require_admin(request) mock_data = { "type": "character_stats", "timestamp": datetime.utcnow().isoformat() + "Z", diff --git a/nginx/overlord.conf b/nginx/overlord.conf index f8b33162..f2cd581b 100644 --- a/nginx/overlord.conf +++ b/nginx/overlord.conf @@ -14,6 +14,12 @@ # WebSockets are long-lived; nginx's default 60s timeout drops idle clients. # Removing these timeouts caused all plugin connections to drop every # ~60s when no data flowed from backend to client (April 2026 incident). +# - SECURITY INVARIANT: every location that proxies to the `tracker` +# upstream MUST set proxy_set_header X-Forwarded-For. The backend treats +# a private-source request WITHOUT that header as internal (host/compose +# callers) and skips session auth — a tracker-bound location that forgot +# the header would silently bypass login for the whole internet. This +# includes any future port-80 or alternate server block. # - /grafana/ panel embeds rely on Grafana's anonymous Viewer auth # (GF_AUTH_ANONYMOUS_ENABLED=true in docker-compose.yml) — no credentials # in this file. Do NOT hardcode tokens here: this file is committed to a diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh new file mode 100755 index 00000000..125d7345 --- /dev/null +++ b/scripts/backup-databases.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Nightly logical backups for both MosswartOverlord databases. +# Install as a cron job on the live host (see docs/backups.md). Note `bash` +# in the cron line (survives a lost executable bit) and that /home/erik/backups +# must exist BEFORE the first run (cron sets up the >> redirection before this +# script's mkdir runs): +# 15 3 * * * bash /home/erik/MosswartOverlord/scripts/backup-databases.sh >> /home/erik/backups/backup.log 2>&1 +# +# What is backed up: +# - dereth (TimescaleDB): full schema + all data EXCEPT the raw +# telemetry_events/spawn_events hypertable chunks. Those tables hold +# ~12 GB of data that expires via retention policies in 7-30 days +# anyway; the irreplaceable rows (users, char_stats, rare_stats, +# rare_events, combat_stats*, portals, character_stats, server_status) +# are all included. +# - inventory_db (postgres): full dump (~1 GB raw, much smaller compressed). +# +# Restore procedure: docs/backups.md (TimescaleDB needs pre/post restore calls). +set -euo pipefail +# Dumps contain the users table (bcrypt hashes) — keep them owner-only. +umask 077 + +BACKUP_DIR="${BACKUP_DIR:-/home/erik/backups/postgres}" +KEEP_DAYS="${KEEP_DAYS:-7}" +STAMP="$(date -u +%Y%m%d-%H%M)" +mkdir -p "$BACKUP_DIR" + +# dereth: -Fc is compressed; exclude hypertable chunk DATA (schema kept so a +# restore recreates the tables empty and retention/compression jobs reattach). +docker exec dereth-db pg_dump -U postgres -Fc \ + --exclude-table-data='public.telemetry_events' \ + --exclude-table-data='public.spawn_events' \ + --exclude-table-data='_timescaledb_internal._hyper_*' \ + dereth > "$BACKUP_DIR/dereth-$STAMP.dump.tmp" +# Canary: a healthy dereth dump is ~50 MB; a tiny one means pg_dump silently +# produced garbage (fail the run so the old dumps are kept and cron logs it). +if [ "$(stat -c%s "$BACKUP_DIR/dereth-$STAMP.dump.tmp")" -lt 10000000 ]; then + echo "$(date -u +%FT%TZ) FAIL dereth dump under 10MB — keeping old backups" >&2 + exit 1 +fi +mv "$BACKUP_DIR/dereth-$STAMP.dump.tmp" "$BACKUP_DIR/dereth-$STAMP.dump" + +docker exec inventory-db pg_dump -U inventory_user -Fc inventory_db \ + > "$BACKUP_DIR/inventory-$STAMP.dump.tmp" +mv "$BACKUP_DIR/inventory-$STAMP.dump.tmp" "$BACKUP_DIR/inventory-$STAMP.dump" + +# Retention: keep KEEP_DAYS days of dailies. +find "$BACKUP_DIR" -name 'dereth-*.dump' -mtime +"$KEEP_DAYS" -delete +find "$BACKUP_DIR" -name 'inventory-*.dump' -mtime +"$KEEP_DAYS" -delete +# Clean up aborted runs older than a day. +find "$BACKUP_DIR" -name '*.dump.tmp' -mtime +1 -delete + +echo "$(date -u +%FT%TZ) OK dereth=$(du -h "$BACKUP_DIR/dereth-$STAMP.dump" | cut -f1) inventory=$(du -h "$BACKUP_DIR/inventory-$STAMP.dump" | cut -f1)"