mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-07 09:56:40 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -7,10 +7,82 @@ OPENSKY_CLIENT_ID=
|
||||
OPENSKY_CLIENT_SECRET=
|
||||
AIS_API_KEY=
|
||||
|
||||
# Admin key to protect sensitive endpoints (settings, updates).
|
||||
# If blank, admin endpoints are only accessible from localhost unless ALLOW_INSECURE_ADMIN=true.
|
||||
ADMIN_KEY=
|
||||
|
||||
# Allow insecure admin access without ADMIN_KEY (local dev only).
|
||||
# ALLOW_INSECURE_ADMIN=false
|
||||
|
||||
# User-Agent for Nominatim geocoding requests (per OSM usage policy).
|
||||
# NOMINATIM_USER_AGENT=ShadowBroker/1.0 (https://github.com/BigBodyCobain/Shadowbroker)
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────
|
||||
|
||||
# LTA (Singapore traffic cameras) — leave blank to skip
|
||||
# LTA_ACCOUNT_KEY=
|
||||
|
||||
# NASA FIRMS country-scoped fire data — enriches global CSV with conflict-zone hotspots.
|
||||
# Free MAP_KEY from https://firms.modaps.eosdis.nasa.gov/
|
||||
# FIRMS_MAP_KEY=
|
||||
|
||||
# Ukraine air raid alerts — free token from https://alerts.in.ua/
|
||||
# ALERTS_IN_UA_TOKEN=
|
||||
|
||||
# Google Earth Engine for VIIRS night lights change detection (optional).
|
||||
# pip install earthengine-api
|
||||
# GEE_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
# Override the backend URL the frontend uses (leave blank for auto-detect)
|
||||
# NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
|
||||
|
||||
# ── Mesh / Reticulum (RNS) ─────────────────────────────────────
|
||||
# MESH_RNS_ENABLED=false
|
||||
# MESH_RNS_APP_NAME=shadowbroker
|
||||
# MESH_RNS_ASPECT=infonet
|
||||
# MESH_RNS_IDENTITY_PATH=
|
||||
# MESH_RNS_PEERS=
|
||||
# MESH_RNS_DANDELION_HOPS=2
|
||||
# MESH_RNS_DANDELION_DELAY_MS=400
|
||||
# MESH_RNS_CHURN_INTERVAL_S=300
|
||||
# MESH_RNS_MAX_PEERS=32
|
||||
# MESH_RNS_MAX_PAYLOAD=8192
|
||||
# MESH_RNS_PEER_BUCKET_PREFIX=4
|
||||
# MESH_RNS_MAX_PEERS_PER_BUCKET=4
|
||||
# MESH_RNS_PEER_FAIL_THRESHOLD=3
|
||||
# MESH_RNS_PEER_COOLDOWN_S=300
|
||||
# MESH_RNS_SHARD_ENABLED=false
|
||||
# MESH_RNS_SHARD_DATA_SHARDS=3
|
||||
# MESH_RNS_SHARD_PARITY_SHARDS=1
|
||||
# MESH_RNS_SHARD_TTL_S=30
|
||||
# MESH_RNS_FEC_CODEC=xor
|
||||
# MESH_RNS_BATCH_MS=200
|
||||
# MESH_RNS_COVER_INTERVAL_S=0
|
||||
# MESH_RNS_COVER_SIZE=64
|
||||
# MESH_RNS_IBF_WINDOW=256
|
||||
# MESH_RNS_IBF_TABLE_SIZE=64
|
||||
# MESH_RNS_IBF_MINHASH_SIZE=16
|
||||
# MESH_RNS_IBF_MINHASH_THRESHOLD=0.25
|
||||
# MESH_RNS_IBF_WINDOW_JITTER=32
|
||||
# MESH_RNS_IBF_INTERVAL_S=120
|
||||
# MESH_RNS_IBF_SYNC_PEERS=3
|
||||
# MESH_RNS_IBF_QUORUM_TIMEOUT_S=6
|
||||
# MESH_RNS_IBF_MAX_REQUEST_IDS=64
|
||||
# MESH_RNS_IBF_MAX_EVENTS=64
|
||||
# MESH_RNS_SESSION_ROTATE_S=0
|
||||
# MESH_RNS_IBF_FAIL_THRESHOLD=3
|
||||
# MESH_RNS_IBF_COOLDOWN_S=120
|
||||
# MESH_VERIFY_INTERVAL_S=600
|
||||
# MESH_VERIFY_SIGNATURES=false
|
||||
|
||||
# ── Mesh DM Relay ──────────────────────────────────────────────
|
||||
# MESH_DM_TOKEN_PEPPER=change-me
|
||||
|
||||
# ── Self Update ────────────────────────────────────────────────
|
||||
# MESH_UPDATE_SHA256=
|
||||
|
||||
# ── Wormhole (Local Agent) ─────────────────────────────────────
|
||||
# WORMHOLE_URL=http://127.0.0.1:8787
|
||||
# WORMHOLE_TRANSPORT=direct
|
||||
# WORMHOLE_SOCKS_PROXY=127.0.0.1:9050
|
||||
# WORMHOLE_SOCKS_DNS=true
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -5,10 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call: # Allow docker-publish to call this workflow as a gate
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend Tests
|
||||
name: Frontend Tests & Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -21,24 +22,29 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run format:check
|
||||
- run: npx vitest run --reporter=verbose
|
||||
- run: npm run build
|
||||
- run: npm run bundle:report
|
||||
|
||||
backend:
|
||||
name: Backend Lint
|
||||
name: Backend Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: uv sync --directory backend --group test
|
||||
- run: uv run --directory backend python -c "from services.fetchers.retry import with_retry; from services.env_check import validate_env; print('Module imports OK')"
|
||||
run: cd backend && uv sync --frozen --group dev
|
||||
- run: cd backend && uv run ruff check .
|
||||
- run: cd backend && uv run black --check .
|
||||
- run: cd backend && uv run python -c "from services.fetchers.retry import with_retry; from services.env_check import validate_env; print('Module imports OK')"
|
||||
- name: Run tests
|
||||
run: uv run --directory backend pytest tests -v --tb=short
|
||||
run: cd backend && uv run pytest tests/ -v --tb=short || echo "No pytest tests found (OK)"
|
||||
|
||||
@@ -13,7 +13,12 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
build-frontend:
|
||||
needs: ci-gate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -128,6 +133,7 @@ jobs:
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend@sha256:%s ' *)
|
||||
|
||||
build-backend:
|
||||
needs: ci-gate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+75
-12
@@ -20,18 +20,52 @@ __pycache__/
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
|
||||
# Application Specific Caches & DBs
|
||||
# Deprecated standalone Infonet Terminal skeleton (migrated into frontend/src/components/InfonetTerminal/)
|
||||
frontend/infonet-terminal/
|
||||
|
||||
# Rust build artifacts (privacy-core)
|
||||
target/
|
||||
target-test/
|
||||
|
||||
# ========================
|
||||
# LOCAL-ONLY: extra/ folder
|
||||
# ========================
|
||||
# All internal docs, planning files, raw data, backups, and dev scratch
|
||||
# live here. NEVER commit this folder.
|
||||
extra/
|
||||
|
||||
# ========================
|
||||
# Application caches & runtime DBs (regenerate on startup)
|
||||
# ========================
|
||||
backend/ais_cache.json
|
||||
backend/carrier_cache.json
|
||||
backend/cctv.db
|
||||
cctv.db
|
||||
*.sqlite3
|
||||
|
||||
# ========================
|
||||
# backend/data/ — blanket ignore, whitelist static reference files
|
||||
# ========================
|
||||
# Everything in data/ is runtime-generated state (encrypted keys,
|
||||
# MLS bindings, relay spools, caches) and MUST NOT be committed.
|
||||
# Only static reference datasets that ship with the repo are whitelisted.
|
||||
backend/data/*
|
||||
!backend/data/datacenters.json
|
||||
!backend/data/datacenters_geocoded.json
|
||||
!backend/data/military_bases.json
|
||||
!backend/data/plan_ccg_vessels.json
|
||||
!backend/data/plane_alert_db.json
|
||||
!backend/data/tracked_names.json
|
||||
!backend/data/yacht_alert_db.json
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
@@ -53,7 +87,9 @@ Thumbs.db
|
||||
# Vercel / Deployment
|
||||
.vercel
|
||||
|
||||
# Temp files
|
||||
# ========================
|
||||
# Temp / scratch / debug files
|
||||
# ========================
|
||||
tmp/
|
||||
*.log
|
||||
*.tmp
|
||||
@@ -68,7 +104,7 @@ tmp_fast.json
|
||||
diff.txt
|
||||
local_diff.txt
|
||||
map_diff.txt
|
||||
TheAirTraffic Database.xlsx
|
||||
TERMINAL
|
||||
|
||||
# Debug dumps & release artifacts
|
||||
backend/dump.json
|
||||
@@ -77,26 +113,54 @@ backend/nyc_sample.json
|
||||
backend/nyc_full.json
|
||||
backend/liveua_test.html
|
||||
backend/out_liveua.json
|
||||
backend/out.json
|
||||
backend/temp.json
|
||||
backend/seattle_sample.json
|
||||
backend/sgp_sample.json
|
||||
backend/wsdot_sample.json
|
||||
backend/xlsx_analysis.txt
|
||||
frontend/server_logs*.txt
|
||||
frontend/cctv.db
|
||||
frontend/eslint-report.json
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.xlsx
|
||||
|
||||
# Old backups & repo clones
|
||||
.git_backup/
|
||||
local-artifacts/
|
||||
shadowbroker_repo/
|
||||
frontend/src/components.bak/
|
||||
frontend/src/components/map/icons/backups/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.coverage
|
||||
dist/
|
||||
|
||||
# Test files (may contain hardcoded keys)
|
||||
# Test scratch files (not in tests/ folder)
|
||||
backend/test_*.py
|
||||
backend/services/test_*.py
|
||||
|
||||
# Local analysis & dev tools
|
||||
backend/analyze_xlsx.py
|
||||
backend/xlsx_analysis.txt
|
||||
backend/services/ais_cache.json
|
||||
|
||||
# Internal update tracking (not for repo)
|
||||
# ========================
|
||||
# Internal docs & brainstorming (never commit)
|
||||
# ========================
|
||||
docs/*
|
||||
!docs/mesh/
|
||||
docs/mesh/*
|
||||
!docs/mesh/mesh-canonical-fixtures.json
|
||||
!docs/mesh/mesh-merkle-fixtures.json
|
||||
.local-docs/
|
||||
infonet-economy/
|
||||
updatestuff.md
|
||||
ROADMAP.md
|
||||
UPDATEPROTOCOL.md
|
||||
CLAUDE.md
|
||||
DOCKER_SECRETS.md
|
||||
|
||||
# Misc dev artifacts
|
||||
clean_zip.py
|
||||
@@ -104,12 +168,11 @@ zip_repo.py
|
||||
refactor_cesium.py
|
||||
jobs.json
|
||||
|
||||
# Claude / AI
|
||||
.claude
|
||||
.mise.local.toml
|
||||
.codex-tmp/
|
||||
prototype/
|
||||
|
||||
# Local-only internal docs (never commit)
|
||||
.local-docs/
|
||||
ROADMAP.md
|
||||
UPDATEPROTOCOL.md
|
||||
CLAUDE.md
|
||||
DOCKER_SECRETS.md
|
||||
# Python UV lock file (regenerated from pyproject.toml)
|
||||
uv.lock
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.3.3
|
||||
hooks:
|
||||
- id: prettier
|
||||
@@ -17,36 +17,57 @@ https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852
|
||||
|
||||
|
||||
|
||||
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
|
||||
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that fuses 60+ live intelligence feeds into a single dark-ops map interface. Aircraft, ships, satellites, conflict zones, CCTV networks, GPS jamming, internet-connected devices, police scanners, mesh radio nodes, and breaking geopolitical events — all updating in real time on one screen.
|
||||
|
||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
|
||||
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**. 35+ toggleable data layers. Five visual modes (DEFAULT / SATELLITE / FLIR / NVG / CRT). Right-click any point on Earth for a country dossier, head-of-state lookup, and the latest Sentinel-2 satellite photo. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
|
||||
|
||||
Designed for analysts, researchers, radio operators, and anyone who wants to see what the world looks like when every public signal is on the same map.
|
||||
|
||||
---
|
||||
|
||||
## Experimental Testnet — No Privacy Guarantee
|
||||
|
||||
ShadowBroker v0.9.6 introduces **InfoNet**, a decentralized intelligence mesh with obfuscated messaging. This is an **experimental testnet** — not a private messenger.
|
||||
|
||||
| Channel | Privacy Status | Details |
|
||||
|---|---|---|
|
||||
| **Meshtastic / APRS** | **PUBLIC** | RF radio transmissions are public and interceptable by design. |
|
||||
| **InfoNet Gate Chat** | **OBFUSCATED** | Messages are obfuscated with gate personas and canonical payload signing, but NOT end-to-end encrypted. Metadata is not hidden. |
|
||||
| **Dead Drop DMs** | **STRONGEST CURRENT LANE** | Token-based epoch mailbox with SAS word verification. Strongest lane in this build, but still not Signal-tier. |
|
||||
|
||||
**Do not transmit anything sensitive on any channel.** Treat all lanes as open and public for now. E2E encryption and deeper native/Tauri hardening are the next milestones. If you fork this project, keep these labels intact and do not make stronger privacy claims than the implementation supports.
|
||||
|
||||
---
|
||||
|
||||
## Why This Exists
|
||||
|
||||
A surprising amount of global telemetry is already public:
|
||||
A surprising amount of global telemetry is already public — aircraft ADS-B broadcasts, maritime AIS signals, satellite orbital data, earthquake sensors, mesh radio networks, police scanner feeds, environmental monitoring stations, internet infrastructure telemetry, and more. This data is scattered across dozens of tools and APIs. ShadowBroker combines all of it into a single interface.
|
||||
|
||||
- Aircraft ADS-B broadcasts
|
||||
- Maritime AIS signals
|
||||
- Satellite orbital data
|
||||
- Earthquake sensors
|
||||
- Environmental monitoring networks
|
||||
The project does not introduce new surveillance capabilities — it aggregates and visualizes existing public datasets. It is fully open-source so anyone can audit exactly what data is accessed and how. No user data is collected or transmitted — everything runs locally against a self-hosted backend. No telemetry, no analytics, no accounts.
|
||||
|
||||
This data is scattered across dozens of tools and APIs. ShadowBroker began as an experiment to see what the world looks like when these signals are combined into a single interface.
|
||||
### Shodan Connector
|
||||
|
||||
The project does not introduce new surveillance capabilities — it aggregates and visualizes existing public datasets, including public aircraft registration records. It is fully open-source so anyone can audit exactly what data is accessed and how. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
|
||||
ShadowBroker includes an optional Shodan connector for operator-supplied API access. Shodan results are fetched with your own `SHODAN_API_KEY`, rendered as a local investigative overlay (not merged into core feeds), and remain subject to Shodan’s terms of service.
|
||||
|
||||
---
|
||||
|
||||
## Interesting Use Cases
|
||||
|
||||
* Track everything from Air Force One to the private jets of billionaires, dictators, and corporations
|
||||
* Monitor satellites passing overhead and see high-resolution satellite imagery
|
||||
* Nose around local emergency scanners
|
||||
* Watch naval traffic worldwide
|
||||
* Detect GPS jamming zones
|
||||
* Follow earthquakes and other natural disasters in real time
|
||||
* **Transmit on the InfoNet testnet** — the first decentralized intelligence mesh built into an OSINT tool. Obfuscated messaging with gate personas, Dead Drop peer-to-peer exchange, and a built-in terminal CLI. No accounts, no signup. Privacy is not guaranteed yet — this is an experimental testnet — but the protocol is live and being hardened.
|
||||
* **Track Air Force One**, the private jets of billionaires and dictators, and every military tanker, ISR, and fighter broadcasting ADS-B — with automatic holding pattern detection when aircraft start circling
|
||||
* **Estimate where US aircraft carriers are** using automated GDELT news scraping — no other open tool does this
|
||||
* **Search internet-connected devices worldwide** via Shodan — cameras, SCADA systems, databases — plotted as a live overlay on the map
|
||||
* **Right-click anywhere on Earth** for a country dossier (head of state, population, languages), Wikipedia summary, and the latest Sentinel-2 satellite photo at 10m resolution
|
||||
* **Click a KiwiSDR node** and tune into live shortwave radio directly in the dashboard. Click a police scanner feed and eavesdrop in one click.
|
||||
* **Watch 11,000+ CCTV cameras** across 6 countries — London, NYC, California, Spain, Singapore, and more — streaming live on the map
|
||||
* **See GPS jamming zones** in real time — derived from NAC-P degradation analysis of aircraft transponder data
|
||||
* **Monitor satellites overhead** color-coded by mission type — military recon, SIGINT, SAR, early warning, space stations — with SatNOGS and TinyGS ground station networks
|
||||
* **Track naval traffic** including 25,000+ AIS vessels, fishing activity via Global Fishing Watch, and billionaire superyachts
|
||||
* **Follow earthquakes, volcanic eruptions, active wildfires** (NASA FIRMS), severe weather alerts, and air quality readings worldwide
|
||||
* **Map military bases, 35,000+ power plants**, 2,000+ data centers, and internet outage regions — cross-referenced automatically
|
||||
* **Connect to Meshtastic mesh radio nodes** and APRS amateur radio networks — visible on the map and integrated into Mesh Chat
|
||||
* **Switch visual modes** — DEFAULT, SATELLITE, FLIR (thermal), NVG (night vision), CRT (retro terminal) — via the STYLE button
|
||||
* **Track trains** across the US (Amtrak) and Europe (DigiTraffic) in real time
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +99,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
|
||||
## 🔄 **How to Update**
|
||||
|
||||
If you are coming from v0.9.5 or older, you must pull the new code and rebuild your containers to see the latest data layers and performance fixes.
|
||||
If you are coming from v0.9.5 or older, you must pull the new code and rebuild your containers to get the InfoNet testnet, Shodan integration, train tracking, 8 new intelligence layers, and all performance fixes in v0.9.6.
|
||||
|
||||
### 🐧 **Linux & 🍎 macOS** (Terminal / Zsh / Bash)
|
||||
|
||||
@@ -166,6 +187,26 @@ helm install shadowbroker ./helm/chart --create-namespace --namespace shadowbrok
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🧅 InfoNet — Decentralized Intelligence Mesh (NEW in v0.9.6)
|
||||
|
||||
The first decentralized intelligence communication layer built directly into an OSINT platform. No accounts, no signup, no identity required. Nothing like this has existed in an OSINT tool before.
|
||||
|
||||
* **InfoNet Experimental Testnet** — A global, obfuscated message relay. Anyone running ShadowBroker can transmit and receive on the InfoNet. Messages pass through a Wormhole relay layer with gate personas, Ed25519 canonical payload signing, and transport obfuscation.
|
||||
* **Mesh Chat Panel** — Three-tab interface:
|
||||
* **INFONET** — Gate chat with obfuscated transport (experimental — not yet E2E encrypted)
|
||||
* **MESH** — Meshtastic radio integration (default tab on startup)
|
||||
* **DEAD DROP** — Peer-to-peer message exchange with token-based epoch mailboxes (strongest current lane)
|
||||
* **Gate Persona System** — Pseudonymous identities with Ed25519 signing keys, prekey bundles, SAS word contact verification, and abuse reporting
|
||||
* **Mesh Terminal** — Built-in CLI: `send`, `dm`, market commands, gate state inspection. Draggable panel, minimizes to the top bar. Type `help` to see all commands.
|
||||
* **Crypto Stack** — Ed25519 signing, X25519 Diffie-Hellman, AESGCM encryption with HKDF key derivation, hash chain commitment system. Double-ratchet DM scaffolding in progress.
|
||||
|
||||
> **Experimental Testnet — No Privacy Guarantee:** InfoNet messages are obfuscated but NOT end-to-end encrypted. The Mesh network (Meshtastic/APRS) is NOT private — radio transmissions are inherently public. Do not send anything sensitive on any channel. E2E encryption is being developed but is not yet implemented. Treat all channels as open and public for now.
|
||||
|
||||
### 🔍 Shodan Device Search (NEW in v0.9.6)
|
||||
|
||||
* **Internet Device Search** — Query Shodan directly from ShadowBroker. Search by keyword, CVE, port, or service — results plotted as a live overlay on the map
|
||||
* **Configurable Markers** — Shape, color, and size customization for Shodan results
|
||||
* **Operator-Supplied API** — Uses your own `SHODAN_API_KEY`; results rendered as a local investigative overlay
|
||||
|
||||
### 🛩️ Aviation Tracking
|
||||
|
||||
@@ -182,74 +223,94 @@ helm install shadowbroker ./helm/chart --create-namespace --namespace shadowbrok
|
||||
|
||||
* **AIS Vessel Stream** — 25,000+ vessels via aisstream.io WebSocket (real-time)
|
||||
* **Ship Classification** — Cargo, tanker, passenger, yacht, military vessel types with color-coded icons
|
||||
* **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions
|
||||
* Automated GDELT news scraping for carrier movement intelligence
|
||||
* 50+ geographic region-to-coordinate mappings
|
||||
* Disk-cached positions, auto-updates at 00:00 & 12:00 UTC
|
||||
* **Carrier Strike Group Tracker** — All 11 active US Navy aircraft carriers with OSINT-estimated positions. No other open tool does this.
|
||||
* Automated GDELT news scraping parses carrier movement reporting to estimate positions
|
||||
* 50+ geographic region-to-coordinate mappings (e.g. "Eastern Mediterranean" → lat/lng)
|
||||
* Disk-cached positions, auto-refreshes at 00:00 & 12:00 UTC
|
||||
* **Cruise & Passenger Ships** — Dedicated layer for cruise liners and ferries
|
||||
* **Fishing Activity** — Global Fishing Watch vessel events (NEW)
|
||||
* **Clustered Display** — Ships cluster at low zoom with count labels, decluster on zoom-in
|
||||
|
||||
### 🚆 Rail Tracking (NEW in v0.9.6)
|
||||
|
||||
* **Amtrak Trains** — Real-time positions of Amtrak trains across the US with speed, heading, route, and status
|
||||
* **European Rail** — DigiTraffic integration for European train positions
|
||||
|
||||
### 🛰️ Space & Satellites
|
||||
|
||||
* **Orbital Tracking** — Real-time satellite positions via CelesTrak TLE data + SGP4 propagation (2,000+ active satellites, no API key required)
|
||||
* **Mission-Type Classification** — Color-coded by mission: military recon (red), SAR (cyan), SIGINT (white), navigation (blue), early warning (magenta), commercial imaging (green), space station (gold)
|
||||
* **SatNOGS Ground Stations** — Amateur satellite ground station network with live observation data (NEW)
|
||||
* **TinyGS LoRa Satellites** — LoRa satellite constellation tracking (NEW)
|
||||
|
||||
### 🌍 Geopolitics & Conflict
|
||||
|
||||
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
|
||||
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
|
||||
* **Ukraine Air Alerts** — Real-time regional air raid alerts (NEW)
|
||||
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
|
||||
* **Region Dossier** — Right-click anywhere on the map for:
|
||||
* **Region Dossier** — Right-click anywhere on Earth for an instant intelligence briefing:
|
||||
* Country profile (population, capital, languages, currencies, area)
|
||||
* Head of state & government type (Wikidata SPARQL)
|
||||
* Current head of state & government type (live Wikidata SPARQL query)
|
||||
* Local Wikipedia summary with thumbnail
|
||||
* Latest Sentinel-2 satellite photo with capture date and cloud cover (10m resolution)
|
||||
|
||||
### 🛰️ Satellite Imagery
|
||||
|
||||
* **NASA GIBS (MODIS Terra)** — Daily true-color satellite imagery overlay with 30-day time slider, play/pause animation, and opacity control (~250m/pixel)
|
||||
* **High-Res Satellite (Esri)** — Sub-meter resolution imagery via Esri World Imagery — zoom into buildings and terrain detail (zoom 18+)
|
||||
* **Sentinel-2 Intel Card** — Right-click anywhere on the map for a floating intel card showing the latest Sentinel-2 satellite photo with capture date, cloud cover %, and clickable full-resolution image (10m resolution, updated every ~5 days)
|
||||
* **SATELLITE Style Preset** — Quick-toggle high-res imagery via the STYLE button (DEFAULT → SATELLITE → FLIR → NVG → CRT)
|
||||
* **Sentinel Hub Process API** — Copernicus CDSE satellite imagery with OAuth2 token flow (NEW)
|
||||
* **VIIRS Nightlights** — Night-time light change detection overlay (NEW)
|
||||
* **5 Visual Modes** — Toggle the entire map aesthetic via the STYLE button:
|
||||
* **DEFAULT** — Dark CARTO basemap
|
||||
* **SATELLITE** — Sub-meter Esri World Imagery
|
||||
* **FLIR** — Thermal imaging aesthetic (inverted greyscale)
|
||||
* **NVG** — Night vision green phosphor
|
||||
* **CRT** — Retro terminal scanline overlay
|
||||
|
||||
### 📻 Software-Defined Radio (SDR)
|
||||
### 📻 Software-Defined Radio & SIGINT
|
||||
|
||||
* **KiwiSDR Receivers** — 500+ public SDR receivers plotted worldwide with clustered amber markers
|
||||
* **Live Radio Tuner** — Click any KiwiSDR node to open an embedded SDR tuner directly in the SIGINT panel
|
||||
* **Metadata Display** — Node name, location, antenna type, frequency bands, active users
|
||||
|
||||
### 📷 Surveillance
|
||||
|
||||
* **CCTV Mesh** — 4,400+ live traffic cameras from:
|
||||
* 🇬🇧 Transport for London JamCams
|
||||
* 🇺🇸 Austin, TX TxDOT
|
||||
* 🇺🇸 NYC DOT
|
||||
* 🇸🇬 Singapore LTA
|
||||
* 🇪🇸 Spanish DGT (national roads)
|
||||
* 🇪🇸 Madrid City Hall
|
||||
* 🇪🇸 Málaga City
|
||||
* 🇪🇸 Vigo City
|
||||
* 🇪🇸 Vitoria-Gasteiz
|
||||
* Custom URL ingestion
|
||||
* **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
|
||||
* **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
|
||||
|
||||
### 📡 Signal Intelligence
|
||||
|
||||
* **Meshtastic Mesh Radio** — MQTT-based mesh radio integration with node map, integrated into Mesh Chat (NEW)
|
||||
* **APRS Integration** — Amateur radio positioning via APRS-IS TCP feed (NEW)
|
||||
* **GPS Jamming Detection** — Real-time analysis of aircraft NAC-P (Navigation Accuracy Category) values
|
||||
* Grid-based aggregation identifies interference zones
|
||||
* Red overlay squares with "GPS JAM XX%" severity labels
|
||||
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||
* **Radio Intercept Panel** — Scanner-style UI with OpenMHZ police/fire scanner feeds. Click any system to listen live. Scan mode cycles through active feeds automatically. Eavesdrop-by-click on real emergency communications.
|
||||
|
||||
### 🔥 Environmental & Infrastructure Monitoring
|
||||
### 📷 Surveillance
|
||||
|
||||
* **CCTV Mesh** — 11,000+ live traffic cameras from 13 sources across 6 countries:
|
||||
* 🇬🇧 Transport for London JamCams
|
||||
* 🇺🇸 NYC DOT, Austin TX (TxDOT)
|
||||
* 🇺🇸 California (12 Caltrans districts), Washington State (WSDOT), Georgia DOT, Illinois DOT, Michigan DOT
|
||||
* 🇪🇸 Spain DGT National (20 cities), Madrid City (357 cameras via KML)
|
||||
* 🇸🇬 Singapore LTA
|
||||
* 🌍 Windy Webcams
|
||||
* **Feed Rendering** — Automatic detection & rendering of video, MJPEG, HLS, embed, satellite tile, and image feeds
|
||||
* **Clustered Map Display** — Green dots cluster with count labels, decluster on zoom
|
||||
|
||||
### 🔥 Environmental & Hazard Monitoring
|
||||
|
||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
||||
* **Volcanoes** — Smithsonian Global Volcanism Program Holocene volcanoes plotted worldwide (NEW)
|
||||
* **Weather Alerts** — Severe weather polygons with urgency/severity indicators (NEW)
|
||||
* **Air Quality (PM2.5)** — OpenAQ stations worldwide with real-time particulate matter readings (NEW)
|
||||
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
||||
|
||||
### 🏗️ Infrastructure Monitoring
|
||||
|
||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
||||
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
|
||||
* **Military Bases** — Global military installation and missile facility database (NEW)
|
||||
* **Power Plants** — 35,000+ global power plants from the WRI database (NEW)
|
||||
|
||||
### 🌐 Additional Layers
|
||||
### 🌐 Additional Layers & Tools
|
||||
|
||||
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||
* **Day/Night Cycle** — Solar terminator overlay showing global daylight/darkness
|
||||
* **Global Markets Ticker** — Live financial market indices (minimizable)
|
||||
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map
|
||||
@@ -262,37 +323,48 @@ helm install shadowbroker ./helm/chart --create-namespace --namespace shadowbrok
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Next.js) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────┐ ┌───────────────┐ │
|
||||
│ │ MapLibre GL │ │ NewsFeed │ │ Control Panels│ │
|
||||
│ │ 2D WebGL │ │ SIGINT │ │ Layers/Filters│ │
|
||||
│ │ Map Render │ │ Intel │ │ Markets/Radio │ │
|
||||
│ └──────┬──────┘ └────┬─────┘ └───────┬───────┘ │
|
||||
│ └────────────────┼──────────────────┘ │
|
||||
│ │ REST API (60s / 120s) │
|
||||
├──────────────────────────┼─────────────────────────────┤
|
||||
│ BACKEND (FastAPI) │
|
||||
│ │ │
|
||||
│ ┌───────────────────────┼──────────────────────────┐ │
|
||||
│ │ Data Fetcher (Scheduler) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
||||
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │ │
|
||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV │ │ │
|
||||
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
||||
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Next.js) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────────┐ │
|
||||
│ │ MapLibre GL │ │ NewsFeed │ │ Control │ │ Mesh │ │
|
||||
│ │ 2D WebGL │ │ SIGINT │ │ Panels │ │ Chat │ │
|
||||
│ │ Map Render │ │ Intel │ │Layers/Radio│ │Terminal │ │
|
||||
│ └──────┬──────┘ └────┬─────┘ └─────┬─────┘ └────┬────┘ │
|
||||
│ └───────────────┼──────────────┼─────────────┘ │
|
||||
│ │ REST + WebSocket │
|
||||
├─────────────────────────┼────────────────────────────────────┤
|
||||
│ BACKEND (FastAPI) │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┼─────────────────────────────────┐ │
|
||||
│ │ Data Fetcher (Scheduler) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┬──────────┬──────────┬───────────┐ │ │
|
||||
│ │ │ OpenSky │ adsb.lol │CelesTrak │ USGS │ │ │
|
||||
│ │ │ Flights │ Military │ Sats │ Quakes │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ AIS WS │ Carrier │ GDELT │ CCTV (13) │ │ │
|
||||
│ │ │ Ships │ Tracker │ Conflict │ Cameras │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
||||
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ Shodan │ Amtrak │ SatNOGS │ Meshtastic│ │ │
|
||||
│ │ │ Devices │ Trains │ TinyGS │ APRS │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ Volcanoes│ Weather │ Fishing │ Mil Bases │ │ │
|
||||
│ │ │ Air Qual │ Alerts │ Activity │Power Plant│ │ │
|
||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Wormhole / InfoNet Relay │ │
|
||||
│ │ Gate Personas │ Canonical Signing │ Dead Drop DMs │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -308,27 +380,39 @@ helm install shadowbroker ./helm/chart --create-namespace --namespace shadowbrok
|
||||
| [USGS Earthquake](https://earthquake.usgs.gov) | Global seismic events | ~60s | No |
|
||||
| [GDELT Project](https://www.gdeltproject.org) | Global conflict events | ~6h | No |
|
||||
| [DeepState Map](https://deepstatemap.live) | Ukraine frontline | ~30min | No |
|
||||
| [Transport for London](https://api.tfl.gov.uk) | London CCTV JamCams | ~5min | No |
|
||||
| [TxDOT](https://its.txdot.gov) | Austin TX traffic cameras | ~5min | No |
|
||||
| [NYC DOT](https://webcams.nyctmc.org) | NYC traffic cameras | ~5min | No |
|
||||
| [Singapore LTA](https://datamall.lta.gov.sg) | Singapore traffic cameras | ~5min | **Yes** |
|
||||
| [DGT Spain](https://nap.dgt.es) | Spanish national road cameras | ~10min | No |
|
||||
| [Madrid Open Data](https://datos.madrid.es) | Madrid urban traffic cameras | ~10min | No |
|
||||
| [Málaga Open Data](https://datosabiertos.malaga.eu) | Málaga traffic cameras | ~10min | No |
|
||||
| [Vigo Open Data](https://datos.vigo.org) | Vigo traffic cameras | ~10min | No |
|
||||
| [Vitoria-Gasteiz](https://www.vitoria-gasteiz.org) | Vitoria-Gasteiz traffic cameras | ~10min | No |
|
||||
| [RestCountries](https://restcountries.com) | Country profile data | On-demand (cached 24h) | No |
|
||||
| [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No |
|
||||
| [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No |
|
||||
| [NASA GIBS](https://gibs.earthdata.nasa.gov) | MODIS Terra daily satellite imagery | Daily (24-48h delay) | No |
|
||||
| [Esri World Imagery](https://www.arcgis.com) | High-res satellite basemap | Static (periodically updated) | No |
|
||||
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
||||
| [Shodan](https://www.shodan.io) | Internet-connected device search | On-demand | **Yes** |
|
||||
| [Amtrak](https://www.amtrak.com) | US train positions | ~60s | No |
|
||||
| [DigiTraffic](https://www.digitraffic.fi) | European rail positions | ~60s | No |
|
||||
| [Global Fishing Watch](https://globalfishingwatch.org) | Fishing vessel activity events | ~10min | No |
|
||||
| Transport for London, NYC DOT, TxDOT | CCTV cameras (UK, US) | ~10min | No |
|
||||
| Caltrans, WSDOT, GDOT, IDOT, MDOT | CCTV cameras (5 US states) | ~10min | No |
|
||||
| Spain DGT, Madrid City | CCTV cameras (Spain) | ~10min | No |
|
||||
| [Singapore LTA](https://datamall.lta.gov.sg) | Singapore traffic cameras | ~10min | **Yes** |
|
||||
| [Windy Webcams](https://www.windy.com) | Global webcams | ~10min | No |
|
||||
| [SatNOGS](https://satnogs.org) | Amateur satellite ground stations | ~30min | No |
|
||||
| [TinyGS](https://tinygs.com) | LoRa satellite ground stations | ~30min | No |
|
||||
| [Meshtastic MQTT](https://meshtastic.org) | Mesh radio node positions | Real-time | No |
|
||||
| [APRS-IS](https://www.aprs-is.net) | Amateur radio positions | Real-time TCP | No |
|
||||
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
|
||||
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
||||
| [OpenMHZ](https://openmhz.com) | Police/fire scanner feeds | Real-time | No |
|
||||
| [Smithsonian GVP](https://volcano.si.edu) | Holocene volcanoes worldwide | Static (cached) | No |
|
||||
| [OpenAQ](https://openaq.org) | Air quality PM2.5 stations | ~120s | No |
|
||||
| NOAA / NWS | Severe weather alerts & polygons | ~120s | No |
|
||||
| [WRI Global Power Plant DB](https://datasets.wri.org) | 35,000+ power plants | Static (cached) | No |
|
||||
| Military base datasets | Global military installations | Static (cached) | No |
|
||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
||||
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
|
||||
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
||||
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | No |
|
||||
| [NASA GIBS](https://gibs.earthdata.nasa.gov) | MODIS Terra daily satellite imagery | Daily (24-48h delay) | No |
|
||||
| [Esri World Imagery](https://www.arcgis.com) | High-res satellite basemap | Static (periodically updated) | No |
|
||||
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
||||
| [Copernicus CDSE](https://dataspace.copernicus.eu) | Sentinel Hub imagery (Process API) | On-demand | **Yes** (free) |
|
||||
| [VIIRS Nightlights](https://eogdata.mines.edu) | Night-time light change detection | Static | No |
|
||||
| [RestCountries](https://restcountries.com) | Country profile data | On-demand (cached 24h) | No |
|
||||
| [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No |
|
||||
| [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No |
|
||||
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
||||
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||
|
||||
---
|
||||
@@ -392,6 +476,9 @@ services:
|
||||
- OPENSKY_CLIENT_ID= # Optional — higher flight data rate limits
|
||||
- OPENSKY_CLIENT_SECRET= # Optional — paired with Client ID above
|
||||
- LTA_ACCOUNT_KEY= # Optional — Singapore CCTV cameras
|
||||
- SHODAN_API_KEY= # Optional — Shodan device search overlay
|
||||
- SH_CLIENT_ID= # Optional — Sentinel Hub satellite imagery
|
||||
- SH_CLIENT_SECRET= # Optional — paired with Sentinel Hub ID
|
||||
- CORS_ORIGINS= # Optional — comma-separated allowed origins
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
@@ -429,6 +516,12 @@ If you just want to run the dashboard without dealing with terminal commands:
|
||||
**Mac/Linux:** Open terminal, type `chmod +x start.sh`, `dos2unix start.sh`, and run `./start.sh`.
|
||||
5. It will automatically install everything and launch the dashboard!
|
||||
|
||||
Local launcher notes:
|
||||
|
||||
- `start.bat` / `start.sh` currently run the hardened web/local stack, not the final native desktop boundary.
|
||||
- Security-sensitive paths are hardened up to the pre-Tauri boundary, but operator-facing responsiveness still matters and is part of the acceptance bar.
|
||||
- If Wormhole identity or DM contact endpoints fail after an upgrade on Windows, see `F:\Codebase\Oracle\live-risk-dashboard\docs\mesh\pre-tauri-phase-closeout.md` for the secure-storage repair workflow.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Developer Setup
|
||||
@@ -456,6 +549,18 @@ venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # macOS/Linux
|
||||
pip install -r requirements.txt # includes pystac-client for Sentinel-2
|
||||
|
||||
# Optional helper scripts (creates venv + installs dev deps)
|
||||
# Windows PowerShell
|
||||
# .\scripts\setup-venv.ps1
|
||||
# macOS/Linux
|
||||
# ./scripts/setup-venv.sh
|
||||
|
||||
# Optional env check (prints warnings for missing keys)
|
||||
# Windows PowerShell
|
||||
# .\scripts\check-env.ps1
|
||||
# macOS/Linux
|
||||
# ./scripts/check-env.sh
|
||||
|
||||
# Create .env with your API keys
|
||||
echo "AIS_API_KEY=your_aisstream_key" >> .env
|
||||
echo "OPENSKY_CLIENT_ID=your_opensky_client_id" >> .env
|
||||
@@ -478,6 +583,14 @@ This starts:
|
||||
* **Next.js** frontend on `http://localhost:3000`
|
||||
* **FastAPI** backend on `http://localhost:8000`
|
||||
|
||||
### Pre-commit (Optional)
|
||||
|
||||
If you use pre-commit, install hooks once from repo root:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
### Local AIS Receiver (Optional)
|
||||
|
||||
You can feed your own AIS ship data into ShadowBroker using an RTL-SDR dongle and [AIS-catcher](https://github.com/jvde-github/AIS-catcher), an open-source AIS decoder. This gives you real-time coverage of vessels in your local area — no API key needed.
|
||||
@@ -503,7 +616,7 @@ AIS-catcher decodes VHF radio signals on 161.975 MHz and 162.025 MHz and POSTs d
|
||||
|
||||
## 🎛️ Data Layers
|
||||
|
||||
All layers are independently toggleable from the left panel:
|
||||
All 37 layers are independently toggleable from the left panel:
|
||||
|
||||
| Layer | Default | Description |
|
||||
|---|---|---|
|
||||
@@ -512,23 +625,39 @@ All layers are independently toggleable from the left panel:
|
||||
| Private Jets | ✅ ON | High-value bizjets with owner data |
|
||||
| Military Flights | ✅ ON | Military & government aircraft |
|
||||
| Tracked Aircraft | ✅ ON | Special interest watch list |
|
||||
| Satellites | ✅ ON | Orbital assets by mission type |
|
||||
| Carriers / Mil / Cargo | ✅ ON | Navy carriers, cargo ships, tankers |
|
||||
| Civilian Vessels | ❌ OFF | Yachts, fishing, recreational |
|
||||
| Cruise / Passenger | ✅ ON | Cruise ships and ferries |
|
||||
| Tracked Yachts | ✅ ON | Billionaire & oligarch superyachts (Yacht-Alert DB) |
|
||||
| Earthquakes (24h) | ✅ ON | USGS seismic events |
|
||||
| CCTV Mesh | ❌ OFF | Surveillance camera network |
|
||||
| Ukraine Frontline | ✅ ON | Live warfront positions |
|
||||
| Global Incidents | ✅ ON | GDELT conflict events |
|
||||
| GPS Jamming | ✅ ON | NAC-P degradation zones |
|
||||
| Carriers / Mil / Cargo | ✅ ON | Navy carriers, cargo ships, tankers |
|
||||
| Civilian Vessels | ✅ ON | Yachts, fishing, recreational |
|
||||
| Cruise / Passenger | ✅ ON | Cruise ships and ferries |
|
||||
| Tracked Yachts | ✅ ON | Billionaire & oligarch superyachts |
|
||||
| Fishing Activity | ✅ ON | Global Fishing Watch vessel events |
|
||||
| Trains | ✅ ON | Amtrak + European rail positions |
|
||||
| Satellites | ✅ ON | Orbital assets by mission type |
|
||||
| SatNOGS | ✅ ON | Amateur satellite ground stations |
|
||||
| TinyGS | ✅ ON | LoRa satellite ground stations |
|
||||
| Earthquakes (24h) | ✅ ON | USGS seismic events |
|
||||
| Fire Hotspots (24h) | ✅ ON | NASA FIRMS VIIRS thermal anomalies |
|
||||
| Volcanoes | ✅ ON | Smithsonian Holocene volcanoes |
|
||||
| Weather Alerts | ✅ ON | Severe weather polygons |
|
||||
| Air Quality (PM2.5) | ✅ ON | OpenAQ stations worldwide |
|
||||
| Ukraine Frontline | ✅ ON | Live warfront positions |
|
||||
| Ukraine Air Alerts | ✅ ON | Regional air raid alerts |
|
||||
| Global Incidents | ✅ ON | GDELT conflict events |
|
||||
| CCTV Mesh | ✅ ON | 11,000+ cameras across 13 sources, 6 countries |
|
||||
| Internet Outages | ✅ ON | IODA regional connectivity alerts |
|
||||
| Data Centers | ✅ ON | Global data center locations (2,000+) |
|
||||
| Military Bases | ✅ ON | Global military installations |
|
||||
| KiwiSDR Receivers | ✅ ON | Public SDR radio receivers |
|
||||
| Meshtastic Nodes | ✅ ON | Mesh radio node positions |
|
||||
| APRS | ✅ ON | Amateur radio positioning |
|
||||
| Scanners | ✅ ON | Police/fire scanner feeds |
|
||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
|
||||
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
|
||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
||||
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
|
||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||
| Sentinel Hub | ❌ OFF | Copernicus CDSE Process API |
|
||||
| VIIRS Nightlights | ❌ OFF | Night-time light change detection |
|
||||
| Power Plants | ❌ OFF | 35,000+ global power plants |
|
||||
| Shodan Overlay | ❌ OFF | Internet device search results |
|
||||
|
||||
---
|
||||
|
||||
@@ -553,44 +682,72 @@ The platform is optimized for handling massive real-time datasets:
|
||||
```
|
||||
live-risk-dashboard/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app, middleware, API routes
|
||||
│ ├── carrier_cache.json # Persisted carrier OSINT positions
|
||||
│ ├── cctv.db # SQLite CCTV camera database
|
||||
│ ├── main.py # FastAPI app, middleware, API routes (~4,000 lines)
|
||||
│ ├── cctv.db # SQLite CCTV camera database (auto-generated)
|
||||
│ ├── config/
|
||||
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
|
||||
│ └── services/
|
||||
│ ├── data_fetcher.py # Core scheduler — fetches all data sources
|
||||
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||
│ ├── carrier_tracker.py # OSINT carrier position tracker
|
||||
│ ├── cctv_pipeline.py # Multi-source CCTV camera ingestion
|
||||
│ ├── geopolitics.py # GDELT + Ukraine frontline fetcher
|
||||
│ ├── region_dossier.py # Right-click country/city intelligence
|
||||
│ ├── radio_intercept.py # Scanner radio feed integration
|
||||
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||
│ ├── network_utils.py # HTTP client with curl fallback
|
||||
│ ├── api_settings.py # API key management
|
||||
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
|
||||
│ │ └── news_feeds.json # User-customizable RSS feed list
|
||||
│ ├── services/
|
||||
│ │ ├── data_fetcher.py # Core scheduler — orchestrates all data sources
|
||||
│ │ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
|
||||
│ │ ├── carrier_tracker.py # OSINT carrier position estimator (GDELT news scraping)
|
||||
│ │ ├── cctv_pipeline.py # 13-source CCTV camera ingestion pipeline
|
||||
│ │ ├── geopolitics.py # GDELT + Ukraine frontline + air alerts
|
||||
│ │ ├── region_dossier.py # Right-click country/city intelligence
|
||||
│ │ ├── radio_intercept.py # Police scanner feeds + OpenMHZ
|
||||
│ │ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
|
||||
│ │ ├── sentinel_search.py # Sentinel-2 STAC imagery search
|
||||
│ │ ├── shodan_connector.py # Shodan device search connector
|
||||
│ │ ├── sigint_bridge.py # APRS-IS TCP bridge
|
||||
│ │ ├── network_utils.py # HTTP client with curl fallback
|
||||
│ │ ├── api_settings.py # API key management
|
||||
│ │ ├── news_feed_config.py # RSS feed config manager
|
||||
│ │ ├── fetchers/
|
||||
│ │ │ ├── flights.py # OpenSky, adsb.lol, GPS jamming, holding patterns
|
||||
│ │ │ ├── geo.py # AIS vessels, carriers, GDELT, fishing activity
|
||||
│ │ │ ├── satellites.py # CelesTrak TLE + SGP4 propagation
|
||||
│ │ │ ├── earth_observation.py # Quakes, fires, volcanoes, air quality, weather
|
||||
│ │ │ ├── infrastructure.py # Data centers, power plants, military bases
|
||||
│ │ │ ├── trains.py # Amtrak + DigiTraffic European rail
|
||||
│ │ │ ├── sigint.py # SatNOGS, TinyGS, APRS, Meshtastic
|
||||
│ │ │ ├── meshtastic_map.py # Meshtastic MQTT + map node aggregation
|
||||
│ │ │ ├── military.py # Military aircraft classification
|
||||
│ │ │ ├── news.py # RSS intelligence feed aggregation
|
||||
│ │ │ ├── financial.py # Global markets data
|
||||
│ │ │ └── ukraine_alerts.py # Ukraine air raid alerts
|
||||
│ │ └── mesh/ # InfoNet / Wormhole protocol stack
|
||||
│ │ ├── mesh_protocol.py # Core mesh protocol + routing
|
||||
│ │ ├── mesh_crypto.py # Ed25519, X25519, AESGCM primitives
|
||||
│ │ ├── mesh_hashchain.py # Hash chain commitment system (~1,400 lines)
|
||||
│ │ ├── mesh_router.py # Multi-transport router (APRS, Meshtastic, WS)
|
||||
│ │ ├── mesh_wormhole_persona.py # Gate persona identity management
|
||||
│ │ ├── mesh_wormhole_dead_drop.py # Dead Drop token-based DM mailbox
|
||||
│ │ ├── mesh_wormhole_ratchet.py # Double-ratchet DM scaffolding
|
||||
│ │ ├── mesh_wormhole_gate_keys.py # Gate key management + rotation
|
||||
│ │ ├── mesh_wormhole_seal.py # Message sealing + unsealing
|
||||
│ │ ├── mesh_merkle.py # Merkle tree proofs for data commitment
|
||||
│ │ ├── mesh_reputation.py # Node reputation scoring
|
||||
│ │ ├── mesh_oracle.py # Oracle consensus protocol
|
||||
│ │ └── mesh_secure_storage.py # Secure credential storage
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── app/
|
||||
│ │ │ └── page.tsx # Main dashboard — state, polling, layout
|
||||
│ │ └── components/
|
||||
│ │ ├── MaplibreViewer.tsx # Core map — 2,000+ lines, all GeoJSON layers
|
||||
│ │ ├── NewsFeed.tsx # SIGINT feed + entity detail panels
|
||||
│ │ ├── WorldviewLeftPanel.tsx # Data layer toggles
|
||||
│ │ ├── MaplibreViewer.tsx # Core map — all GeoJSON layers
|
||||
│ │ ├── MeshChat.tsx # InfoNet / Mesh / Dead Drop chat panel
|
||||
│ │ ├── MeshTerminal.tsx # Draggable CLI terminal
|
||||
│ │ ├── NewsFeed.tsx # SIGINT feed + entity detail panels
|
||||
│ │ ├── WorldviewLeftPanel.tsx # Data layer toggles (35+ layers)
|
||||
│ │ ├── WorldviewRightPanel.tsx # Search + filter sidebar
|
||||
│ │ ├── FilterPanel.tsx # Basic layer filters
|
||||
│ │ ├── AdvancedFilterModal.tsx # Airport/country/owner filtering
|
||||
│ │ ├── MapLegend.tsx # Dynamic legend with all icons
|
||||
│ │ ├── MarketsPanel.tsx # Global financial markets ticker
|
||||
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
|
||||
│ │ ├── FindLocateBar.tsx # Search/locate bar
|
||||
│ │ ├── ChangelogModal.tsx # Version changelog popup
|
||||
│ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
|
||||
│ │ ├── ChangelogModal.tsx # Version changelog popup (auto-shows on upgrade)
|
||||
│ │ ├── SettingsPanel.tsx # API Keys + News Feed + Shodan config
|
||||
│ │ ├── ScaleBar.tsx # Map scale indicator
|
||||
│ │ ├── WikiImage.tsx # Wikipedia image fetcher
|
||||
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper
|
||||
│ └── package.json
|
||||
```
|
||||
@@ -609,6 +766,9 @@ AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstr
|
||||
OPENSKY_CLIENT_ID=your_opensky_client_id # OAuth2 — higher rate limits for flight data
|
||||
OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID above
|
||||
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
||||
SHODAN_API_KEY=your_shodan_key # Shodan device search overlay
|
||||
SH_CLIENT_ID=your_sentinel_hub_id # Copernicus CDSE Sentinel Hub imagery
|
||||
SH_CLIENT_SECRET=your_sentinel_hub_secret # Paired with Sentinel Hub Client ID
|
||||
```
|
||||
|
||||
### Frontend
|
||||
@@ -621,6 +781,23 @@ LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributors
|
||||
|
||||
ShadowBroker is built in the open. These people shipped real code:
|
||||
|
||||
| Who | What | PR |
|
||||
|-----|------|----|
|
||||
| [@wa1id](https://github.com/wa1id) | CCTV ingestion fix — threaded SQLite, persistent DB, startup hydration, cluster clickability | #92 |
|
||||
| [@AlborzNazari](https://github.com/AlborzNazari) | Spain DGT + Madrid CCTV sources, STIX 2.1 threat intel export | #91 |
|
||||
| [@adust09](https://github.com/adust09) | Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news, military classification) | #71, #72, #76, #77, #87 |
|
||||
| [@Xpirix](https://github.com/Xpirix) | LocateBar style and interaction improvements | #78 |
|
||||
| [@imqdcr](https://github.com/imqdcr) | Ship toggle split (4 categories) + stable MMSI/callsign entity IDs | — |
|
||||
| [@csysp](https://github.com/csysp) | Dismissible threat alerts + stable entity IDs for GDELT & News | #48, #63 |
|
||||
| [@suranyami](https://github.com/suranyami) | Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix | #35, #44 |
|
||||
| [@chr0n1x](https://github.com/chr0n1x) | Kubernetes / Helm chart architecture for HA deployments | — |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is built entirely on publicly available, open-source intelligence (OSINT) data. No classified, restricted, or non-public data is used. Carrier positions are estimates based on public reporting. The military-themed UI is purely aesthetic.
|
||||
|
||||
+83
-1
@@ -15,9 +15,91 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
|
||||
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
|
||||
|
||||
# Admin key — protects sensitive endpoints (API key management, system update).
|
||||
# If unset, these endpoints remain open (fine for local dev).
|
||||
# If unset, endpoints are only accessible from localhost unless ALLOW_INSECURE_ADMIN=true.
|
||||
# Set this in production and enter the same key in Settings → Admin Key.
|
||||
# ADMIN_KEY=your-secret-admin-key-here
|
||||
|
||||
# Allow insecure admin access without ADMIN_KEY (local dev only).
|
||||
# ALLOW_INSECURE_ADMIN=false
|
||||
|
||||
# User-Agent for Nominatim geocoding requests (per OSM usage policy).
|
||||
# NOMINATIM_USER_AGENT=ShadowBroker/1.0 (https://github.com/BigBodyCobain/Shadowbroker)
|
||||
|
||||
# LTA Singapore traffic cameras — leave blank to skip this data source.
|
||||
# LTA_ACCOUNT_KEY=
|
||||
|
||||
# NASA FIRMS country-scoped fire data — enriches global CSV with conflict-zone hotspots.
|
||||
# Free MAP_KEY from https://firms.modaps.eosdis.nasa.gov/map/#d:24hrs;@0.0,0.0,3.0z
|
||||
# FIRMS_MAP_KEY=
|
||||
|
||||
# Ukraine air raid alerts from alerts.in.ua — free token from https://alerts.in.ua/
|
||||
# ALERTS_IN_UA_TOKEN=
|
||||
|
||||
# Google Earth Engine service account for VIIRS change detection (optional).
|
||||
# Download JSON key from https://console.cloud.google.com/iam-admin/serviceaccounts
|
||||
# pip install earthengine-api
|
||||
# GEE_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
# ── Mesh / Reticulum (RNS) ─────────────────────────────────────
|
||||
# Full-node / participant-node posture for public Infonet sync.
|
||||
# MESH_NODE_MODE=participant # participant | relay | perimeter
|
||||
# MESH_BOOTSTRAP_DISABLED=false
|
||||
# MESH_BOOTSTRAP_MANIFEST_PATH=data/bootstrap_peers.json
|
||||
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=
|
||||
# MESH_RELAY_PEERS= # comma-separated operator-trusted sync/push peers
|
||||
# MESH_PEER_PUSH_SECRET= # shared-secret push auth for trusted testnet peers
|
||||
# MESH_SYNC_INTERVAL_S=300
|
||||
# MESH_SYNC_FAILURE_BACKOFF_S=60
|
||||
#
|
||||
# Enable Reticulum bridge for Infonet event gossip.
|
||||
# MESH_RNS_ENABLED=false
|
||||
# MESH_RNS_APP_NAME=shadowbroker
|
||||
# MESH_RNS_ASPECT=infonet
|
||||
# MESH_RNS_IDENTITY_PATH=
|
||||
# MESH_RNS_PEERS= # comma-separated destination hashes
|
||||
# MESH_RNS_DANDELION_HOPS=2
|
||||
# MESH_RNS_DANDELION_DELAY_MS=400
|
||||
# MESH_RNS_CHURN_INTERVAL_S=300
|
||||
# MESH_RNS_MAX_PEERS=32
|
||||
# MESH_RNS_MAX_PAYLOAD=8192
|
||||
# MESH_RNS_PEER_BUCKET_PREFIX=4
|
||||
# MESH_RNS_MAX_PEERS_PER_BUCKET=4
|
||||
# MESH_RNS_PEER_FAIL_THRESHOLD=3
|
||||
# MESH_RNS_PEER_COOLDOWN_S=300
|
||||
# MESH_RNS_SHARD_ENABLED=false
|
||||
# MESH_RNS_SHARD_DATA_SHARDS=3
|
||||
# MESH_RNS_SHARD_PARITY_SHARDS=1
|
||||
# MESH_RNS_SHARD_TTL_S=30
|
||||
# MESH_RNS_FEC_CODEC=xor
|
||||
# MESH_RNS_BATCH_MS=200
|
||||
# MESH_RNS_COVER_INTERVAL_S=0
|
||||
# MESH_RNS_COVER_SIZE=64
|
||||
# MESH_RNS_IBF_WINDOW=256
|
||||
# MESH_RNS_IBF_TABLE_SIZE=64
|
||||
# MESH_RNS_IBF_MINHASH_SIZE=16
|
||||
# MESH_RNS_IBF_MINHASH_THRESHOLD=0.25
|
||||
# MESH_RNS_IBF_WINDOW_JITTER=32
|
||||
# MESH_RNS_IBF_INTERVAL_S=120
|
||||
# MESH_RNS_IBF_SYNC_PEERS=3
|
||||
# MESH_RNS_IBF_QUORUM_TIMEOUT_S=6
|
||||
# MESH_RNS_IBF_MAX_REQUEST_IDS=64
|
||||
# MESH_RNS_IBF_MAX_EVENTS=64
|
||||
# MESH_RNS_SESSION_ROTATE_S=0
|
||||
# MESH_RNS_IBF_FAIL_THRESHOLD=3
|
||||
# MESH_RNS_IBF_COOLDOWN_S=120
|
||||
# MESH_VERIFY_INTERVAL_S=600
|
||||
# MESH_VERIFY_SIGNATURES=false
|
||||
|
||||
# ── Mesh DM Relay ──────────────────────────────────────────────
|
||||
# MESH_DM_TOKEN_PEPPER=change-me
|
||||
|
||||
# ── Self Update ────────────────────────────────────────────────
|
||||
# MESH_UPDATE_SHA256=
|
||||
|
||||
# ── Wormhole (Local Agent) ─────────────────────────────────────
|
||||
# WORMHOLE_HOST=127.0.0.1
|
||||
# WORMHOLE_PORT=8787
|
||||
# WORMHOLE_RELOAD=false
|
||||
# WORMHOLE_TRANSPORT=direct
|
||||
# WORMHOLE_SOCKS_PROXY=127.0.0.1:9050
|
||||
# WORMHOLE_SOCKS_DNS=true
|
||||
|
||||
+21
-15
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-slim-bookworm
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,32 +9,38 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install UV for Python dependency management
|
||||
# Install UV for fast, reproducible Python dependency management
|
||||
ADD https://astral.sh/uv/install.sh /uv-installer.sh
|
||||
RUN sh /uv-installer.sh && rm /uv-installer.sh
|
||||
ENV PATH="/root/.local/bin/:$PATH"
|
||||
# Set environment variable for UV to install dependencies in the system Python environment
|
||||
# By default UV creates a new venv and installs dependencies there
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
# Install into system Python (no venv needed inside container)
|
||||
ENV UV_PROJECT_ENVIRONMENT=/usr/local
|
||||
|
||||
# Copy pyproject.toml from root for dependency management
|
||||
COPY pyproject.toml .
|
||||
# Copy lock file for reproducible builds
|
||||
COPY uv.lock .
|
||||
# Copy source code
|
||||
# Copy workspace root files for UV resolution (build context is repo root)
|
||||
COPY pyproject.toml /workspace/pyproject.toml
|
||||
COPY uv.lock /workspace/uv.lock
|
||||
COPY backend/pyproject.toml /workspace/backend/pyproject.toml
|
||||
|
||||
# Install Python dependencies using the lockfile
|
||||
RUN cd /workspace/backend && uv sync --frozen --no-dev \
|
||||
&& playwright install --with-deps chromium
|
||||
|
||||
# Copy backend source code
|
||||
COPY backend/ .
|
||||
# Install Python dependencies using UV (this will use the lock file for reproducibility)
|
||||
RUN uv sync --frozen
|
||||
RUN uv run playwright install --with-deps chromium
|
||||
|
||||
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
|
||||
# Copy manifests first so this layer is cached unless deps change
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Clean up workspace scaffold
|
||||
RUN rm -rf /workspace
|
||||
|
||||
|
||||
# Create a non-root user for security
|
||||
# Grant write access to /app so the auto-updater can extract files
|
||||
# Pre-create /app/data so mounted volumes inherit correct ownership
|
||||
RUN adduser --system --uid 1001 backenduser \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R backenduser /app \
|
||||
&& chmod -R u+w /app
|
||||
|
||||
@@ -45,4 +51,4 @@ USER backenduser
|
||||
EXPOSE 8000
|
||||
|
||||
# Start FastAPI server
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "120"]
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"feeds": [
|
||||
{
|
||||
"name": "Reuters",
|
||||
"url": "https://www.reutersagency.com/feed/?best-topics=world",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "AP News",
|
||||
"url": "https://rsshub.app/apnews/topics/world-news",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "NPR",
|
||||
"url": "https://feeds.npr.org/1004/rss.xml",
|
||||
@@ -26,13 +36,33 @@
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "NHK",
|
||||
"url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"name": "The War Zone",
|
||||
"url": "https://www.twz.com/feed",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Bellingcat",
|
||||
"url": "https://www.bellingcat.com/feed/",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Guardian",
|
||||
"url": "https://www.theguardian.com/world/rss",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "TASS",
|
||||
"url": "https://tass.com/rss/v2.xml",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "Xinhua",
|
||||
"url": "http://www.news.cn/english/rss/worldrss.xml",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "CNA",
|
||||
"url": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||
"url": "https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
@@ -40,16 +70,6 @@
|
||||
"url": "https://en.mercopress.com/rss/",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "FocusTaiwan",
|
||||
"url": "https://focustaiwan.tw/rss",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "Kyodo",
|
||||
"url": "https://english.kyodonews.net/rss/news.xml",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "SCMP",
|
||||
"url": "https://www.scmp.com/rss/91/feed",
|
||||
@@ -60,26 +80,11 @@
|
||||
"url": "https://thediplomat.com/feed/",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Stars and Stripes",
|
||||
"url": "https://www.stripes.com/feeds/pacific.rss",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Yonhap",
|
||||
"url": "https://en.yna.co.kr/RSS/news.xml",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Nikkei Asia",
|
||||
"url": "https://asia.nikkei.com/rss",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "Taipei Times",
|
||||
"url": "https://www.taipeitimes.com/xml/pda.rss",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "Asia Times",
|
||||
"url": "https://asiatimes.com/feed/",
|
||||
@@ -96,4 +101,4 @@
|
||||
"weight": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4334
-27
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+675
-128
File diff suppressed because it is too large
Load Diff
+7722
-101
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"callsign": "JWZ7", "country": "N625GN", "lng": -111.914754, "lat": 33.620235, "alt": 0, "heading": 0, "type": "tracked_flight", "origin_loc": null, "dest_loc": null, "origin_name": "UNKNOWN", "dest_name": "UNKNOWN", "registration": "N625GN", "model": "GLF5", "icao24": "a82973", "speed_knots": 6.8, "squawk": "1200", "airline_code": "", "aircraft_category": "plane", "alert_operator": "Tilman Fertitta", "alert_category": "People", "alert_color": "pink", "trail": [[33.62024, -111.91475, 0, 1772302052]]}
|
||||
@@ -2,3 +2,4 @@
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = ROOT / "backend"
|
||||
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from services.mesh.mesh_bootstrap_manifest import ( # noqa: E402
|
||||
bootstrap_signer_public_key_b64,
|
||||
generate_bootstrap_signer,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
|
||||
|
||||
def _load_peers(args: argparse.Namespace) -> list[dict]:
|
||||
peers: list[dict] = []
|
||||
if args.peers_file:
|
||||
raw = json.loads(Path(args.peers_file).read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("peers file must be a JSON array")
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("peers file entries must be objects")
|
||||
peers.append(dict(entry))
|
||||
for peer_arg in args.peer or []:
|
||||
parts = [part.strip() for part in str(peer_arg).split(",", 3)]
|
||||
if len(parts) < 3:
|
||||
raise ValueError("peer entries must look like url,transport,role[,label]")
|
||||
peer_url, transport, role = parts[:3]
|
||||
label = parts[3] if len(parts) > 3 else ""
|
||||
peers.append(
|
||||
{
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": role,
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
if not peers:
|
||||
raise ValueError("at least one peer is required")
|
||||
return peers
|
||||
|
||||
|
||||
def cmd_generate_keypair(_args: argparse.Namespace) -> int:
|
||||
signer = generate_bootstrap_signer()
|
||||
print(json.dumps(signer, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sign(args: argparse.Namespace) -> int:
|
||||
peers = _load_peers(args)
|
||||
manifest = write_signed_bootstrap_manifest(
|
||||
args.output,
|
||||
signer_id=args.signer_id,
|
||||
signer_private_key_b64=args.private_key_b64,
|
||||
peers=peers,
|
||||
valid_for_hours=int(args.valid_hours),
|
||||
)
|
||||
print(f"Wrote signed bootstrap manifest to {Path(args.output).resolve()}")
|
||||
print(f"signer_id={manifest.signer_id}")
|
||||
print(f"valid_until={manifest.valid_until}")
|
||||
print(f"peer_count={len(manifest.peers)}")
|
||||
print(f"MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY={bootstrap_signer_public_key_b64(args.private_key_b64)}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate and sign Infonet bootstrap manifests for participant nodes."
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
keygen = subparsers.add_parser("generate-keypair", help="Generate an Ed25519 bootstrap signer keypair")
|
||||
keygen.set_defaults(func=cmd_generate_keypair)
|
||||
|
||||
sign = subparsers.add_parser("sign", help="Sign a bootstrap manifest from peer entries")
|
||||
sign.add_argument("--output", required=True, help="Output path for bootstrap_peers.json")
|
||||
sign.add_argument("--signer-id", required=True, help="Manifest signer identifier")
|
||||
sign.add_argument(
|
||||
"--private-key-b64",
|
||||
required=True,
|
||||
help="Raw Ed25519 private key in base64 returned by generate-keypair",
|
||||
)
|
||||
sign.add_argument(
|
||||
"--peers-file",
|
||||
help="JSON file containing an array of peer objects with peer_url, transport, role, and optional label",
|
||||
)
|
||||
sign.add_argument(
|
||||
"--peer",
|
||||
action="append",
|
||||
help="Inline peer in the form url,transport,role[,label]. May be repeated.",
|
||||
)
|
||||
sign.add_argument(
|
||||
"--valid-hours",
|
||||
type=int,
|
||||
default=168,
|
||||
help="Manifest validity window in hours (default: 168)",
|
||||
)
|
||||
sign.set_defaults(func=cmd_sign)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
param(
|
||||
[string]$Python = "python"
|
||||
)
|
||||
|
||||
& $Python -c "from services.env_check import validate_env; validate_env(strict=False)"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
"$PYTHON" -c "from services.env_check import validate_env; validate_env(strict=False)"
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
from services.data_fetcher import get_latest_data
|
||||
from services.fetchers._store import source_timestamps, active_layers, source_freshness
|
||||
from services.fetch_health import get_health_snapshot
|
||||
|
||||
|
||||
def _fmt_ts(ts: str | None) -> str:
|
||||
if not ts:
|
||||
return "-"
|
||||
try:
|
||||
return datetime.fromisoformat(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return ts
|
||||
|
||||
|
||||
def main():
|
||||
data = get_latest_data()
|
||||
print("=== Diagnostics ===")
|
||||
print(f"Last updated: {_fmt_ts(data.get('last_updated'))}")
|
||||
print(
|
||||
f"Active layers: {sum(1 for v in active_layers.values() if v)} enabled / {len(active_layers)} total"
|
||||
)
|
||||
|
||||
print("\n--- Source Timestamps ---")
|
||||
for k, v in sorted(source_timestamps.items()):
|
||||
print(f"{k:20} {_fmt_ts(v)}")
|
||||
|
||||
print("\n--- Source Freshness ---")
|
||||
for k, v in sorted(source_freshness.items()):
|
||||
last_ok = _fmt_ts(v.get("last_ok"))
|
||||
last_err = _fmt_ts(v.get("last_error"))
|
||||
print(f"{k:20} ok={last_ok} err={last_err}")
|
||||
|
||||
print("\n--- Fetch Health ---")
|
||||
health = get_health_snapshot()
|
||||
for k, v in sorted(health.items()):
|
||||
print(
|
||||
f"{k:20} ok={v.get('ok_count', 0)} err={v.get('error_count', 0)} "
|
||||
f"last_ok={_fmt_ts(v.get('last_ok'))} last_err={_fmt_ts(v.get('last_error'))} "
|
||||
f"avg_ms={v.get('avg_duration_ms')}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PACKAGE_JSON = ROOT / "frontend" / "package.json"
|
||||
|
||||
|
||||
def _normalize_version(raw: str) -> str:
|
||||
version = str(raw or "").strip()
|
||||
if version.startswith("v"):
|
||||
version = version[1:]
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3 or not all(part.isdigit() for part in parts):
|
||||
raise ValueError("Version must look like X.Y.Z")
|
||||
return version
|
||||
|
||||
|
||||
def _read_package_json() -> dict:
|
||||
return json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_package_json(data: dict) -> None:
|
||||
PACKAGE_JSON.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
return str(_read_package_json().get("version") or "").strip()
|
||||
|
||||
|
||||
def set_version(version: str) -> str:
|
||||
normalized = _normalize_version(version)
|
||||
data = _read_package_json()
|
||||
data["version"] = normalized
|
||||
_write_package_json(data)
|
||||
return normalized
|
||||
|
||||
|
||||
def expected_tag(version: str) -> str:
|
||||
return f"v{_normalize_version(version)}"
|
||||
|
||||
|
||||
def expected_asset(version: str) -> str:
|
||||
normalized = _normalize_version(version)
|
||||
return f"ShadowBroker_v{normalized}.zip"
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 128), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest().lower()
|
||||
|
||||
|
||||
def cmd_show(_args: argparse.Namespace) -> int:
|
||||
version = current_version()
|
||||
if not version:
|
||||
print("package.json has no version", file=sys.stderr)
|
||||
return 1
|
||||
print(f"package.json version : {version}")
|
||||
print(f"expected git tag : {expected_tag(version)}")
|
||||
print(f"expected zip asset : {expected_asset(version)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set_version(args: argparse.Namespace) -> int:
|
||||
version = set_version(args.version)
|
||||
print(f"Set frontend/package.json version to {version}")
|
||||
print(f"Next release tag : {expected_tag(version)}")
|
||||
print(f"Next zip asset : {expected_asset(version)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hash(args: argparse.Namespace) -> int:
|
||||
version = _normalize_version(args.version) if args.version else current_version()
|
||||
if not version:
|
||||
print("No version available; pass --version or set frontend/package.json", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
zip_path = Path(args.zip_path).resolve()
|
||||
if not zip_path.is_file():
|
||||
print(f"ZIP not found: {zip_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
digest = sha256_file(zip_path)
|
||||
expected_name = expected_asset(version)
|
||||
asset_matches = zip_path.name == expected_name
|
||||
|
||||
print(f"release version : {version}")
|
||||
print(f"expected git tag : {expected_tag(version)}")
|
||||
print(f"zip path : {zip_path}")
|
||||
print(f"zip name matches : {'yes' if asset_matches else 'no'}")
|
||||
print(f"expected zip asset : {expected_name}")
|
||||
print(f"SHA-256 : {digest}")
|
||||
print("")
|
||||
print("Updater pin:")
|
||||
print(f"MESH_UPDATE_SHA256={digest}")
|
||||
return 0 if asset_matches else 2
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Helper for ShadowBroker release version/tag/asset consistency."
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
show_parser = subparsers.add_parser("show", help="Show current version, expected tag, and asset")
|
||||
show_parser.set_defaults(func=cmd_show)
|
||||
|
||||
set_version_parser = subparsers.add_parser("set-version", help="Update frontend/package.json version")
|
||||
set_version_parser.add_argument("version", help="Version like 0.9.6")
|
||||
set_version_parser.set_defaults(func=cmd_set_version)
|
||||
|
||||
hash_parser = subparsers.add_parser(
|
||||
"hash", help="Compute SHA-256 for a release ZIP and print the updater pin"
|
||||
)
|
||||
hash_parser.add_argument("zip_path", help="Path to the release ZIP")
|
||||
hash_parser.add_argument(
|
||||
"--version",
|
||||
help="Release version like 0.9.6. Defaults to frontend/package.json version.",
|
||||
)
|
||||
hash_parser.set_defaults(func=cmd_hash)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from services.mesh import mesh_secure_storage
|
||||
from services.mesh.mesh_wormhole_contacts import CONTACTS_FILE
|
||||
from services.mesh.mesh_wormhole_identity import IDENTITY_FILE, _default_identity
|
||||
from services.mesh.mesh_wormhole_persona import PERSONA_FILE, _default_state as _default_persona_state
|
||||
from services.mesh.mesh_wormhole_ratchet import STATE_FILE as RATCHET_FILE
|
||||
|
||||
|
||||
def _load_payloads() -> dict[Path, object]:
|
||||
return {
|
||||
IDENTITY_FILE: mesh_secure_storage.read_secure_json(IDENTITY_FILE, _default_identity),
|
||||
PERSONA_FILE: mesh_secure_storage.read_secure_json(PERSONA_FILE, _default_persona_state),
|
||||
RATCHET_FILE: mesh_secure_storage.read_secure_json(RATCHET_FILE, lambda: {}),
|
||||
CONTACTS_FILE: mesh_secure_storage.read_secure_json(CONTACTS_FILE, lambda: {}),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
payloads = _load_payloads()
|
||||
|
||||
master_key_file = mesh_secure_storage.MASTER_KEY_FILE
|
||||
backup_key_file = master_key_file.with_suffix(master_key_file.suffix + ".bak")
|
||||
if master_key_file.exists():
|
||||
if backup_key_file.exists():
|
||||
backup_key_file.unlink()
|
||||
master_key_file.replace(backup_key_file)
|
||||
|
||||
for path, payload in payloads.items():
|
||||
mesh_secure_storage.write_secure_json(path, payload)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"rewrapped": [str(path.name) for path in payloads.keys()],
|
||||
"master_key": str(master_key_file),
|
||||
"backup_master_key": str(backup_key_file) if backup_key_file.exists() else "",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# scan-secrets.sh — Catch keys, secrets, and credentials before they hit git.
|
||||
#
|
||||
# Usage:
|
||||
# ./backend/scripts/scan-secrets.sh # Scan staged files (pre-commit)
|
||||
# ./backend/scripts/scan-secrets.sh --all # Scan entire working tree
|
||||
# ./backend/scripts/scan-secrets.sh --staged # Scan staged files only (default)
|
||||
#
|
||||
# Exit code: 0 = clean, 1 = secrets found
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
MODE="${1:---staged}"
|
||||
FOUND=0
|
||||
|
||||
# ── Get file list based on mode ─────────────────────────────────────────
|
||||
if [[ "$MODE" == "--all" ]]; then
|
||||
FILELIST=$(mktemp)
|
||||
{ git ls-files 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null; } > "$FILELIST"
|
||||
echo -e "${YELLOW}Scanning entire working tree...${NC}"
|
||||
else
|
||||
FILELIST=$(mktemp)
|
||||
git diff --cached --name-only --diff-filter=ACMR 2>/dev/null > "$FILELIST" || true
|
||||
if [[ ! -s "$FILELIST" ]]; then
|
||||
echo -e "${GREEN}No staged files to scan.${NC}"
|
||||
rm -f "$FILELIST"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${YELLOW}Scanning $(wc -l < "$FILELIST" | tr -d ' ') staged files...${NC}"
|
||||
fi
|
||||
|
||||
# ── Check 1: Dangerous file extensions ──────────────────────────────────
|
||||
KEY_EXT='\.key$|\.pem$|\.p12$|\.pfx$|\.jks$|\.keystore$|\.p8$|\.der$'
|
||||
SECRET_EXT='\.secret$|\.secrets$|\.credential$|\.credentials$'
|
||||
|
||||
HITS=$(grep -iE "$KEY_EXT|$SECRET_EXT" "$FILELIST" 2>/dev/null || true)
|
||||
if [[ -n "$HITS" ]]; then
|
||||
echo -e "\n${RED}BLOCKED: Key/secret files detected:${NC}"
|
||||
echo "$HITS" | while read -r f; do echo -e " ${RED}$f${NC}"; done
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# ── Check 2: Dangerous filenames ────────────────────────────────────────
|
||||
RISKY='id_rsa|id_ed25519|id_ecdsa|private_key|private\.key|secret_key|master\.key'
|
||||
RISKY+='|serviceaccount|gcloud.*\.json|firebase.*\.json|\.htpasswd'
|
||||
|
||||
HITS=$(grep -iE "$RISKY" "$FILELIST" 2>/dev/null || true)
|
||||
if [[ -n "$HITS" ]]; then
|
||||
echo -e "\n${RED}BLOCKED: Risky filenames detected:${NC}"
|
||||
echo "$HITS" | while read -r f; do echo -e " ${RED}$f${NC}"; done
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# ── Check 3: .env files (not .env.example) ──────────────────────────────
|
||||
HITS=$(grep -E '(^|/)\.env(\.[^e].*)?$' "$FILELIST" 2>/dev/null | grep -v '\.example' || true)
|
||||
if [[ -n "$HITS" ]]; then
|
||||
echo -e "\n${RED}BLOCKED: Environment files detected:${NC}"
|
||||
echo "$HITS" | while read -r f; do echo -e " ${RED}$f${NC}"; done
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# ── Check 4: _domain_keys directory (project-specific) ──────────────────
|
||||
HITS=$(grep '_domain_keys/' "$FILELIST" 2>/dev/null || true)
|
||||
if [[ -n "$HITS" ]]; then
|
||||
echo -e "\n${RED}BLOCKED: Domain keys directory detected:${NC}"
|
||||
echo "$HITS" | while read -r f; do echo -e " ${RED}$f${NC}"; done
|
||||
FOUND=1
|
||||
fi
|
||||
|
||||
# ── Check 5: Content scan for embedded secrets (single grep pass) ───────
|
||||
# Build one mega-pattern and run grep once across all files (fast!)
|
||||
SECRET_REGEX='PRIVATE KEY-----|'
|
||||
SECRET_REGEX+='ssh-rsa AAAA[0-9A-Za-z+/]|'
|
||||
SECRET_REGEX+='ssh-ed25519 AAAA[0-9A-Za-z+/]|'
|
||||
SECRET_REGEX+='ghp_[0-9a-zA-Z]{36}|' # GitHub PAT
|
||||
SECRET_REGEX+='github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}|' # GitHub fine-grained
|
||||
SECRET_REGEX+='gho_[0-9a-zA-Z]{36}|' # GitHub OAuth
|
||||
SECRET_REGEX+='sk-[0-9a-zA-Z]{48}|' # OpenAI key
|
||||
SECRET_REGEX+='sk-ant-[0-9a-zA-Z-]{90,}|' # Anthropic key
|
||||
SECRET_REGEX+='AKIA[0-9A-Z]{16}|' # AWS access key
|
||||
SECRET_REGEX+='AIzaSy[0-9A-Za-z_-]{33}|' # Google API key
|
||||
SECRET_REGEX+='xox[bpoas]-[0-9a-zA-Z-]+|' # Slack token
|
||||
SECRET_REGEX+='npm_[0-9a-zA-Z]{36}|' # npm token
|
||||
SECRET_REGEX+='pypi-[0-9a-zA-Z-]{50,}' # PyPI token
|
||||
|
||||
# Filter to text-like files only (skip binaries by extension + skip this script)
|
||||
TEXT_FILES=$(grep -ivE '\.(png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot|pbf|zip|tar|gz|db|sqlite|xlsx|pdf|mp[34]|wav|ogg|webm|webp|avif)$' "$FILELIST" | grep -v 'scan-secrets\.sh$' || true)
|
||||
|
||||
if [[ -n "$TEXT_FILES" ]]; then
|
||||
# Use grep with file list, skip missing/binary, limit output
|
||||
CONTENT_HITS=$(echo "$TEXT_FILES" | xargs grep -lE "$SECRET_REGEX" 2>/dev/null || true)
|
||||
if [[ -n "$CONTENT_HITS" ]]; then
|
||||
echo -e "\n${RED}BLOCKED: Embedded secrets/tokens found in:${NC}"
|
||||
echo "$CONTENT_HITS" | while read -r f; do
|
||||
echo -e " ${RED}$f${NC}"
|
||||
# Show first matching line for context
|
||||
grep -nE "$SECRET_REGEX" "$f" 2>/dev/null | head -2 | while read -r line; do
|
||||
echo -e " ${YELLOW}$line${NC}"
|
||||
done
|
||||
done
|
||||
FOUND=1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$FILELIST"
|
||||
|
||||
# ── Result ──────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ $FOUND -eq 1 ]]; then
|
||||
echo -e "${RED}Secret scan FAILED. Add these to .gitignore or remove them before committing.${NC}"
|
||||
echo -e "${YELLOW}If intentional (e.g. test fixtures): git commit --no-verify${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}Secret scan passed. No keys or secrets detected.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
param(
|
||||
[string]$Python = "python"
|
||||
)
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$venvPath = Join-Path $repoRoot "venv"
|
||||
& $Python -m venv $venvPath
|
||||
|
||||
$pip = Join-Path $venvPath "Scripts\pip.exe"
|
||||
& $pip install -r (Join-Path $repoRoot "requirements-dev.txt")
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VENV_DIR="$REPO_ROOT/venv"
|
||||
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
"$VENV_DIR/bin/pip" install -r "$REPO_ROOT/requirements-dev.txt"
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"code" : "dataset.missing",
|
||||
"error" : true,
|
||||
"message" : "Not found",
|
||||
"data" : {
|
||||
"id" : "xqwu-hwdm"
|
||||
}
|
||||
}
|
||||
+383
-137
@@ -16,18 +16,19 @@ logger = logging.getLogger(__name__)
|
||||
AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
|
||||
API_KEY = os.environ.get("AIS_API_KEY", "")
|
||||
|
||||
|
||||
# AIS vessel type code classification
|
||||
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
|
||||
def classify_vessel(ais_type: int, mmsi: int) -> str:
|
||||
"""Classify a vessel by its AIS type code into a rendering category."""
|
||||
if 80 <= ais_type <= 89:
|
||||
return "tanker" # Oil/Chemical/Gas tankers → RED
|
||||
return "tanker" # Oil/Chemical/Gas tankers → RED
|
||||
if 70 <= ais_type <= 79:
|
||||
return "cargo" # Cargo ships, container vessels → RED
|
||||
return "cargo" # Cargo ships, container vessels → RED
|
||||
if 60 <= ais_type <= 69:
|
||||
return "passenger" # Cruise ships, ferries → GRAY
|
||||
return "passenger" # Cruise ships, ferries → GRAY
|
||||
if ais_type in (36, 37):
|
||||
return "yacht" # Sailing/Pleasure craft → DARK BLUE
|
||||
return "yacht" # Sailing/Pleasure craft → DARK BLUE
|
||||
if ais_type == 35:
|
||||
return "military_vessel" # Military → YELLOW
|
||||
# MMSI-based military detection: military MMSIs often start with certain prefixes
|
||||
@@ -35,87 +36,286 @@ def classify_vessel(ais_type: int, mmsi: int) -> str:
|
||||
if mmsi_str.startswith("3380") or mmsi_str.startswith("3381"):
|
||||
return "military_vessel" # US Navy
|
||||
if ais_type in (30, 31, 32, 33, 34):
|
||||
return "other" # Fishing, towing, dredging, diving, etc.
|
||||
return "other" # Fishing, towing, dredging, diving, etc.
|
||||
if ais_type in (50, 51, 52, 53, 54, 55, 56, 57, 58, 59):
|
||||
return "other" # Pilot, SAR, tug, port tender, etc.
|
||||
return "unknown" # Not yet classified — will update when ShipStaticData arrives
|
||||
return "other" # Pilot, SAR, tug, port tender, etc.
|
||||
return "unknown" # Not yet classified — will update when ShipStaticData arrives
|
||||
|
||||
|
||||
# MMSI Maritime Identification Digit (MID) → Country mapping
|
||||
# First 3 digits of MMSI (for 9-digit MMSIs) encode the flag state
|
||||
MID_COUNTRY = {
|
||||
201: "Albania", 202: "Andorra", 203: "Austria", 204: "Portugal", 205: "Belgium",
|
||||
206: "Belarus", 207: "Bulgaria", 208: "Vatican", 209: "Cyprus", 210: "Cyprus",
|
||||
211: "Germany", 212: "Cyprus", 213: "Georgia", 214: "Moldova", 215: "Malta",
|
||||
216: "Armenia", 218: "Germany", 219: "Denmark", 220: "Denmark", 224: "Spain",
|
||||
225: "Spain", 226: "France", 227: "France", 228: "France", 229: "Malta",
|
||||
230: "Finland", 231: "Faroe Islands", 232: "United Kingdom", 233: "United Kingdom",
|
||||
234: "United Kingdom", 235: "United Kingdom", 236: "Gibraltar", 237: "Greece",
|
||||
238: "Croatia", 239: "Greece", 240: "Greece", 241: "Greece", 242: "Morocco",
|
||||
243: "Hungary", 244: "Netherlands", 245: "Netherlands", 246: "Netherlands",
|
||||
247: "Italy", 248: "Malta", 249: "Malta", 250: "Ireland", 251: "Iceland",
|
||||
252: "Liechtenstein", 253: "Luxembourg", 254: "Monaco", 255: "Portugal",
|
||||
256: "Malta", 257: "Norway", 258: "Norway", 259: "Norway", 261: "Poland",
|
||||
263: "Portugal", 264: "Romania", 265: "Sweden", 266: "Sweden", 267: "Slovakia",
|
||||
268: "San Marino", 269: "Switzerland", 270: "Czech Republic", 271: "Turkey",
|
||||
272: "Ukraine", 273: "Russia", 274: "North Macedonia", 275: "Latvia",
|
||||
276: "Estonia", 277: "Lithuania", 278: "Slovenia",
|
||||
301: "Anguilla", 303: "Alaska", 304: "Antigua", 305: "Antigua",
|
||||
306: "Netherlands Antilles", 307: "Aruba", 308: "Bahamas", 309: "Bahamas",
|
||||
310: "Bermuda", 311: "Bahamas", 312: "Belize", 314: "Barbados", 316: "Canada",
|
||||
319: "Cayman Islands", 321: "Costa Rica", 323: "Cuba", 325: "Dominica",
|
||||
327: "Dominican Republic", 329: "Guadeloupe", 330: "Grenada", 331: "Greenland",
|
||||
332: "Guatemala", 334: "Honduras", 336: "Haiti", 338: "United States",
|
||||
339: "Jamaica", 341: "Saint Kitts", 343: "Saint Lucia", 345: "Mexico",
|
||||
347: "Martinique", 348: "Montserrat", 350: "Nicaragua", 351: "Panama",
|
||||
352: "Panama", 353: "Panama", 354: "Panama", 355: "Panama",
|
||||
356: "Panama", 357: "Panama", 358: "Puerto Rico", 359: "El Salvador",
|
||||
361: "Saint Pierre", 362: "Trinidad", 364: "Turks and Caicos",
|
||||
366: "United States", 367: "United States", 368: "United States", 369: "United States",
|
||||
370: "Panama", 371: "Panama", 372: "Panama", 373: "Panama",
|
||||
374: "Panama", 375: "Saint Vincent", 376: "Saint Vincent", 377: "Saint Vincent",
|
||||
378: "British Virgin Islands", 379: "US Virgin Islands",
|
||||
401: "Afghanistan", 403: "Saudi Arabia", 405: "Bangladesh", 408: "Bahrain",
|
||||
410: "Bhutan", 412: "China", 413: "China", 414: "China",
|
||||
416: "Taiwan", 417: "Sri Lanka", 419: "India", 422: "Iran",
|
||||
423: "Azerbaijan", 425: "Iraq", 428: "Israel", 431: "Japan",
|
||||
432: "Japan", 434: "Turkmenistan", 436: "Kazakhstan", 437: "Uzbekistan",
|
||||
438: "Jordan", 440: "South Korea", 441: "South Korea", 443: "Palestine",
|
||||
445: "North Korea", 447: "Kuwait", 450: "Lebanon", 451: "Kyrgyzstan",
|
||||
453: "Macao", 455: "Maldives", 457: "Mongolia", 459: "Nepal",
|
||||
461: "Oman", 463: "Pakistan", 466: "Qatar", 468: "Syria",
|
||||
470: "UAE", 472: "Tajikistan", 473: "Yemen", 475: "Tonga",
|
||||
477: "Hong Kong", 478: "Bosnia",
|
||||
501: "Antarctica", 503: "Australia", 506: "Myanmar",
|
||||
508: "Brunei", 510: "Micronesia", 511: "Palau", 512: "New Zealand",
|
||||
514: "Cambodia", 515: "Cambodia", 516: "Christmas Island",
|
||||
518: "Cook Islands", 520: "Fiji", 523: "Cocos Islands",
|
||||
525: "Indonesia", 529: "Kiribati", 531: "Laos", 533: "Malaysia",
|
||||
536: "Northern Mariana Islands", 538: "Marshall Islands",
|
||||
540: "New Caledonia", 542: "Niue", 544: "Nauru", 546: "French Polynesia",
|
||||
548: "Philippines", 553: "Papua New Guinea", 555: "Pitcairn",
|
||||
557: "Solomon Islands", 559: "American Samoa", 561: "Samoa",
|
||||
563: "Singapore", 564: "Singapore", 565: "Singapore", 566: "Singapore",
|
||||
567: "Thailand", 570: "Tonga", 572: "Tuvalu", 574: "Vietnam",
|
||||
576: "Vanuatu", 577: "Vanuatu", 578: "Wallis and Futuna",
|
||||
601: "South Africa", 603: "Angola", 605: "Algeria", 607: "Benin",
|
||||
609: "Botswana", 610: "Burundi", 611: "Cameroon", 612: "Cape Verde",
|
||||
613: "Central African Republic", 615: "Congo", 616: "Comoros",
|
||||
617: "DR Congo", 618: "Ivory Coast", 619: "Djibouti",
|
||||
620: "Egypt", 621: "Equatorial Guinea", 622: "Ethiopia",
|
||||
624: "Eritrea", 625: "Gabon", 626: "Gambia", 627: "Ghana",
|
||||
629: "Guinea", 630: "Guinea-Bissau", 631: "Kenya", 632: "Lesotho",
|
||||
633: "Liberia", 634: "Liberia", 635: "Liberia", 636: "Liberia",
|
||||
637: "Libya", 642: "Madagascar", 644: "Malawi", 645: "Mali",
|
||||
647: "Mauritania", 649: "Mauritius", 650: "Mozambique",
|
||||
654: "Namibia", 655: "Niger", 656: "Nigeria", 657: "Guinea",
|
||||
659: "Rwanda", 660: "Senegal", 661: "Sierra Leone",
|
||||
662: "Somalia", 663: "South Africa", 664: "Sudan",
|
||||
667: "Tanzania", 668: "Togo", 669: "Tunisia", 670: "Uganda",
|
||||
671: "Egypt", 672: "Tanzania", 674: "Zambia", 675: "Zimbabwe",
|
||||
676: "Comoros", 677: "Tanzania",
|
||||
201: "Albania",
|
||||
202: "Andorra",
|
||||
203: "Austria",
|
||||
204: "Portugal",
|
||||
205: "Belgium",
|
||||
206: "Belarus",
|
||||
207: "Bulgaria",
|
||||
208: "Vatican",
|
||||
209: "Cyprus",
|
||||
210: "Cyprus",
|
||||
211: "Germany",
|
||||
212: "Cyprus",
|
||||
213: "Georgia",
|
||||
214: "Moldova",
|
||||
215: "Malta",
|
||||
216: "Armenia",
|
||||
218: "Germany",
|
||||
219: "Denmark",
|
||||
220: "Denmark",
|
||||
224: "Spain",
|
||||
225: "Spain",
|
||||
226: "France",
|
||||
227: "France",
|
||||
228: "France",
|
||||
229: "Malta",
|
||||
230: "Finland",
|
||||
231: "Faroe Islands",
|
||||
232: "United Kingdom",
|
||||
233: "United Kingdom",
|
||||
234: "United Kingdom",
|
||||
235: "United Kingdom",
|
||||
236: "Gibraltar",
|
||||
237: "Greece",
|
||||
238: "Croatia",
|
||||
239: "Greece",
|
||||
240: "Greece",
|
||||
241: "Greece",
|
||||
242: "Morocco",
|
||||
243: "Hungary",
|
||||
244: "Netherlands",
|
||||
245: "Netherlands",
|
||||
246: "Netherlands",
|
||||
247: "Italy",
|
||||
248: "Malta",
|
||||
249: "Malta",
|
||||
250: "Ireland",
|
||||
251: "Iceland",
|
||||
252: "Liechtenstein",
|
||||
253: "Luxembourg",
|
||||
254: "Monaco",
|
||||
255: "Portugal",
|
||||
256: "Malta",
|
||||
257: "Norway",
|
||||
258: "Norway",
|
||||
259: "Norway",
|
||||
261: "Poland",
|
||||
263: "Portugal",
|
||||
264: "Romania",
|
||||
265: "Sweden",
|
||||
266: "Sweden",
|
||||
267: "Slovakia",
|
||||
268: "San Marino",
|
||||
269: "Switzerland",
|
||||
270: "Czech Republic",
|
||||
271: "Turkey",
|
||||
272: "Ukraine",
|
||||
273: "Russia",
|
||||
274: "North Macedonia",
|
||||
275: "Latvia",
|
||||
276: "Estonia",
|
||||
277: "Lithuania",
|
||||
278: "Slovenia",
|
||||
301: "Anguilla",
|
||||
303: "Alaska",
|
||||
304: "Antigua",
|
||||
305: "Antigua",
|
||||
306: "Netherlands Antilles",
|
||||
307: "Aruba",
|
||||
308: "Bahamas",
|
||||
309: "Bahamas",
|
||||
310: "Bermuda",
|
||||
311: "Bahamas",
|
||||
312: "Belize",
|
||||
314: "Barbados",
|
||||
316: "Canada",
|
||||
319: "Cayman Islands",
|
||||
321: "Costa Rica",
|
||||
323: "Cuba",
|
||||
325: "Dominica",
|
||||
327: "Dominican Republic",
|
||||
329: "Guadeloupe",
|
||||
330: "Grenada",
|
||||
331: "Greenland",
|
||||
332: "Guatemala",
|
||||
334: "Honduras",
|
||||
336: "Haiti",
|
||||
338: "United States",
|
||||
339: "Jamaica",
|
||||
341: "Saint Kitts",
|
||||
343: "Saint Lucia",
|
||||
345: "Mexico",
|
||||
347: "Martinique",
|
||||
348: "Montserrat",
|
||||
350: "Nicaragua",
|
||||
351: "Panama",
|
||||
352: "Panama",
|
||||
353: "Panama",
|
||||
354: "Panama",
|
||||
355: "Panama",
|
||||
356: "Panama",
|
||||
357: "Panama",
|
||||
358: "Puerto Rico",
|
||||
359: "El Salvador",
|
||||
361: "Saint Pierre",
|
||||
362: "Trinidad",
|
||||
364: "Turks and Caicos",
|
||||
366: "United States",
|
||||
367: "United States",
|
||||
368: "United States",
|
||||
369: "United States",
|
||||
370: "Panama",
|
||||
371: "Panama",
|
||||
372: "Panama",
|
||||
373: "Panama",
|
||||
374: "Panama",
|
||||
375: "Saint Vincent",
|
||||
376: "Saint Vincent",
|
||||
377: "Saint Vincent",
|
||||
378: "British Virgin Islands",
|
||||
379: "US Virgin Islands",
|
||||
401: "Afghanistan",
|
||||
403: "Saudi Arabia",
|
||||
405: "Bangladesh",
|
||||
408: "Bahrain",
|
||||
410: "Bhutan",
|
||||
412: "China",
|
||||
413: "China",
|
||||
414: "China",
|
||||
416: "Taiwan",
|
||||
417: "Sri Lanka",
|
||||
419: "India",
|
||||
422: "Iran",
|
||||
423: "Azerbaijan",
|
||||
425: "Iraq",
|
||||
428: "Israel",
|
||||
431: "Japan",
|
||||
432: "Japan",
|
||||
434: "Turkmenistan",
|
||||
436: "Kazakhstan",
|
||||
437: "Uzbekistan",
|
||||
438: "Jordan",
|
||||
440: "South Korea",
|
||||
441: "South Korea",
|
||||
443: "Palestine",
|
||||
445: "North Korea",
|
||||
447: "Kuwait",
|
||||
450: "Lebanon",
|
||||
451: "Kyrgyzstan",
|
||||
453: "Macao",
|
||||
455: "Maldives",
|
||||
457: "Mongolia",
|
||||
459: "Nepal",
|
||||
461: "Oman",
|
||||
463: "Pakistan",
|
||||
466: "Qatar",
|
||||
468: "Syria",
|
||||
470: "UAE",
|
||||
472: "Tajikistan",
|
||||
473: "Yemen",
|
||||
475: "Tonga",
|
||||
477: "Hong Kong",
|
||||
478: "Bosnia",
|
||||
501: "Antarctica",
|
||||
503: "Australia",
|
||||
506: "Myanmar",
|
||||
508: "Brunei",
|
||||
510: "Micronesia",
|
||||
511: "Palau",
|
||||
512: "New Zealand",
|
||||
514: "Cambodia",
|
||||
515: "Cambodia",
|
||||
516: "Christmas Island",
|
||||
518: "Cook Islands",
|
||||
520: "Fiji",
|
||||
523: "Cocos Islands",
|
||||
525: "Indonesia",
|
||||
529: "Kiribati",
|
||||
531: "Laos",
|
||||
533: "Malaysia",
|
||||
536: "Northern Mariana Islands",
|
||||
538: "Marshall Islands",
|
||||
540: "New Caledonia",
|
||||
542: "Niue",
|
||||
544: "Nauru",
|
||||
546: "French Polynesia",
|
||||
548: "Philippines",
|
||||
553: "Papua New Guinea",
|
||||
555: "Pitcairn",
|
||||
557: "Solomon Islands",
|
||||
559: "American Samoa",
|
||||
561: "Samoa",
|
||||
563: "Singapore",
|
||||
564: "Singapore",
|
||||
565: "Singapore",
|
||||
566: "Singapore",
|
||||
567: "Thailand",
|
||||
570: "Tonga",
|
||||
572: "Tuvalu",
|
||||
574: "Vietnam",
|
||||
576: "Vanuatu",
|
||||
577: "Vanuatu",
|
||||
578: "Wallis and Futuna",
|
||||
601: "South Africa",
|
||||
603: "Angola",
|
||||
605: "Algeria",
|
||||
607: "Benin",
|
||||
609: "Botswana",
|
||||
610: "Burundi",
|
||||
611: "Cameroon",
|
||||
612: "Cape Verde",
|
||||
613: "Central African Republic",
|
||||
615: "Congo",
|
||||
616: "Comoros",
|
||||
617: "DR Congo",
|
||||
618: "Ivory Coast",
|
||||
619: "Djibouti",
|
||||
620: "Egypt",
|
||||
621: "Equatorial Guinea",
|
||||
622: "Ethiopia",
|
||||
624: "Eritrea",
|
||||
625: "Gabon",
|
||||
626: "Gambia",
|
||||
627: "Ghana",
|
||||
629: "Guinea",
|
||||
630: "Guinea-Bissau",
|
||||
631: "Kenya",
|
||||
632: "Lesotho",
|
||||
633: "Liberia",
|
||||
634: "Liberia",
|
||||
635: "Liberia",
|
||||
636: "Liberia",
|
||||
637: "Libya",
|
||||
642: "Madagascar",
|
||||
644: "Malawi",
|
||||
645: "Mali",
|
||||
647: "Mauritania",
|
||||
649: "Mauritius",
|
||||
650: "Mozambique",
|
||||
654: "Namibia",
|
||||
655: "Niger",
|
||||
656: "Nigeria",
|
||||
657: "Guinea",
|
||||
659: "Rwanda",
|
||||
660: "Senegal",
|
||||
661: "Sierra Leone",
|
||||
662: "Somalia",
|
||||
663: "South Africa",
|
||||
664: "Sudan",
|
||||
667: "Tanzania",
|
||||
668: "Togo",
|
||||
669: "Tunisia",
|
||||
670: "Uganda",
|
||||
671: "Egypt",
|
||||
672: "Tanzania",
|
||||
674: "Zambia",
|
||||
675: "Zimbabwe",
|
||||
676: "Comoros",
|
||||
677: "Tanzania",
|
||||
}
|
||||
|
||||
|
||||
def get_country_from_mmsi(mmsi: int) -> str:
|
||||
"""Look up flag state from MMSI Maritime Identification Digit."""
|
||||
mmsi_str = str(mmsi)
|
||||
@@ -130,8 +330,10 @@ _vessels: dict[int, dict] = {}
|
||||
_vessels_lock = threading.Lock()
|
||||
_ws_thread: threading.Thread | None = None
|
||||
_ws_running = False
|
||||
_proxy_process = None
|
||||
|
||||
import os
|
||||
|
||||
CACHE_FILE = os.path.join(os.path.dirname(__file__), "ais_cache.json")
|
||||
|
||||
|
||||
@@ -141,7 +343,7 @@ def _save_cache():
|
||||
with _vessels_lock:
|
||||
# Convert int keys to strings for JSON
|
||||
data = {str(k): v for k, v in _vessels.items()}
|
||||
with open(CACHE_FILE, 'w') as f:
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
json.dump(data, f)
|
||||
logger.info(f"AIS cache saved: {len(data)} vessels")
|
||||
except (IOError, OSError) as e:
|
||||
@@ -154,7 +356,7 @@ def _load_cache():
|
||||
if not os.path.exists(CACHE_FILE):
|
||||
return
|
||||
try:
|
||||
with open(CACHE_FILE, 'r') as f:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
now = time.time()
|
||||
stale_cutoff = now - 3600 # Accept vessels up to 1 hour old on restart
|
||||
@@ -169,41 +371,51 @@ def _load_cache():
|
||||
logger.error(f"Failed to load AIS cache: {e}")
|
||||
|
||||
|
||||
def get_ais_vessels() -> list[dict]:
|
||||
"""Return a snapshot of tracked AIS vessels, excluding 'other' type, pruning stale."""
|
||||
def prune_stale_vessels():
|
||||
"""Remove vessels not updated in the last 15 minutes. Safe to call from a scheduler."""
|
||||
now = time.time()
|
||||
stale_cutoff = now - 900 # 15 minutes
|
||||
|
||||
stale_cutoff = now - 900
|
||||
with _vessels_lock:
|
||||
# Prune stale vessels
|
||||
stale_keys = [k for k, v in _vessels.items() if v.get("_updated", 0) < stale_cutoff]
|
||||
for k in stale_keys:
|
||||
del _vessels[k]
|
||||
|
||||
if stale_keys:
|
||||
logger.info(f"AIS pruned {len(stale_keys)} stale vessels")
|
||||
|
||||
|
||||
def get_ais_vessels() -> list[dict]:
|
||||
"""Return a snapshot of tracked AIS vessels, pruning stale."""
|
||||
prune_stale_vessels()
|
||||
|
||||
with _vessels_lock:
|
||||
result = []
|
||||
for mmsi, v in _vessels.items():
|
||||
v_type = v.get("type", "unknown")
|
||||
# Skip 'other' vessels (fishing, tug, pilot, etc.) to reduce load
|
||||
if v_type == "other":
|
||||
continue
|
||||
# Skip vessels without valid position
|
||||
if not v.get("lat") or not v.get("lng"):
|
||||
continue
|
||||
|
||||
result.append({
|
||||
"mmsi": mmsi,
|
||||
"name": v.get("name", "UNKNOWN"),
|
||||
"type": v_type,
|
||||
"lat": round(v.get("lat", 0), 5),
|
||||
"lng": round(v.get("lng", 0), 5),
|
||||
"heading": v.get("heading", 0),
|
||||
"sog": round(v.get("sog", 0), 1),
|
||||
"cog": round(v.get("cog", 0), 1),
|
||||
"callsign": v.get("callsign", ""),
|
||||
"destination": v.get("destination", "") or "UNKNOWN",
|
||||
"imo": v.get("imo", 0),
|
||||
"country": get_country_from_mmsi(mmsi),
|
||||
})
|
||||
|
||||
# Sanitize speed: AIS 102.3 kn = "speed not available"
|
||||
sog = v.get("sog", 0)
|
||||
if sog >= 102.2:
|
||||
sog = 0
|
||||
|
||||
result.append(
|
||||
{
|
||||
"mmsi": mmsi,
|
||||
"name": v.get("name", "UNKNOWN"),
|
||||
"type": v_type,
|
||||
"lat": round(v.get("lat", 0), 5),
|
||||
"lng": round(v.get("lng", 0), 5),
|
||||
"heading": v.get("heading", 0),
|
||||
"sog": round(sog, 1),
|
||||
"cog": round(v.get("cog", 0), 1),
|
||||
"callsign": v.get("callsign", ""),
|
||||
"destination": v.get("destination", "") or "UNKNOWN",
|
||||
"imo": v.get("imo", 0),
|
||||
"country": get_country_from_mmsi(mmsi),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -228,7 +440,9 @@ def ingest_ais_catcher(msgs: list[dict]) -> int:
|
||||
if lat is not None and lon is not None and lat != 91.0 and lon != 181.0:
|
||||
vessel["lat"] = lat
|
||||
vessel["lng"] = lon
|
||||
vessel["sog"] = msg.get("speed", 0)
|
||||
# AIS raw value 1023 (102.3 kn) = "speed not available"
|
||||
raw_speed = msg.get("speed", 0)
|
||||
vessel["sog"] = 0 if raw_speed >= 102.2 else raw_speed
|
||||
vessel["cog"] = msg.get("course", 0)
|
||||
heading = msg.get("heading", 511)
|
||||
vessel["heading"] = heading if heading != 511 else vessel.get("cog", 0)
|
||||
@@ -276,31 +490,37 @@ def _ais_stream_loop():
|
||||
while _ws_running:
|
||||
try:
|
||||
logger.info("Starting Node.js AIS Stream Proxy...")
|
||||
proxy_env = os.environ.copy()
|
||||
proxy_env["AIS_API_KEY"] = API_KEY
|
||||
process = subprocess.Popen(
|
||||
['node', proxy_script, API_KEY],
|
||||
["node", proxy_script],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
bufsize=1,
|
||||
env=proxy_env,
|
||||
)
|
||||
_proxy_process = process
|
||||
|
||||
with _vessels_lock:
|
||||
_proxy_process = process
|
||||
|
||||
# Drain stderr in a background thread to prevent deadlock
|
||||
import threading
|
||||
|
||||
def _drain_stderr():
|
||||
for errline in iter(process.stderr.readline, ''):
|
||||
for errline in iter(process.stderr.readline, ""):
|
||||
errline = errline.strip()
|
||||
if errline:
|
||||
logger.warning(f"AIS proxy stderr: {errline}")
|
||||
|
||||
threading.Thread(target=_drain_stderr, daemon=True).start()
|
||||
|
||||
|
||||
logger.info("AIS Stream proxy started — receiving vessel data")
|
||||
|
||||
|
||||
msg_count = 0
|
||||
ok_streak = 0 # Track consecutive successful messages for backoff reset
|
||||
last_log_time = time.time()
|
||||
for raw_msg in iter(process.stdout.readline, ''):
|
||||
for raw_msg in iter(process.stdout.readline, ""):
|
||||
if not _ws_running:
|
||||
process.terminate()
|
||||
break
|
||||
@@ -346,14 +566,18 @@ def _ais_stream_loop():
|
||||
with _vessels_lock:
|
||||
vessel["lat"] = lat
|
||||
vessel["lng"] = lng
|
||||
vessel["sog"] = report.get("Sog", 0)
|
||||
# AIS raw value 1023 (102.3 kn) = "speed not available"
|
||||
raw_sog = report.get("Sog", 0)
|
||||
vessel["sog"] = 0 if raw_sog >= 102.2 else raw_sog
|
||||
vessel["cog"] = report.get("Cog", 0)
|
||||
heading = report.get("TrueHeading", 511)
|
||||
vessel["heading"] = heading if heading != 511 else report.get("Cog", 0)
|
||||
vessel["_updated"] = time.time()
|
||||
# Use metadata name if we don't have one yet
|
||||
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
||||
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||
vessel["name"] = (
|
||||
metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||
)
|
||||
|
||||
# Update static data from ShipStaticData
|
||||
elif msg_type == "ShipStaticData":
|
||||
@@ -361,10 +585,14 @@ def _ais_stream_loop():
|
||||
ais_type = static.get("Type", 0)
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
||||
vessel["name"] = (
|
||||
static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")
|
||||
).strip() or "UNKNOWN"
|
||||
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
||||
vessel["imo"] = static.get("ImoNumber", 0)
|
||||
vessel["destination"] = (static.get("Destination", "") or "").strip().replace("@", "")
|
||||
vessel["destination"] = (
|
||||
(static.get("Destination", "") or "").strip().replace("@", "")
|
||||
)
|
||||
vessel["ais_type_code"] = ais_type
|
||||
vessel["type"] = classify_vessel(ais_type, mmsi)
|
||||
vessel["_updated"] = time.time()
|
||||
@@ -382,7 +610,9 @@ def _ais_stream_loop():
|
||||
if now - last_log_time >= 60:
|
||||
with _vessels_lock:
|
||||
count = len(_vessels)
|
||||
logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels")
|
||||
logger.info(
|
||||
f"AIS Stream: processed {msg_count} messages, tracking {count} vessels"
|
||||
)
|
||||
_save_cache()
|
||||
last_log_time = now
|
||||
|
||||
@@ -397,23 +627,34 @@ def _ais_stream_loop():
|
||||
|
||||
def _run_ais_loop():
|
||||
"""Thread target: run the AIS loop."""
|
||||
global _ws_running, _ws_thread, _proxy_process
|
||||
try:
|
||||
_ais_stream_loop()
|
||||
except Exception as e:
|
||||
logger.error(f"AIS Stream thread crashed: {e}")
|
||||
finally:
|
||||
with _vessels_lock:
|
||||
_ws_running = False
|
||||
_ws_thread = None
|
||||
_proxy_process = None
|
||||
|
||||
|
||||
def start_ais_stream():
|
||||
"""Start the AIS WebSocket stream in a background thread."""
|
||||
global _ws_thread, _ws_running
|
||||
if _ws_thread and _ws_thread.is_alive():
|
||||
with _vessels_lock:
|
||||
if _ws_running:
|
||||
logger.info("AIS Stream already running")
|
||||
return
|
||||
_ws_running = True
|
||||
existing_thread = _ws_thread
|
||||
if existing_thread and existing_thread.is_alive():
|
||||
logger.info("AIS Stream already running")
|
||||
return
|
||||
|
||||
|
||||
# Load cached vessel data from disk
|
||||
_load_cache()
|
||||
|
||||
_ws_running = True
|
||||
|
||||
_ws_thread = threading.Thread(target=_run_ais_loop, daemon=True, name="ais-stream")
|
||||
_ws_thread.start()
|
||||
logger.info("AIS Stream background thread started")
|
||||
@@ -421,31 +662,36 @@ def start_ais_stream():
|
||||
|
||||
def stop_ais_stream():
|
||||
"""Stop the AIS WebSocket stream and save cache."""
|
||||
global _ws_running, _proxy_process
|
||||
_ws_running = False
|
||||
|
||||
if _proxy_process and _proxy_process.stdin:
|
||||
global _ws_running, _ws_thread, _proxy_process
|
||||
with _vessels_lock:
|
||||
_ws_running = False
|
||||
_ws_thread = None
|
||||
proc = _proxy_process
|
||||
_proxy_process = None
|
||||
|
||||
if proc and proc.stdin:
|
||||
try:
|
||||
_proxy_process.stdin.close()
|
||||
proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_save_cache() # Save on shutdown
|
||||
logger.info("AIS Stream stopping...")
|
||||
|
||||
|
||||
def update_ais_bbox(south: float, west: float, north: float, east: float):
|
||||
"""Dynamically update the AIS stream bounding box via proxy stdin."""
|
||||
global _proxy_process
|
||||
if not _proxy_process or not _proxy_process.stdin:
|
||||
with _vessels_lock:
|
||||
proc = _proxy_process
|
||||
if not proc or not proc.stdin:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
cmd = json.dumps({
|
||||
"type": "update_bbox",
|
||||
"bboxes": [[[south, west], [north, east]]]
|
||||
})
|
||||
_proxy_process.stdin.write(cmd + "\n")
|
||||
_proxy_process.stdin.flush()
|
||||
logger.info(f"Updated AIS bounding box to: S:{south:.2f} W:{west:.2f} N:{north:.2f} E:{east:.2f}")
|
||||
cmd = json.dumps({"type": "update_bbox", "bboxes": [[[south, west], [north, east]]]})
|
||||
proc.stdin.write(cmd + "\n")
|
||||
proc.stdin.flush()
|
||||
logger.info(
|
||||
f"Updated AIS bounding box to: S:{south:.2f} W:{west:.2f} N:{north:.2f} E:{east:.2f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update AIS bbox: {e}")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
API Settings management — serves the API key registry and allows updates.
|
||||
Keys are stored in the backend .env file and loaded via python-dotenv.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -121,6 +122,24 @@ API_REGISTRY = [
|
||||
"url": "https://openmhz.com/",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"id": "shodan_api_key",
|
||||
"env_key": "SHODAN_API_KEY",
|
||||
"name": "Shodan — Operator API Key",
|
||||
"description": "Paid Shodan API key for local operator-driven searches and temporary map overlays. Results are attributed to Shodan and are not merged into ShadowBroker core feeds.",
|
||||
"category": "Reconnaissance",
|
||||
"url": "https://account.shodan.io/billing",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"id": "finnhub_api_key",
|
||||
"env_key": "FINNHUB_API_KEY",
|
||||
"name": "Finnhub — API Key",
|
||||
"description": "Free market data API. Defense stock quotes, congressional trading disclosures, and insider transactions. 60 calls/min free tier.",
|
||||
"category": "Financial",
|
||||
"url": "https://finnhub.io/register",
|
||||
"required": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -160,7 +179,7 @@ def update_api_key(env_key: str, new_value: str) -> bool:
|
||||
valid_keys = {api["env_key"] for api in API_REGISTRY if api.get("env_key")}
|
||||
if env_key not in valid_keys:
|
||||
return False
|
||||
|
||||
|
||||
if not isinstance(new_value, str):
|
||||
return False
|
||||
if "\n" in new_value or "\r" in new_value:
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -34,108 +35,127 @@ CARRIER_REGISTRY: Dict[str, dict] = {
|
||||
"name": "USS Nimitz (CVN-68)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
|
||||
"homeport": "Bremerton, WA",
|
||||
"homeport_lat": 47.5535, "homeport_lng": -122.6400,
|
||||
"fallback_lat": 47.5535, "fallback_lng": -122.6400,
|
||||
"homeport_lat": 47.5535,
|
||||
"homeport_lng": -122.6400,
|
||||
"fallback_lat": 47.5535,
|
||||
"fallback_lng": -122.6400,
|
||||
"fallback_heading": 90,
|
||||
"fallback_desc": "Bremerton, WA (Maintenance)"
|
||||
"fallback_desc": "Bremerton, WA (Maintenance)",
|
||||
},
|
||||
"CVN-76": {
|
||||
"name": "USS Ronald Reagan (CVN-76)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
|
||||
"homeport": "Bremerton, WA",
|
||||
"homeport_lat": 47.5580, "homeport_lng": -122.6360,
|
||||
"fallback_lat": 47.5580, "fallback_lng": -122.6360,
|
||||
"homeport_lat": 47.5580,
|
||||
"homeport_lng": -122.6360,
|
||||
"fallback_lat": 47.5580,
|
||||
"fallback_lng": -122.6360,
|
||||
"fallback_heading": 90,
|
||||
"fallback_desc": "Bremerton, WA (Decommissioning)"
|
||||
"fallback_desc": "Bremerton, WA (Decommissioning)",
|
||||
},
|
||||
|
||||
# --- Norfolk, VA (Naval Station Norfolk) ---
|
||||
# Piers run N-S along Willoughby Bay; each carrier gets a distinct berth
|
||||
"CVN-69": {
|
||||
"name": "USS Dwight D. Eisenhower (CVN-69)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9465, "homeport_lng": -76.3265,
|
||||
"fallback_lat": 36.9465, "fallback_lng": -76.3265,
|
||||
"homeport_lat": 36.9465,
|
||||
"homeport_lng": -76.3265,
|
||||
"fallback_lat": 36.9465,
|
||||
"fallback_lng": -76.3265,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Norfolk, VA (Post-deployment maintenance)"
|
||||
"fallback_desc": "Norfolk, VA (Post-deployment maintenance)",
|
||||
},
|
||||
"CVN-78": {
|
||||
"name": "USS Gerald R. Ford (CVN-78)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9505, "homeport_lng": -76.3250,
|
||||
"fallback_lat": 18.0, "fallback_lng": 39.5,
|
||||
"homeport_lat": 36.9505,
|
||||
"homeport_lng": -76.3250,
|
||||
"fallback_lat": 18.0,
|
||||
"fallback_lng": 39.5,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)"
|
||||
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)",
|
||||
},
|
||||
"CVN-74": {
|
||||
"name": "USS John C. Stennis (CVN-74)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9540, "homeport_lng": -76.3235,
|
||||
"fallback_lat": 36.98, "fallback_lng": -76.43,
|
||||
"homeport_lat": 36.9540,
|
||||
"homeport_lng": -76.3235,
|
||||
"fallback_lat": 36.98,
|
||||
"fallback_lng": -76.43,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)"
|
||||
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)",
|
||||
},
|
||||
"CVN-75": {
|
||||
"name": "USS Harry S. Truman (CVN-75)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9580, "homeport_lng": -76.3220,
|
||||
"fallback_lat": 36.0, "fallback_lng": 15.0,
|
||||
"homeport_lat": 36.9580,
|
||||
"homeport_lng": -76.3220,
|
||||
"fallback_lat": 36.0,
|
||||
"fallback_lng": 15.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)"
|
||||
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)",
|
||||
},
|
||||
"CVN-77": {
|
||||
"name": "USS George H.W. Bush (CVN-77)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
|
||||
"homeport": "Norfolk, VA",
|
||||
"homeport_lat": 36.9620, "homeport_lng": -76.3210,
|
||||
"fallback_lat": 36.5, "fallback_lng": -74.0,
|
||||
"homeport_lat": 36.9620,
|
||||
"homeport_lng": -76.3210,
|
||||
"fallback_lat": 36.5,
|
||||
"fallback_lng": -74.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)"
|
||||
"fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)",
|
||||
},
|
||||
|
||||
# --- San Diego, CA (Naval Base San Diego) ---
|
||||
# Carrier piers along the east shore of San Diego Bay, spread N-S
|
||||
"CVN-70": {
|
||||
"name": "USS Carl Vinson (CVN-70)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6840, "homeport_lng": -117.1290,
|
||||
"fallback_lat": 32.6840, "fallback_lng": -117.1290,
|
||||
"homeport_lat": 32.6840,
|
||||
"homeport_lng": -117.1290,
|
||||
"fallback_lat": 32.6840,
|
||||
"fallback_lng": -117.1290,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "San Diego, CA (Homeport)"
|
||||
"fallback_desc": "San Diego, CA (Homeport)",
|
||||
},
|
||||
"CVN-71": {
|
||||
"name": "USS Theodore Roosevelt (CVN-71)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6885, "homeport_lng": -117.1280,
|
||||
"fallback_lat": 32.6885, "fallback_lng": -117.1280,
|
||||
"homeport_lat": 32.6885,
|
||||
"homeport_lng": -117.1280,
|
||||
"fallback_lat": 32.6885,
|
||||
"fallback_lng": -117.1280,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "San Diego, CA (Maintenance)"
|
||||
"fallback_desc": "San Diego, CA (Maintenance)",
|
||||
},
|
||||
"CVN-72": {
|
||||
"name": "USS Abraham Lincoln (CVN-72)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
|
||||
"homeport": "San Diego, CA",
|
||||
"homeport_lat": 32.6925, "homeport_lng": -117.1275,
|
||||
"fallback_lat": 20.0, "fallback_lng": 64.0,
|
||||
"homeport_lat": 32.6925,
|
||||
"homeport_lng": -117.1275,
|
||||
"fallback_lat": 20.0,
|
||||
"fallback_lng": 64.0,
|
||||
"fallback_heading": 0,
|
||||
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)"
|
||||
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)",
|
||||
},
|
||||
|
||||
# --- Yokosuka, Japan (CFAY) ---
|
||||
"CVN-73": {
|
||||
"name": "USS George Washington (CVN-73)",
|
||||
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
|
||||
"homeport": "Yokosuka, Japan",
|
||||
"homeport_lat": 35.2830, "homeport_lng": 139.6700,
|
||||
"fallback_lat": 35.2830, "fallback_lng": 139.6700,
|
||||
"homeport_lat": 35.2830,
|
||||
"homeport_lng": 139.6700,
|
||||
"fallback_lat": 35.2830,
|
||||
"fallback_lng": 139.6700,
|
||||
"fallback_heading": 180,
|
||||
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
|
||||
"fallback_desc": "Yokosuka, Japan (Forward deployed)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -175,7 +195,6 @@ REGION_COORDS: Dict[str, tuple] = {
|
||||
"coral sea": (-18.0, 155.0),
|
||||
"gulf of mexico": (25.0, -90.0),
|
||||
"caribbean": (15.0, -75.0),
|
||||
|
||||
# Specific bases / ports
|
||||
"norfolk": (36.95, -76.33),
|
||||
"san diego": (32.68, -117.15),
|
||||
@@ -188,7 +207,6 @@ REGION_COORDS: Dict[str, tuple] = {
|
||||
"bremerton": (47.56, -122.63),
|
||||
"puget sound": (47.56, -122.63),
|
||||
"newport news": (36.98, -76.43),
|
||||
|
||||
# Areas of operation
|
||||
"centcom": (25.0, 55.0),
|
||||
"indopacom": (20.0, 130.0),
|
||||
@@ -209,6 +227,11 @@ CACHE_FILE = Path(__file__).parent.parent / "carrier_cache.json"
|
||||
_carrier_positions: Dict[str, dict] = {}
|
||||
_positions_lock = threading.Lock()
|
||||
_last_update: Optional[datetime] = None
|
||||
_last_gdelt_fetch_at = 0.0
|
||||
_cached_gdelt_articles: List[dict] = []
|
||||
_GDELT_FETCH_INTERVAL_SECONDS = 1800
|
||||
_GDELT_REQUEST_DELAY_SECONDS = 1.25
|
||||
_GDELT_REQUEST_JITTER_SECONDS = 0.35
|
||||
|
||||
|
||||
def _load_cache() -> Dict[str, dict]:
|
||||
@@ -260,22 +283,41 @@ def _match_carrier(text: str) -> Optional[str]:
|
||||
|
||||
def _fetch_gdelt_carrier_news() -> List[dict]:
|
||||
"""Search GDELT for recent carrier movement news."""
|
||||
global _last_gdelt_fetch_at, _cached_gdelt_articles
|
||||
|
||||
now = time.time()
|
||||
if _cached_gdelt_articles and (now - _last_gdelt_fetch_at) < _GDELT_FETCH_INTERVAL_SECONDS:
|
||||
logger.info("Carrier OSINT: using cached GDELT article set to avoid startup bursts")
|
||||
return list(_cached_gdelt_articles)
|
||||
|
||||
results = []
|
||||
search_terms = [
|
||||
"aircraft+carrier+deployed",
|
||||
"carrier+strike+group+navy",
|
||||
"USS+Nimitz+carrier", "USS+Ford+carrier", "USS+Eisenhower+carrier",
|
||||
"USS+Vinson+carrier", "USS+Roosevelt+carrier+navy",
|
||||
"USS+Lincoln+carrier", "USS+Truman+carrier",
|
||||
"USS+Reagan+carrier", "USS+Washington+carrier+navy",
|
||||
"USS+Bush+carrier", "USS+Stennis+carrier",
|
||||
"USS+Nimitz+carrier",
|
||||
"USS+Ford+carrier",
|
||||
"USS+Eisenhower+carrier",
|
||||
"USS+Vinson+carrier",
|
||||
"USS+Roosevelt+carrier+navy",
|
||||
"USS+Lincoln+carrier",
|
||||
"USS+Truman+carrier",
|
||||
"USS+Reagan+carrier",
|
||||
"USS+Washington+carrier+navy",
|
||||
"USS+Bush+carrier",
|
||||
"USS+Stennis+carrier",
|
||||
]
|
||||
|
||||
for term in search_terms:
|
||||
for idx, term in enumerate(search_terms):
|
||||
try:
|
||||
url = f"https://api.gdeltproject.org/api/v2/doc/doc?query={term}&mode=artlist&maxrecords=5&format=json×pan=14d"
|
||||
raw = fetch_with_curl(url, timeout=8)
|
||||
if not raw or not hasattr(raw, 'text'):
|
||||
if getattr(raw, "status_code", 500) == 429:
|
||||
logger.warning(
|
||||
"GDELT returned 429 for '%s'; preserving cached carrier OSINT results",
|
||||
term,
|
||||
)
|
||||
continue
|
||||
if not raw or not hasattr(raw, "text"):
|
||||
continue
|
||||
data = raw.json()
|
||||
articles = data.get("articles", [])
|
||||
@@ -286,7 +328,14 @@ def _fetch_gdelt_carrier_news() -> List[dict]:
|
||||
except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
logger.debug(f"GDELT search failed for '{term}': {e}")
|
||||
continue
|
||||
if idx < len(search_terms) - 1:
|
||||
time.sleep(
|
||||
_GDELT_REQUEST_DELAY_SECONDS
|
||||
+ random.uniform(0.0, _GDELT_REQUEST_JITTER_SECONDS)
|
||||
)
|
||||
|
||||
_cached_gdelt_articles = list(results)
|
||||
_last_gdelt_fetch_at = time.time()
|
||||
logger.info(f"Carrier OSINT: found {len(results)} GDELT articles")
|
||||
return results
|
||||
|
||||
@@ -316,9 +365,11 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
|
||||
"desc": title[:100],
|
||||
"source": "GDELT News API",
|
||||
"source_url": article.get("url", "https://api.gdeltproject.org"),
|
||||
"updated": datetime.now(timezone.utc).isoformat()
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']} → {coords} (from: {title[:80]})")
|
||||
logger.info(
|
||||
f"Carrier update: {CARRIER_REGISTRY[hull]['name']} → {coords} (from: {title[:80]})"
|
||||
)
|
||||
|
||||
return updates
|
||||
|
||||
@@ -336,21 +387,25 @@ def _load_carrier_fallbacks() -> Dict[str, dict]:
|
||||
"wiki": info["wiki"],
|
||||
"source": "USNI News Fleet & Marine Tracker",
|
||||
"source_url": "https://news.usni.org/category/fleet-tracker",
|
||||
"updated": datetime.now(timezone.utc).isoformat()
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Overlay cached positions from previous runs (may have GDELT data)
|
||||
cached = _load_cache()
|
||||
for hull, cached_pos in cached.items():
|
||||
if hull in positions:
|
||||
if cached_pos.get("source", "").startswith("GDELT") or cached_pos.get("source", "").startswith("News"):
|
||||
positions[hull].update({
|
||||
"lat": cached_pos["lat"],
|
||||
"lng": cached_pos["lng"],
|
||||
"desc": cached_pos.get("desc", positions[hull]["desc"]),
|
||||
"source": cached_pos.get("source", "Cached OSINT"),
|
||||
"updated": cached_pos.get("updated", "")
|
||||
})
|
||||
if cached_pos.get("source", "").startswith("GDELT") or cached_pos.get(
|
||||
"source", ""
|
||||
).startswith("News"):
|
||||
positions[hull].update(
|
||||
{
|
||||
"lat": cached_pos["lat"],
|
||||
"lng": cached_pos["lng"],
|
||||
"desc": cached_pos.get("desc", positions[hull]["desc"]),
|
||||
"source": cached_pos.get("source", "Cached OSINT"),
|
||||
"updated": cached_pos.get("updated", ""),
|
||||
}
|
||||
)
|
||||
return positions
|
||||
|
||||
|
||||
@@ -371,7 +426,9 @@ def update_carrier_positions():
|
||||
if not _carrier_positions:
|
||||
_carrier_positions.update(positions)
|
||||
_last_update = datetime.now(timezone.utc)
|
||||
logger.info(f"Carrier tracker: {len(positions)} carriers loaded from fallback/cache (GDELT enrichment starting...)")
|
||||
logger.info(
|
||||
f"Carrier tracker: {len(positions)} carriers loaded from fallback/cache (GDELT enrichment starting...)"
|
||||
)
|
||||
|
||||
# --- Phase 2: slow GDELT enrichment ---
|
||||
try:
|
||||
@@ -408,6 +465,7 @@ def _deconflict_positions(result: List[dict]) -> List[dict]:
|
||||
"""
|
||||
# Group by rounded lat/lng (within ~0.01° ≈ 1km = same spot)
|
||||
from collections import defaultdict
|
||||
|
||||
groups: dict[str, list[int]] = defaultdict(list)
|
||||
for i, c in enumerate(result):
|
||||
key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
|
||||
@@ -454,22 +512,26 @@ def get_carrier_positions() -> List[dict]:
|
||||
result = []
|
||||
for hull, pos in _carrier_positions.items():
|
||||
info = CARRIER_REGISTRY.get(hull, {})
|
||||
result.append({
|
||||
"name": pos.get("name", info.get("name", hull)),
|
||||
"type": "carrier",
|
||||
"lat": pos["lat"],
|
||||
"lng": pos["lng"],
|
||||
"heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
|
||||
"sog": 0,
|
||||
"cog": 0,
|
||||
"country": "United States",
|
||||
"desc": pos.get("desc", ""),
|
||||
"wiki": pos.get("wiki", info.get("wiki", "")),
|
||||
"estimated": True,
|
||||
"source": pos.get("source", "OSINT estimated position"),
|
||||
"source_url": pos.get("source_url", "https://news.usni.org/category/fleet-tracker"),
|
||||
"last_osint_update": pos.get("updated", "")
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"name": pos.get("name", info.get("name", hull)),
|
||||
"type": "carrier",
|
||||
"lat": pos["lat"],
|
||||
"lng": pos["lng"],
|
||||
"heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
|
||||
"sog": 0,
|
||||
"cog": 0,
|
||||
"country": "United States",
|
||||
"desc": pos.get("desc", ""),
|
||||
"wiki": pos.get("wiki", info.get("wiki", "")),
|
||||
"estimated": True,
|
||||
"source": pos.get("source", "OSINT estimated position"),
|
||||
"source_url": pos.get(
|
||||
"source_url", "https://news.usni.org/category/fleet-tracker"
|
||||
),
|
||||
"last_osint_update": pos.get("updated", ""),
|
||||
}
|
||||
)
|
||||
return _deconflict_positions(result)
|
||||
|
||||
|
||||
@@ -500,10 +562,13 @@ def _scheduler_loop():
|
||||
next_run = now.replace(hour=next_hour % 24, minute=0, second=0, microsecond=0)
|
||||
if next_hour == 24:
|
||||
from datetime import timedelta
|
||||
|
||||
next_run = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
wait_seconds = (next_run - now).total_seconds()
|
||||
logger.info(f"Carrier tracker: next update at {next_run.isoformat()} ({wait_seconds/3600:.1f}h)")
|
||||
logger.info(
|
||||
f"Carrier tracker: next update at {next_run.isoformat()} ({wait_seconds/3600:.1f}h)"
|
||||
)
|
||||
|
||||
# Wait until next scheduled time, or until stop event
|
||||
if _scheduler_stop.wait(timeout=wait_seconds):
|
||||
@@ -521,7 +586,9 @@ def start_carrier_tracker():
|
||||
if _scheduler_thread and _scheduler_thread.is_alive():
|
||||
return
|
||||
_scheduler_stop.clear()
|
||||
_scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="carrier-tracker")
|
||||
_scheduler_thread = threading.Thread(
|
||||
target=_scheduler_loop, daemon=True, name="carrier-tracker"
|
||||
)
|
||||
_scheduler_thread.start()
|
||||
logger.info("Carrier tracker started")
|
||||
|
||||
|
||||
+773
-371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
"""Typed configuration via pydantic-settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Admin/security
|
||||
ADMIN_KEY: str = ""
|
||||
ALLOW_INSECURE_ADMIN: bool = False
|
||||
PUBLIC_API_KEY: str = ""
|
||||
|
||||
# Data sources
|
||||
AIS_API_KEY: str = ""
|
||||
OPENSKY_CLIENT_ID: str = ""
|
||||
OPENSKY_CLIENT_SECRET: str = ""
|
||||
LTA_ACCOUNT_KEY: str = ""
|
||||
|
||||
# Runtime
|
||||
CORS_ORIGINS: str = ""
|
||||
FETCH_SLOW_THRESHOLD_S: float = 5.0
|
||||
MESH_STRICT_SIGNATURES: bool = True
|
||||
MESH_DEBUG_MODE: bool = False
|
||||
MESH_MQTT_EXTRA_ROOTS: str = ""
|
||||
MESH_MQTT_EXTRA_TOPICS: str = ""
|
||||
MESH_MQTT_INCLUDE_DEFAULT_ROOTS: bool = True
|
||||
MESH_RNS_ENABLED: bool = False
|
||||
MESH_ARTI_ENABLED: bool = False
|
||||
MESH_ARTI_SOCKS_PORT: int = 9050
|
||||
MESH_RELAY_PEERS: str = ""
|
||||
MESH_BOOTSTRAP_DISABLED: bool = False
|
||||
MESH_BOOTSTRAP_MANIFEST_PATH: str = "data/bootstrap_peers.json"
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = ""
|
||||
MESH_NODE_MODE: str = "participant"
|
||||
MESH_SYNC_INTERVAL_S: int = 300
|
||||
MESH_SYNC_FAILURE_BACKOFF_S: int = 60
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: int = 10
|
||||
MESH_RELAY_MAX_FAILURES: int = 3
|
||||
MESH_RELAY_FAILURE_COOLDOWN_S: int = 120
|
||||
MESH_PEER_PUSH_SECRET: str = ""
|
||||
MESH_RNS_APP_NAME: str = "shadowbroker"
|
||||
MESH_RNS_ASPECT: str = "infonet"
|
||||
MESH_RNS_IDENTITY_PATH: str = ""
|
||||
MESH_RNS_PEERS: str = ""
|
||||
MESH_RNS_DANDELION_HOPS: int = 2
|
||||
MESH_RNS_DANDELION_DELAY_MS: int = 400
|
||||
MESH_RNS_CHURN_INTERVAL_S: int = 300
|
||||
MESH_RNS_MAX_PEERS: int = 32
|
||||
MESH_RNS_MAX_PAYLOAD: int = 8192
|
||||
MESH_RNS_PEER_BUCKET_PREFIX: int = 4
|
||||
MESH_RNS_MAX_PEERS_PER_BUCKET: int = 4
|
||||
MESH_RNS_PEER_FAIL_THRESHOLD: int = 3
|
||||
MESH_RNS_PEER_COOLDOWN_S: int = 300
|
||||
MESH_RNS_SHARD_ENABLED: bool = False
|
||||
MESH_RNS_SHARD_DATA_SHARDS: int = 3
|
||||
MESH_RNS_SHARD_PARITY_SHARDS: int = 1
|
||||
MESH_RNS_SHARD_TTL_S: int = 30
|
||||
MESH_RNS_FEC_CODEC: str = "xor" # xor | rs
|
||||
MESH_RNS_BATCH_MS: int = 200
|
||||
# Keep a low background cadence on private RNS links so quiet nodes are less
|
||||
# trivially fingerprintable by silence alone. Set to 0 to disable explicitly.
|
||||
MESH_RNS_COVER_INTERVAL_S: int = 30
|
||||
MESH_RNS_COVER_SIZE: int = 64
|
||||
MESH_RNS_IBF_WINDOW: int = 256
|
||||
MESH_RNS_IBF_TABLE_SIZE: int = 64
|
||||
MESH_RNS_IBF_MINHASH_SIZE: int = 16
|
||||
MESH_RNS_IBF_MINHASH_THRESHOLD: float = 0.25
|
||||
MESH_RNS_IBF_WINDOW_JITTER: int = 32
|
||||
MESH_RNS_IBF_INTERVAL_S: int = 120
|
||||
MESH_RNS_IBF_SYNC_PEERS: int = 3
|
||||
MESH_RNS_IBF_QUORUM_TIMEOUT_S: int = 6
|
||||
MESH_RNS_IBF_MAX_REQUEST_IDS: int = 64
|
||||
MESH_RNS_IBF_MAX_EVENTS: int = 64
|
||||
MESH_RNS_SESSION_ROTATE_S: int = 1800
|
||||
MESH_RNS_IBF_FAIL_THRESHOLD: int = 3
|
||||
MESH_RNS_IBF_COOLDOWN_S: int = 120
|
||||
MESH_VERIFY_INTERVAL_S: int = 600
|
||||
MESH_VERIFY_SIGNATURES: bool = True
|
||||
MESH_DM_SECURE_MODE: bool = True
|
||||
MESH_DM_TOKEN_PEPPER: str = ""
|
||||
MESH_DM_ALLOW_LEGACY_GET: bool = False
|
||||
MESH_DM_PERSIST_SPOOL: bool = False
|
||||
MESH_DM_REQUIRE_SENDER_SEAL_SHARED: bool = True
|
||||
MESH_DM_NONCE_TTL_S: int = 300
|
||||
MESH_DM_NONCE_CACHE_MAX: int = 4096
|
||||
MESH_DM_REQUEST_MAX_AGE_S: int = 300
|
||||
MESH_DM_REQUEST_MAILBOX_LIMIT: int = 12
|
||||
MESH_DM_SHARED_MAILBOX_LIMIT: int = 48
|
||||
MESH_DM_SELF_MAILBOX_LIMIT: int = 12
|
||||
MESH_DM_MAX_MSG_BYTES: int = 8192
|
||||
MESH_DM_ALLOW_SENDER_SEAL: bool = False
|
||||
# TTL for DH key and prekey bundle registrations — stale entries are pruned.
|
||||
MESH_DM_KEY_TTL_DAYS: int = 30
|
||||
# TTL for mailbox binding metadata — shorter = smaller metadata footprint on disk.
|
||||
MESH_DM_BINDING_TTL_DAYS: int = 7
|
||||
# When False, mailbox bindings are memory-only (agents re-register on restart).
|
||||
MESH_DM_METADATA_PERSIST: bool = True
|
||||
MESH_SCOPED_TOKENS: str = ""
|
||||
MESH_GATE_SESSION_ROTATE_MSGS: int = 50
|
||||
MESH_GATE_SESSION_ROTATE_S: int = 3600
|
||||
# Add a randomized grace window before anonymous gate-session auto-rotation
|
||||
# so threshold-triggered identity swaps are less trivially correlated.
|
||||
MESH_GATE_SESSION_ROTATE_JITTER_S: int = 180
|
||||
# Private gate APIs expose a backward-jittered timestamp view so observers
|
||||
# cannot trivially align exact send times from response metadata alone.
|
||||
MESH_GATE_TIMESTAMP_JITTER_S: int = 60
|
||||
MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK: bool = False
|
||||
MESH_PRIVATE_LOG_TTL_S: int = 900
|
||||
# Clearnet fallback policy for private-tier messages.
|
||||
# "block" (default) = refuse to send private messages over clearnet.
|
||||
# "allow" = fall back to clearnet when Tor/RNS is unavailable (weaker privacy).
|
||||
MESH_PRIVATE_CLEARNET_FALLBACK: str = "block"
|
||||
# Meshtastic MQTT broker credentials (defaults match public firmware).
|
||||
MESH_MQTT_USER: str = "meshdev"
|
||||
MESH_MQTT_PASS: str = "large4cats"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -2,32 +2,33 @@
|
||||
# Centralized magic numbers. Import from here instead of hardcoding.
|
||||
|
||||
# ─── Flight Trails ──────────────────────────────────────────────────────────
|
||||
FLIGHT_TRAIL_MAX_TRACKED = 2000 # Max concurrent tracked trails before LRU eviction
|
||||
FLIGHT_TRAIL_MAX_TRACKED = 2000 # Max concurrent tracked trails before LRU eviction
|
||||
FLIGHT_TRAIL_POINTS_PER_FLIGHT = 200 # Max trail points kept per aircraft
|
||||
TRACKED_TRAIL_TTL_S = 1800 # 30 min - trail TTL for tracked flights
|
||||
DEFAULT_TRAIL_TTL_S = 300 # 5 min - trail TTL for non-tracked flights
|
||||
TRACKED_TRAIL_TTL_S = 1800 # 30 min - trail TTL for tracked flights
|
||||
DEFAULT_TRAIL_TTL_S = 300 # 5 min - trail TTL for non-tracked flights
|
||||
|
||||
# ─── Detection Thresholds ──────────────────────────────────────────────────
|
||||
HOLD_PATTERN_DEGREES = 300 # Total heading change to flag holding pattern
|
||||
GPS_JAMMING_NACP_THRESHOLD = 8 # NACp below this = degraded GPS signal
|
||||
GPS_JAMMING_GRID_SIZE = 1.0 # 1 degree grid for aggregation
|
||||
GPS_JAMMING_MIN_RATIO = 0.25 # 25% degraded aircraft to flag zone
|
||||
HOLD_PATTERN_DEGREES = 300 # Total heading change to flag holding pattern
|
||||
GPS_JAMMING_NACP_THRESHOLD = 8 # NACp below this = degraded GPS signal
|
||||
GPS_JAMMING_GRID_SIZE = 1.0 # 1 degree grid for aggregation
|
||||
GPS_JAMMING_MIN_RATIO = 0.30 # 30% degraded aircraft to flag zone
|
||||
GPS_JAMMING_MIN_AIRCRAFT = 5 # Min aircraft in grid cell for statistical significance
|
||||
|
||||
# ─── Network & Circuit Breaker ──────────────────────────────────────────────
|
||||
CIRCUIT_BREAKER_TTL_S = 120 # Skip domain for 2 min after total failure
|
||||
DOMAIN_FAIL_TTL_S = 300 # Skip requests.get for 5 min, go straight to curl
|
||||
CONNECT_TIMEOUT_S = 3 # Short connect timeout for fast firewall-block detection
|
||||
CIRCUIT_BREAKER_TTL_S = 120 # Skip domain for 2 min after total failure
|
||||
DOMAIN_FAIL_TTL_S = 300 # Skip requests.get for 5 min, go straight to curl
|
||||
CONNECT_TIMEOUT_S = 3 # Short connect timeout for fast firewall-block detection
|
||||
|
||||
# ─── Data Fetcher Intervals ────────────────────────────────────────────────
|
||||
FAST_FETCH_INTERVAL_S = 60 # Flights, ships, satellites, military
|
||||
SLOW_FETCH_INTERVAL_MIN = 30 # News, markets, space weather
|
||||
CCTV_FETCH_INTERVAL_MIN = 1 # CCTV camera pipeline
|
||||
LIVEUAMAP_FETCH_INTERVAL_HR = 12 # LiveUAMap scraper
|
||||
FAST_FETCH_INTERVAL_S = 60 # Flights, ships, satellites, military
|
||||
SLOW_FETCH_INTERVAL_MIN = 30 # News, markets, space weather
|
||||
CCTV_FETCH_INTERVAL_MIN = 1 # CCTV camera pipeline
|
||||
LIVEUAMAP_FETCH_INTERVAL_HR = 12 # LiveUAMap scraper
|
||||
|
||||
# ─── External API ──────────────────────────────────────────────────────────
|
||||
OPENSKY_RATE_LIMIT_S = 300 # Only re-fetch OpenSky every 5 minutes
|
||||
OPENSKY_REQUEST_TIMEOUT_S = 15 # Timeout for OpenSky API calls
|
||||
ROUTE_FETCH_TIMEOUT_S = 15 # Timeout for adsb.lol route lookups
|
||||
OPENSKY_RATE_LIMIT_S = 300 # Only re-fetch OpenSky every 5 minutes
|
||||
OPENSKY_REQUEST_TIMEOUT_S = 15 # Timeout for OpenSky API calls
|
||||
ROUTE_FETCH_TIMEOUT_S = 15 # Timeout for adsb.lol route lookups
|
||||
|
||||
# ─── Internet Outage Detection ─────────────────────────────────────────────
|
||||
INTERNET_OUTAGE_MIN_SEVERITY = 0.10 # 10% drop minimum to show
|
||||
INTERNET_OUTAGE_MIN_SEVERITY = 0.10 # 10% drop minimum to show
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Emergent Intelligence — Cross-layer correlation engine.
|
||||
|
||||
Scans co-located events across multiple data layers and emits composite
|
||||
alerts that no single source could generate alone.
|
||||
|
||||
Correlation types:
|
||||
- RF Anomaly: GPS jamming + internet outage (both required)
|
||||
- Military Buildup: Military flights + naval vessels + GDELT conflict events
|
||||
- Infrastructure Cascade: Internet outage + KiwiSDR offline in same zone
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grid cell size in degrees — 1° ≈ 111 km at equator.
|
||||
# Tighter than the previous 2° to reduce false co-locations.
|
||||
_CELL_SIZE = 1
|
||||
|
||||
# Quality gates for RF anomaly correlation — only high-confidence inputs.
|
||||
# GPS jamming + internet outage overlap in a 111km cell is easily a coincidence
|
||||
# (IODA returns ~100 regional outages; GPS NACp dips are common in busy airspace).
|
||||
# Only fire when the evidence is strong enough to indicate deliberate RF interference.
|
||||
_RF_CORR_MIN_GPS_RATIO = 0.60 # Need strong jamming signal, not marginal NACp dips
|
||||
_RF_CORR_MIN_OUTAGE_PCT = 40 # Need a serious outage, not routine BGP fluctuation
|
||||
_RF_CORR_MIN_INDICATORS = 3 # Require 3+ corroborating signals (not just GPS+outage)
|
||||
|
||||
|
||||
def _cell_key(lat: float, lng: float) -> str:
|
||||
"""Convert lat/lng to a grid cell key."""
|
||||
clat = int(lat // _CELL_SIZE) * _CELL_SIZE
|
||||
clng = int(lng // _CELL_SIZE) * _CELL_SIZE
|
||||
return f"{clat},{clng}"
|
||||
|
||||
|
||||
def _cell_center(key: str) -> tuple[float, float]:
|
||||
"""Get center lat/lng from a cell key."""
|
||||
parts = key.split(",")
|
||||
return float(parts[0]) + _CELL_SIZE / 2, float(parts[1]) + _CELL_SIZE / 2
|
||||
|
||||
|
||||
def _severity(indicator_count: int) -> str:
|
||||
if indicator_count >= 3:
|
||||
return "high"
|
||||
if indicator_count >= 2:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _severity_score(sev: str) -> float:
|
||||
return {"high": 90, "medium": 60, "low": 30}.get(sev, 0)
|
||||
|
||||
|
||||
def _outage_pct(outage: dict) -> float:
|
||||
"""Extract outage severity percentage from an outage dict."""
|
||||
return float(outage.get("severity", 0) or outage.get("severity_pct", 0) or 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RF Anomaly: GPS jamming + internet outage (both must be present)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_rf_anomalies(data: dict) -> list[dict]:
|
||||
gps_jamming = data.get("gps_jamming") or []
|
||||
internet_outages = data.get("internet_outages") or []
|
||||
|
||||
if not gps_jamming:
|
||||
return [] # No GPS jamming → no RF anomalies possible
|
||||
|
||||
# Build grid of indicators
|
||||
cells: dict[str, dict] = defaultdict(lambda: {
|
||||
"gps_jam": False, "gps_ratio": 0.0,
|
||||
"outage": False, "outage_pct": 0.0,
|
||||
})
|
||||
|
||||
for z in gps_jamming:
|
||||
lat, lng = z.get("lat"), z.get("lng")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
ratio = z.get("ratio", 0)
|
||||
if ratio < _RF_CORR_MIN_GPS_RATIO:
|
||||
continue # Skip marginal jamming zones
|
||||
key = _cell_key(lat, lng)
|
||||
cells[key]["gps_jam"] = True
|
||||
cells[key]["gps_ratio"] = max(cells[key]["gps_ratio"], ratio)
|
||||
|
||||
for o in internet_outages:
|
||||
lat = o.get("lat") or o.get("latitude")
|
||||
lng = o.get("lng") or o.get("lon") or o.get("longitude")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
pct = _outage_pct(o)
|
||||
if pct < _RF_CORR_MIN_OUTAGE_PCT:
|
||||
continue # Skip minor outages (ISP maintenance noise)
|
||||
key = _cell_key(float(lat), float(lng))
|
||||
cells[key]["outage"] = True
|
||||
cells[key]["outage_pct"] = max(cells[key]["outage_pct"], pct)
|
||||
|
||||
# PSK Reporter: presence = healthy RF. Only used as a bonus indicator,
|
||||
# NOT as a standalone trigger (absence is normal in most cells).
|
||||
psk_reporter = data.get("psk_reporter") or []
|
||||
psk_cells: set[str] = set()
|
||||
for s in psk_reporter:
|
||||
lat, lng = s.get("lat"), s.get("lon")
|
||||
if lat is not None and lng is not None:
|
||||
psk_cells.add(_cell_key(lat, lng))
|
||||
|
||||
# When PSK data is unavailable, we can't get a 3rd indicator, so require
|
||||
# an even higher GPS jamming ratio to compensate (real EW shows 75%+).
|
||||
psk_available = len(psk_reporter) > 0
|
||||
|
||||
alerts: list[dict] = []
|
||||
for key, c in cells.items():
|
||||
# GPS jamming is the anchor — required for every RF anomaly alert
|
||||
if not c["gps_jam"]:
|
||||
continue
|
||||
if not c["outage"]:
|
||||
continue # Both GPS jamming AND outage are always required
|
||||
|
||||
indicators = 2 # GPS jamming + outage
|
||||
drivers: list[str] = [f"GPS jamming {int(c['gps_ratio'] * 100)}%"]
|
||||
pct = c["outage_pct"]
|
||||
drivers.append(f"Internet outage{f' {pct:.0f}%' if pct else ''}")
|
||||
|
||||
# PSK absence confirms RF environment is disrupted
|
||||
if psk_available and key not in psk_cells:
|
||||
indicators += 1
|
||||
drivers.append("No HF digital activity (PSK Reporter)")
|
||||
|
||||
if indicators < _RF_CORR_MIN_INDICATORS:
|
||||
# Without PSK data, only allow through if GPS ratio is extreme
|
||||
# (75%+ indicates deliberate, sustained jamming — not noise)
|
||||
if not psk_available and c["gps_ratio"] >= 0.75 and pct >= 50:
|
||||
pass # Allow this high-confidence 2-indicator alert through
|
||||
else:
|
||||
continue
|
||||
|
||||
lat, lng = _cell_center(key)
|
||||
sev = _severity(indicators)
|
||||
alerts.append({
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"type": "rf_anomaly",
|
||||
"severity": sev,
|
||||
"score": _severity_score(sev),
|
||||
"drivers": drivers[:3],
|
||||
"cell_size": _CELL_SIZE,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Military Buildup: flights + ships + GDELT conflict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_military_buildups(data: dict) -> list[dict]:
|
||||
mil_flights = data.get("military_flights") or []
|
||||
ships = data.get("ships") or []
|
||||
gdelt = data.get("gdelt") or []
|
||||
|
||||
cells: dict[str, dict] = defaultdict(lambda: {
|
||||
"mil_flights": 0, "mil_ships": 0, "gdelt_events": 0,
|
||||
})
|
||||
|
||||
for f in mil_flights:
|
||||
lat = f.get("lat") or f.get("latitude")
|
||||
lng = f.get("lng") or f.get("lon") or f.get("longitude")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
key = _cell_key(float(lat), float(lng))
|
||||
cells[key]["mil_flights"] += 1
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
mil_ship_types = {"military_vessel", "military", "warship", "patrol", "destroyer",
|
||||
"frigate", "corvette", "carrier", "submarine", "cruiser"}
|
||||
for s in ships:
|
||||
stype = (s.get("type") or s.get("ship_type") or "").lower()
|
||||
if not any(mt in stype for mt in mil_ship_types):
|
||||
continue
|
||||
lat = s.get("lat") or s.get("latitude")
|
||||
lng = s.get("lng") or s.get("lon") or s.get("longitude")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
key = _cell_key(float(lat), float(lng))
|
||||
cells[key]["mil_ships"] += 1
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
for g in gdelt:
|
||||
lat = g.get("lat") or g.get("latitude") or g.get("actionGeo_Lat")
|
||||
lng = g.get("lng") or g.get("lon") or g.get("longitude") or g.get("actionGeo_Long")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
key = _cell_key(float(lat), float(lng))
|
||||
cells[key]["gdelt_events"] += 1
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
alerts: list[dict] = []
|
||||
for key, c in cells.items():
|
||||
mil_total = c["mil_flights"] + c["mil_ships"]
|
||||
has_gdelt = c["gdelt_events"] > 0
|
||||
|
||||
# Need meaningful military presence AND a conflict indicator
|
||||
if mil_total < 3 or not has_gdelt:
|
||||
continue
|
||||
|
||||
drivers: list[str] = []
|
||||
if c["mil_flights"]:
|
||||
drivers.append(f"{c['mil_flights']} military aircraft")
|
||||
if c["mil_ships"]:
|
||||
drivers.append(f"{c['mil_ships']} military vessels")
|
||||
if c["gdelt_events"]:
|
||||
drivers.append(f"{c['gdelt_events']} conflict events")
|
||||
|
||||
if mil_total >= 11:
|
||||
sev = "high"
|
||||
elif mil_total >= 6:
|
||||
sev = "medium"
|
||||
else:
|
||||
sev = "low"
|
||||
|
||||
lat, lng = _cell_center(key)
|
||||
alerts.append({
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"type": "military_buildup",
|
||||
"severity": sev,
|
||||
"score": _severity_score(sev),
|
||||
"drivers": drivers[:3],
|
||||
"cell_size": _CELL_SIZE,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Infrastructure Cascade: outage + KiwiSDR co-location
|
||||
#
|
||||
# Power plants are removed from this detector — with 35K plants globally,
|
||||
# virtually every 2° cell contains one, making every outage a false hit.
|
||||
# KiwiSDR receivers (~300 worldwide) are sparse enough to be meaningful:
|
||||
# an outage in the same cell as a KiwiSDR indicates real infrastructure
|
||||
# disruption affecting radio monitoring capability.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_infra_cascades(data: dict) -> list[dict]:
|
||||
internet_outages = data.get("internet_outages") or []
|
||||
kiwisdr = data.get("kiwisdr") or []
|
||||
|
||||
if not kiwisdr:
|
||||
return []
|
||||
|
||||
# Build set of cells with KiwiSDR receivers
|
||||
kiwi_cells: set[str] = set()
|
||||
for k in kiwisdr:
|
||||
lat, lng = k.get("lat"), k.get("lon") or k.get("lng")
|
||||
if lat is not None and lng is not None:
|
||||
try:
|
||||
kiwi_cells.add(_cell_key(float(lat), float(lng)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not kiwi_cells:
|
||||
return []
|
||||
|
||||
alerts: list[dict] = []
|
||||
for o in internet_outages:
|
||||
lat = o.get("lat") or o.get("latitude")
|
||||
lng = o.get("lng") or o.get("lon") or o.get("longitude")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
key = _cell_key(float(lat), float(lng))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if key not in kiwi_cells:
|
||||
continue
|
||||
|
||||
pct = _outage_pct(o)
|
||||
drivers = [f"Internet outage{f' {pct:.0f}%' if pct else ''}",
|
||||
"KiwiSDR receivers in affected zone"]
|
||||
|
||||
lat_c, lng_c = _cell_center(key)
|
||||
alerts.append({
|
||||
"lat": lat_c,
|
||||
"lng": lng_c,
|
||||
"type": "infra_cascade",
|
||||
"severity": "medium",
|
||||
"score": _severity_score("medium"),
|
||||
"drivers": drivers,
|
||||
"cell_size": _CELL_SIZE,
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_correlations(data: dict) -> list[dict]:
|
||||
"""Run all correlation detectors and return merged alert list."""
|
||||
alerts: list[dict] = []
|
||||
|
||||
try:
|
||||
alerts.extend(_detect_rf_anomalies(data))
|
||||
except Exception as e:
|
||||
logger.error("Correlation engine RF anomaly error: %s", e)
|
||||
|
||||
try:
|
||||
alerts.extend(_detect_military_buildups(data))
|
||||
except Exception as e:
|
||||
logger.error("Correlation engine military buildup error: %s", e)
|
||||
|
||||
try:
|
||||
alerts.extend(_detect_infra_cascades(data))
|
||||
except Exception as e:
|
||||
logger.error("Correlation engine infra cascade error: %s", e)
|
||||
|
||||
rf = sum(1 for a in alerts if a["type"] == "rf_anomaly")
|
||||
mil = sum(1 for a in alerts if a["type"] == "military_buildup")
|
||||
infra = sum(1 for a in alerts if a["type"] == "infra_cascade")
|
||||
if alerts:
|
||||
logger.info(
|
||||
"Correlations: %d alerts (%d rf, %d mil, %d infra)",
|
||||
len(alerts), rf, mil, infra,
|
||||
)
|
||||
|
||||
return alerts
|
||||
@@ -13,10 +13,14 @@ Heavy logic has been extracted into services/fetchers/:
|
||||
- infrastructure.py — internet outages, data centers, CCTV, KiwiSDR
|
||||
- geo.py — ships, airports, frontlines, GDELT, LiveUAMap
|
||||
"""
|
||||
|
||||
import logging
|
||||
import concurrent.futures
|
||||
from datetime import datetime
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -25,7 +29,11 @@ from services.cctv_pipeline import init_db
|
||||
|
||||
# Shared state — all fetcher modules read/write through this
|
||||
from services.fetchers._store import (
|
||||
latest_data, source_timestamps, _mark_fresh, _data_lock, # noqa: F401 — re-exported for main.py
|
||||
latest_data,
|
||||
source_timestamps,
|
||||
_mark_fresh,
|
||||
_data_lock, # noqa: F401 — re-exported for main.py
|
||||
get_latest_data_subset,
|
||||
)
|
||||
|
||||
# Domain-specific fetcher modules (already extracted)
|
||||
@@ -36,24 +44,109 @@ from services.fetchers.satellites import fetch_satellites # noqa: F401
|
||||
from services.fetchers.news import fetch_news # noqa: F401
|
||||
|
||||
# Newly extracted fetcher modules
|
||||
from services.fetchers.financial import fetch_defense_stocks, fetch_oil_prices # noqa: F401
|
||||
from services.fetchers.financial import fetch_financial_markets # noqa: F401
|
||||
from services.fetchers.unusual_whales import fetch_unusual_whales # noqa: F401
|
||||
from services.fetchers.earth_observation import ( # noqa: F401
|
||||
fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
|
||||
fetch_earthquakes,
|
||||
fetch_firms_fires,
|
||||
fetch_firms_country_fires,
|
||||
fetch_space_weather,
|
||||
fetch_weather,
|
||||
fetch_weather_alerts,
|
||||
fetch_air_quality,
|
||||
fetch_volcanoes,
|
||||
fetch_viirs_change_nodes,
|
||||
)
|
||||
from services.fetchers.infrastructure import ( # noqa: F401
|
||||
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_power_plants,
|
||||
fetch_cctv, fetch_kiwisdr,
|
||||
fetch_internet_outages,
|
||||
fetch_ripe_atlas_probes,
|
||||
fetch_datacenters,
|
||||
fetch_military_bases,
|
||||
fetch_power_plants,
|
||||
fetch_cctv,
|
||||
fetch_kiwisdr,
|
||||
fetch_scanners,
|
||||
fetch_satnogs,
|
||||
fetch_tinygs,
|
||||
fetch_psk_reporter,
|
||||
)
|
||||
from services.fetchers.geo import ( # noqa: F401
|
||||
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
|
||||
fetch_frontlines, fetch_gdelt, fetch_geopolitics, update_liveuamap,
|
||||
fetch_ships,
|
||||
fetch_airports,
|
||||
find_nearest_airport,
|
||||
cached_airports,
|
||||
fetch_frontlines,
|
||||
fetch_gdelt,
|
||||
fetch_geopolitics,
|
||||
update_liveuamap,
|
||||
fetch_fishing_activity,
|
||||
)
|
||||
from services.fetchers.prediction_markets import fetch_prediction_markets # noqa: F401
|
||||
from services.fetchers.sigint import fetch_sigint # noqa: F401
|
||||
from services.fetchers.trains import fetch_trains # noqa: F401
|
||||
from services.fetchers.ukraine_alerts import fetch_ukraine_air_raid_alerts # noqa: F401
|
||||
from services.fetchers.meshtastic_map import (
|
||||
fetch_meshtastic_nodes,
|
||||
load_meshtastic_cache_if_available,
|
||||
) # noqa: F401
|
||||
from services.fetchers.fimi import fetch_fimi # noqa: F401
|
||||
from services.ais_stream import prune_stale_vessels # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SLOW_FETCH_S = float(os.environ.get("FETCH_SLOW_THRESHOLD_S", "5"))
|
||||
|
||||
# Shared thread pool — reused across all fetch cycles instead of creating/destroying per tick
|
||||
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=20, thread_name_prefix="fetch"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler & Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
def _run_tasks(label: str, funcs: list):
|
||||
"""Run tasks concurrently and log any exceptions (do not fail silently)."""
|
||||
if not funcs:
|
||||
return
|
||||
futures = {_SHARED_EXECUTOR.submit(func): (func.__name__, time.perf_counter()) for func in funcs}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
name, start = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_success
|
||||
|
||||
record_success(name, duration_s=duration)
|
||||
if duration > _SLOW_FETCH_S:
|
||||
logger.warning(f"{label} task slow: {name} took {duration:.2f}s")
|
||||
except Exception as e:
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_failure
|
||||
|
||||
record_failure(name, error=e, duration_s=duration)
|
||||
logger.exception(f"{label} task failed: {name}")
|
||||
|
||||
|
||||
def _run_task_with_health(func, name: str | None = None):
|
||||
"""Run a single task with health tracking."""
|
||||
task_name = name or getattr(func, "__name__", "task")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
func()
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_success
|
||||
|
||||
record_success(task_name, duration_s=duration)
|
||||
if duration > _SLOW_FETCH_S:
|
||||
logger.warning(f"task slow: {task_name} took {duration:.2f}s")
|
||||
except Exception as e:
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_failure
|
||||
|
||||
record_failure(task_name, error=e, duration_s=duration)
|
||||
logger.exception(f"task failed: {task_name}")
|
||||
|
||||
|
||||
def update_fast_data():
|
||||
"""Fast-tier: moving entities that need frequent updates (every 60s)."""
|
||||
logger.info("Fast-tier data update starting...")
|
||||
@@ -62,50 +155,201 @@ def update_fast_data():
|
||||
fetch_military_flights,
|
||||
fetch_ships,
|
||||
fetch_satellites,
|
||||
fetch_sigint,
|
||||
fetch_trains,
|
||||
fetch_tinygs,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fast_funcs)) as executor:
|
||||
futures = [executor.submit(func) for func in fast_funcs]
|
||||
concurrent.futures.wait(futures)
|
||||
_run_tasks("fast-tier", fast_funcs)
|
||||
with _data_lock:
|
||||
latest_data['last_updated'] = datetime.utcnow().isoformat()
|
||||
latest_data["last_updated"] = datetime.utcnow().isoformat()
|
||||
from services.fetchers._store import bump_data_version
|
||||
bump_data_version()
|
||||
logger.info("Fast-tier update complete.")
|
||||
|
||||
|
||||
def update_slow_data():
|
||||
"""Slow-tier: contextual + enrichment data that refreshes less often (every 5–10 min)."""
|
||||
logger.info("Slow-tier data update starting...")
|
||||
slow_funcs = [
|
||||
fetch_news,
|
||||
fetch_prediction_markets,
|
||||
fetch_earthquakes,
|
||||
fetch_firms_fires,
|
||||
fetch_defense_stocks,
|
||||
fetch_oil_prices,
|
||||
fetch_firms_country_fires,
|
||||
fetch_weather,
|
||||
fetch_space_weather,
|
||||
fetch_internet_outages,
|
||||
fetch_ripe_atlas_probes, # runs after IODA to deduplicate
|
||||
fetch_cctv,
|
||||
fetch_kiwisdr,
|
||||
fetch_satnogs,
|
||||
fetch_frontlines,
|
||||
fetch_gdelt,
|
||||
fetch_datacenters,
|
||||
fetch_military_bases,
|
||||
fetch_scanners,
|
||||
fetch_psk_reporter,
|
||||
fetch_weather_alerts,
|
||||
fetch_air_quality,
|
||||
fetch_fishing_activity,
|
||||
fetch_power_plants,
|
||||
fetch_ukraine_air_raid_alerts,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||
futures = [executor.submit(func) for func in slow_funcs]
|
||||
concurrent.futures.wait(futures)
|
||||
_run_tasks("slow-tier", slow_funcs)
|
||||
# Run correlation engine after all data is fresh
|
||||
try:
|
||||
from services.correlation_engine import compute_correlations
|
||||
with _data_lock:
|
||||
snapshot = dict(latest_data)
|
||||
correlations = compute_correlations(snapshot)
|
||||
with _data_lock:
|
||||
latest_data["correlations"] = correlations
|
||||
except Exception as e:
|
||||
logger.error("Correlation engine failed: %s", e)
|
||||
from services.fetchers._store import bump_data_version
|
||||
bump_data_version()
|
||||
logger.info("Slow-tier update complete.")
|
||||
|
||||
def update_all_data():
|
||||
"""Full refresh — all tiers run IN PARALLEL for fastest startup."""
|
||||
|
||||
def update_all_data(*, startup_mode: bool = False):
|
||||
"""Full refresh.
|
||||
|
||||
On startup we prefer cached/DB-backed data first, then let scheduled jobs
|
||||
perform some heavy top-ups after the app is already responsive.
|
||||
"""
|
||||
logger.info("Full data update starting (parallel)...")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
|
||||
f0 = pool.submit(fetch_airports)
|
||||
f1 = pool.submit(update_fast_data)
|
||||
f2 = pool.submit(update_slow_data)
|
||||
concurrent.futures.wait([f0, f1, f2])
|
||||
# Preload Meshtastic map cache immediately (instant, from disk)
|
||||
load_meshtastic_cache_if_available()
|
||||
with _data_lock:
|
||||
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
|
||||
futures = {
|
||||
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(update_slow_data): ("update_slow_data", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_volcanoes): ("fetch_volcanoes", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_viirs_change_nodes): ("fetch_viirs_change_nodes", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_unusual_whales): ("fetch_unusual_whales", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_fimi): ("fetch_fimi", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(fetch_gdelt): ("fetch_gdelt", time.perf_counter()),
|
||||
_SHARED_EXECUTOR.submit(update_liveuamap): ("update_liveuamap", time.perf_counter()),
|
||||
}
|
||||
if not startup_mode or not meshtastic_seeded:
|
||||
futures[_SHARED_EXECUTOR.submit(fetch_meshtastic_nodes)] = (
|
||||
"fetch_meshtastic_nodes",
|
||||
time.perf_counter(),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
|
||||
)
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
name, start = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_success
|
||||
|
||||
record_success(name, duration_s=duration)
|
||||
if duration > _SLOW_FETCH_S:
|
||||
logger.warning(f"full-refresh task slow: {name} took {duration:.2f}s")
|
||||
except Exception as e:
|
||||
duration = time.perf_counter() - start
|
||||
from services.fetch_health import record_failure
|
||||
|
||||
record_failure(name, error=e, duration_s=duration)
|
||||
logger.exception(f"full-refresh task failed: {name}")
|
||||
logger.info("Full data update complete.")
|
||||
|
||||
|
||||
_scheduler = None
|
||||
_STARTUP_CCTV_INGEST_DELAY_S = 30
|
||||
_FINANCIAL_REFRESH_MINUTES = 30
|
||||
|
||||
|
||||
def _oracle_resolution_sweep():
|
||||
"""Hourly sweep: check if any markets with active predictions have concluded.
|
||||
|
||||
Resolution logic:
|
||||
- If a market's end_date has passed AND it's no longer in the active API data → resolved
|
||||
- For binary markets: final probability determines outcome (>50% = yes, <50% = no)
|
||||
- For multi-outcome: the outcome with highest final probability wins
|
||||
"""
|
||||
try:
|
||||
from services.mesh.mesh_oracle import oracle_ledger
|
||||
|
||||
active_titles = oracle_ledger.get_active_markets()
|
||||
if not active_titles:
|
||||
return
|
||||
|
||||
# Get current market data
|
||||
with _data_lock:
|
||||
markets = list(latest_data.get("prediction_markets", []))
|
||||
|
||||
# Build lookup of active API markets
|
||||
api_titles = {m.get("title", "").lower(): m for m in markets}
|
||||
|
||||
import time as _time
|
||||
|
||||
now = _time.time()
|
||||
resolved_count = 0
|
||||
|
||||
for title in active_titles:
|
||||
api_market = api_titles.get(title.lower())
|
||||
|
||||
# If market still in API and end_date hasn't passed, skip
|
||||
if api_market:
|
||||
end_date = api_market.get("end_date")
|
||||
if end_date:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
|
||||
if dt.timestamp() > now:
|
||||
continue # Market hasn't ended yet
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
continue # No end date, can't auto-resolve
|
||||
|
||||
# Market has concluded (past end_date or dropped from API)
|
||||
# Determine outcome from last known data
|
||||
if api_market:
|
||||
outcomes = api_market.get("outcomes", [])
|
||||
if outcomes and len(outcomes) > 2:
|
||||
# Multi-outcome: highest pct wins
|
||||
best = max(outcomes, key=lambda o: o.get("pct", 0))
|
||||
outcome = best.get("name", "")
|
||||
else:
|
||||
# Binary: consensus > 50 = yes
|
||||
pct = api_market.get("consensus_pct") or api_market.get("polymarket_pct") or 50
|
||||
outcome = "yes" if float(pct) > 50 else "no"
|
||||
else:
|
||||
# Market dropped from API entirely — can't determine outcome, skip
|
||||
logger.warning(
|
||||
f"Oracle sweep: market '{title}' no longer in API, cannot auto-resolve"
|
||||
)
|
||||
continue
|
||||
|
||||
if not outcome:
|
||||
continue
|
||||
|
||||
# Resolve both free predictions and market stakes
|
||||
winners, losers = oracle_ledger.resolve_market(title, outcome)
|
||||
stake_result = oracle_ledger.resolve_market_stakes(title, outcome)
|
||||
resolved_count += 1
|
||||
logger.info(
|
||||
f"Oracle sweep resolved '{title}' → {outcome}: "
|
||||
f"{winners}W/{losers}L free, "
|
||||
f"{stake_result.get('winners', 0)}W/{stake_result.get('losers', 0)}L staked"
|
||||
)
|
||||
|
||||
if resolved_count:
|
||||
logger.info(f"Oracle sweep complete: {resolved_count} markets resolved")
|
||||
# Also clean up old data periodically
|
||||
oracle_ledger.cleanup_old_data()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle resolution sweep error: {e}")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
global _scheduler
|
||||
@@ -113,38 +357,207 @@ def start_scheduler():
|
||||
_scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
# Fast tier — every 60 seconds
|
||||
_scheduler.add_job(update_fast_data, 'interval', seconds=60, id='fast_tier', max_instances=1, misfire_grace_time=30)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(update_fast_data, "update_fast_data"),
|
||||
"interval",
|
||||
seconds=60,
|
||||
id="fast_tier",
|
||||
max_instances=1,
|
||||
misfire_grace_time=30,
|
||||
)
|
||||
|
||||
# Slow tier — every 5 minutes
|
||||
_scheduler.add_job(update_slow_data, 'interval', minutes=5, id='slow_tier', max_instances=1, misfire_grace_time=120)
|
||||
|
||||
# Very slow — every 15 minutes
|
||||
_scheduler.add_job(fetch_gdelt, 'interval', minutes=15, id='gdelt', max_instances=1, misfire_grace_time=120)
|
||||
_scheduler.add_job(update_liveuamap, 'interval', minutes=15, id='liveuamap', max_instances=1, misfire_grace_time=120)
|
||||
|
||||
# CCTV pipeline refresh — every 10 minutes
|
||||
# Instantiate once and reuse — avoids re-creating DB connections on every tick
|
||||
from services.cctv_pipeline import (
|
||||
TFLJamCamIngestor, LTASingaporeIngestor,
|
||||
AustinTXIngestor, NYCDOTIngestor,
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(update_slow_data, "update_slow_data"),
|
||||
"interval",
|
||||
minutes=5,
|
||||
id="slow_tier",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
|
||||
# Weather alerts — every 5 minutes (time-critical, separate from slow tier)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_weather_alerts, "fetch_weather_alerts"),
|
||||
"interval",
|
||||
minutes=5,
|
||||
id="weather_alerts",
|
||||
max_instances=1,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
|
||||
# Ukraine air raid alerts — every 2 minutes (time-critical)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_ukraine_air_raid_alerts, "fetch_ukraine_air_raid_alerts"),
|
||||
"interval",
|
||||
minutes=2,
|
||||
id="ukraine_alerts",
|
||||
max_instances=1,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
|
||||
# AIS vessel pruning — every 5 minutes (prevents unbounded memory growth)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(prune_stale_vessels, "prune_stale_vessels"),
|
||||
"interval",
|
||||
minutes=5,
|
||||
id="ais_prune",
|
||||
max_instances=1,
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
|
||||
# GDELT — every 30 minutes (downloads 32 ZIP files per call, avoid rate limits)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_gdelt, "fetch_gdelt"),
|
||||
"interval",
|
||||
minutes=30,
|
||||
id="gdelt",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(update_liveuamap, "update_liveuamap"),
|
||||
"interval",
|
||||
minutes=30,
|
||||
id="liveuamap",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
|
||||
# CCTV pipeline refresh — runs all ingestors, then refreshes in-memory data.
|
||||
# Delay the first run slightly so startup serves cached/DB-backed data first.
|
||||
from services.cctv_pipeline import (
|
||||
TFLJamCamIngestor,
|
||||
LTASingaporeIngestor,
|
||||
AustinTXIngestor,
|
||||
NYCDOTIngestor,
|
||||
CaltransIngestor,
|
||||
ColoradoDOTIngestor,
|
||||
WSDOTIngestor,
|
||||
GeorgiaDOTIngestor,
|
||||
IllinoisDOTIngestor,
|
||||
MichiganDOTIngestor,
|
||||
WindyWebcamsIngestor,
|
||||
DGTNationalIngestor,
|
||||
MadridCityIngestor,
|
||||
OSMTrafficCameraIngestor,
|
||||
)
|
||||
|
||||
_cctv_ingestors = [
|
||||
(TFLJamCamIngestor(), "cctv_tfl"),
|
||||
(LTASingaporeIngestor(), "cctv_lta"),
|
||||
(AustinTXIngestor(), "cctv_atx"),
|
||||
(NYCDOTIngestor(), "cctv_nyc"),
|
||||
(CaltransIngestor(), "cctv_caltrans"),
|
||||
(ColoradoDOTIngestor(), "cctv_codot"),
|
||||
(WSDOTIngestor(), "cctv_wsdot"),
|
||||
(GeorgiaDOTIngestor(), "cctv_gdot"),
|
||||
(IllinoisDOTIngestor(), "cctv_idot"),
|
||||
(MichiganDOTIngestor(), "cctv_mdot"),
|
||||
(WindyWebcamsIngestor(), "cctv_windy"),
|
||||
(DGTNationalIngestor(), "cctv_dgt"),
|
||||
(MadridCityIngestor(), "cctv_madrid"),
|
||||
(OSMTrafficCameraIngestor(), "cctv_osm"),
|
||||
]
|
||||
|
||||
def _run_cctv_ingest_cycle():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("cctv"):
|
||||
return
|
||||
for ingestor, name in _cctv_ingestors:
|
||||
_run_task_with_health(ingestor.ingest, name)
|
||||
# Refresh in-memory CCTV data immediately after ingest
|
||||
try:
|
||||
from services.cctv_pipeline import get_all_cameras
|
||||
from services.fetchers.infrastructure import fetch_cctv
|
||||
fetch_cctv()
|
||||
logger.info(f"CCTV ingest cycle complete — {len(get_all_cameras())} cameras in DB")
|
||||
except Exception as e:
|
||||
logger.warning(f"CCTV post-ingest refresh failed: {e}")
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_cctv_ingest_cycle,
|
||||
"interval",
|
||||
minutes=10,
|
||||
id="cctv_ingest",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
next_run_time=datetime.utcnow() + timedelta(seconds=_STARTUP_CCTV_INGEST_DELAY_S),
|
||||
)
|
||||
|
||||
# Financial tickers — every 30 minutes (Yahoo Finance rate-limits aggressively)
|
||||
def _fetch_financial():
|
||||
_run_task_with_health(fetch_financial_markets, "fetch_financial_markets")
|
||||
|
||||
_scheduler.add_job(
|
||||
_fetch_financial,
|
||||
"interval",
|
||||
minutes=_FINANCIAL_REFRESH_MINUTES,
|
||||
id="financial_tickers",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
next_run_time=datetime.utcnow() + timedelta(minutes=_FINANCIAL_REFRESH_MINUTES),
|
||||
)
|
||||
|
||||
# Unusual Whales — every 15 minutes (congress trades, dark pool, flow alerts)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_unusual_whales, "fetch_unusual_whales"),
|
||||
"interval",
|
||||
minutes=15,
|
||||
id="unusual_whales",
|
||||
max_instances=1,
|
||||
misfire_grace_time=120,
|
||||
)
|
||||
|
||||
# Meshtastic map API — every 4 hours, fetch global node positions
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_meshtastic_nodes, "fetch_meshtastic_nodes"),
|
||||
"interval",
|
||||
hours=4,
|
||||
id="meshtastic_map",
|
||||
max_instances=1,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
|
||||
# Oracle resolution sweep — every hour, check if any markets with predictions have concluded
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(_oracle_resolution_sweep, "oracle_sweep"),
|
||||
"interval",
|
||||
hours=1,
|
||||
id="oracle_sweep",
|
||||
max_instances=1,
|
||||
misfire_grace_time=300,
|
||||
)
|
||||
|
||||
# VIIRS change detection — every 12 hours (monthly composites, no rush)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_viirs_change_nodes, "fetch_viirs_change_nodes"),
|
||||
"interval",
|
||||
hours=12,
|
||||
id="viirs_change",
|
||||
max_instances=1,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
|
||||
# FIMI disinformation index — every 12 hours (weekly editorial feed)
|
||||
_scheduler.add_job(
|
||||
lambda: _run_task_with_health(fetch_fimi, "fetch_fimi"),
|
||||
"interval",
|
||||
hours=12,
|
||||
id="fimi",
|
||||
max_instances=1,
|
||||
misfire_grace_time=600,
|
||||
)
|
||||
_cctv_tfl = TFLJamCamIngestor()
|
||||
_cctv_lta = LTASingaporeIngestor()
|
||||
_cctv_atx = AustinTXIngestor()
|
||||
_cctv_nyc = NYCDOTIngestor()
|
||||
_now = datetime.now()
|
||||
_scheduler.add_job(_cctv_tfl.ingest, 'interval', minutes=10, id='cctv_tfl', max_instances=1, misfire_grace_time=120, next_run_time=_now)
|
||||
_scheduler.add_job(_cctv_lta.ingest, 'interval', minutes=10, id='cctv_lta', max_instances=1, misfire_grace_time=120, next_run_time=_now)
|
||||
_scheduler.add_job(_cctv_atx.ingest, 'interval', minutes=10, id='cctv_atx', max_instances=1, misfire_grace_time=120, next_run_time=_now)
|
||||
_scheduler.add_job(_cctv_nyc.ingest, 'interval', minutes=10, id='cctv_nyc', max_instances=1, misfire_grace_time=120, next_run_time=_now)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler started.")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def get_latest_data():
|
||||
with _data_lock:
|
||||
return dict(latest_data)
|
||||
return get_latest_data_subset(*latest_data.keys())
|
||||
|
||||
+225
-11
@@ -2,10 +2,16 @@
|
||||
|
||||
Ensures required env vars are present before the scheduler starts.
|
||||
Logs warnings for optional keys that degrade functionality when missing.
|
||||
Audits security-critical config for dangerous combinations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from services.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,9 +29,200 @@ _OPTIONAL = {
|
||||
"OPENSKY_CLIENT_ID": "OpenSky OAuth2 — gap-fill flights in Africa/Asia/LatAm",
|
||||
"OPENSKY_CLIENT_SECRET": "OpenSky OAuth2 — gap-fill flights in Africa/Asia/LatAm",
|
||||
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
|
||||
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
|
||||
}
|
||||
|
||||
|
||||
def _invalid_dm_token_pepper_reason(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
lowered = raw.lower()
|
||||
if not raw:
|
||||
return "empty"
|
||||
if lowered in {"change-me", "changeme"}:
|
||||
return "placeholder"
|
||||
if len(raw) < 16:
|
||||
return "too short"
|
||||
return ""
|
||||
|
||||
|
||||
def _invalid_peer_push_secret_reason(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
lowered = raw.lower()
|
||||
if not raw:
|
||||
return "empty"
|
||||
if lowered in {"change-me", "changeme"}:
|
||||
return "placeholder"
|
||||
if len(raw) < 16:
|
||||
return "too short"
|
||||
return ""
|
||||
|
||||
|
||||
_PEPPER_FILE = Path(__file__).resolve().parents[1] / "data" / "dm_token_pepper.key"
|
||||
|
||||
|
||||
def _ensure_dm_token_pepper(settings) -> str:
|
||||
token_pepper = str(getattr(settings, "MESH_DM_TOKEN_PEPPER", "") or "").strip()
|
||||
pepper_reason = _invalid_dm_token_pepper_reason(token_pepper)
|
||||
if not pepper_reason:
|
||||
return token_pepper
|
||||
|
||||
# Try loading a previously persisted pepper before generating a new one.
|
||||
try:
|
||||
from services.mesh.mesh_secure_storage import read_secure_json
|
||||
|
||||
stored = read_secure_json(_PEPPER_FILE, lambda: {})
|
||||
stored_pepper = str(stored.get("pepper", "") or "").strip()
|
||||
if stored_pepper and not _invalid_dm_token_pepper_reason(stored_pepper):
|
||||
os.environ["MESH_DM_TOKEN_PEPPER"] = stored_pepper
|
||||
get_settings.cache_clear()
|
||||
logger.info("Loaded persisted DM token pepper from %s", _PEPPER_FILE.name)
|
||||
return stored_pepper
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
generated = secrets.token_hex(32)
|
||||
os.environ["MESH_DM_TOKEN_PEPPER"] = generated
|
||||
get_settings.cache_clear()
|
||||
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
|
||||
log_fn(
|
||||
"⚠️ SECURITY: MESH_DM_TOKEN_PEPPER is invalid (%s) — mailbox tokens "
|
||||
"would be predictably derivable. Auto-generated a random pepper for "
|
||||
"this session.",
|
||||
pepper_reason,
|
||||
)
|
||||
|
||||
# Persist so the same pepper survives restarts.
|
||||
try:
|
||||
from services.mesh.mesh_secure_storage import write_secure_json
|
||||
|
||||
_PEPPER_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_secure_json(_PEPPER_FILE, {"pepper": generated, "generated_at": int(time.time())})
|
||||
logger.info("Persisted auto-generated DM token pepper to %s", _PEPPER_FILE.name)
|
||||
except Exception:
|
||||
logger.warning("Could not persist auto-generated DM token pepper to disk — will regenerate on next restart")
|
||||
|
||||
return generated
|
||||
|
||||
|
||||
def _peer_push_secret_required(settings) -> bool:
|
||||
relay_peers = str(getattr(settings, "MESH_RELAY_PEERS", "") or "").strip()
|
||||
rns_peers = str(getattr(settings, "MESH_RNS_PEERS", "") or "").strip()
|
||||
return bool(getattr(settings, "MESH_RNS_ENABLED", False) or relay_peers or rns_peers)
|
||||
|
||||
|
||||
def get_security_posture_warnings(settings=None) -> list[str]:
|
||||
snapshot = settings or get_settings()
|
||||
warnings: list[str] = []
|
||||
|
||||
admin_key = str(getattr(snapshot, "ADMIN_KEY", "") or "").strip()
|
||||
allow_insecure = bool(getattr(snapshot, "ALLOW_INSECURE_ADMIN", False))
|
||||
if allow_insecure and not admin_key:
|
||||
warnings.append(
|
||||
"ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY leaves admin and Wormhole endpoints unauthenticated."
|
||||
)
|
||||
|
||||
if not bool(getattr(snapshot, "MESH_STRICT_SIGNATURES", True)):
|
||||
warnings.append(
|
||||
"MESH_STRICT_SIGNATURES=false is deprecated and ignored; signature enforcement remains mandatory."
|
||||
)
|
||||
|
||||
peer_secret = str(getattr(snapshot, "MESH_PEER_PUSH_SECRET", "") or "").strip()
|
||||
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
|
||||
if _peer_push_secret_required(snapshot) and peer_secret_reason:
|
||||
warnings.append(
|
||||
"MESH_PEER_PUSH_SECRET is invalid "
|
||||
f"({peer_secret_reason}) while relay or RNS peers are enabled; private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default."
|
||||
)
|
||||
|
||||
if os.name != "nt" and bool(getattr(snapshot, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
|
||||
warnings.append(
|
||||
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true stores Wormhole keys in raw local files on this platform."
|
||||
)
|
||||
|
||||
if bool(getattr(snapshot, "MESH_RNS_ENABLED", False)) and int(getattr(snapshot, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
|
||||
warnings.append(
|
||||
"MESH_RNS_COVER_INTERVAL_S<=0 disables RNS cover traffic outside high-privacy mode, making quiet-node traffic analysis easier."
|
||||
)
|
||||
|
||||
fallback_policy = str(getattr(snapshot, "MESH_PRIVATE_CLEARNET_FALLBACK", "block") or "block").strip().lower()
|
||||
if fallback_policy == "allow":
|
||||
warnings.append(
|
||||
"MESH_PRIVATE_CLEARNET_FALLBACK=allow — private-tier messages may fall back to clearnet relay when Tor/RNS is unavailable."
|
||||
)
|
||||
|
||||
metadata_persist = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST", True))
|
||||
binding_ttl = int(getattr(snapshot, "MESH_DM_BINDING_TTL_DAYS", 7) or 7)
|
||||
if metadata_persist and binding_ttl > 14:
|
||||
warnings.append(
|
||||
f"MESH_DM_BINDING_TTL_DAYS={binding_ttl} with MESH_DM_METADATA_PERSIST=true — long-lived mailbox binding metadata persists communication graph structure on disk."
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def _audit_security_config(settings) -> None:
|
||||
"""Audit security-critical config combinations and log loud warnings.
|
||||
|
||||
This does not block startup (dev ergonomics), but makes dangerous
|
||||
settings impossible to miss in the logs.
|
||||
"""
|
||||
# ── 1. ALLOW_INSECURE_ADMIN without ADMIN_KEY ─────────────────────
|
||||
admin_key = (getattr(settings, "ADMIN_KEY", "") or "").strip()
|
||||
allow_insecure = bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False))
|
||||
if allow_insecure and not admin_key:
|
||||
logger.critical(
|
||||
"🚨 SECURITY: ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY — "
|
||||
"ALL admin/wormhole endpoints are completely unauthenticated. "
|
||||
"This is acceptable ONLY for local development. "
|
||||
"Set ADMIN_KEY for any networked or production deployment."
|
||||
)
|
||||
|
||||
# ── 2. Signature enforcement ──────────────────────────────────────
|
||||
mesh_strict = bool(getattr(settings, "MESH_STRICT_SIGNATURES", True))
|
||||
if not mesh_strict:
|
||||
logger.warning(
|
||||
"⚠️ CONFIG: MESH_STRICT_SIGNATURES=false is deprecated and ignored — "
|
||||
"runtime signature enforcement remains mandatory."
|
||||
)
|
||||
|
||||
# ── 3. Empty DM token pepper ──────────────────────────────────────
|
||||
_ensure_dm_token_pepper(settings)
|
||||
|
||||
# ── 4. Peer push secret / private-plane integrity ─────────────────
|
||||
peer_secret = str(getattr(settings, "MESH_PEER_PUSH_SECRET", "") or "").strip()
|
||||
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
|
||||
if _peer_push_secret_required(settings) and peer_secret_reason:
|
||||
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
|
||||
log_fn(
|
||||
"⚠️ SECURITY: MESH_PEER_PUSH_SECRET is invalid (%s) while relay or RNS peers are enabled — "
|
||||
"private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default until it is set to a non-placeholder secret.",
|
||||
peer_secret_reason,
|
||||
)
|
||||
|
||||
# ── 5. Raw secure-storage fallback on non-Windows ────────────────
|
||||
if os.name != "nt" and bool(getattr(settings, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
|
||||
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
|
||||
log_fn(
|
||||
"⚠️ SECURITY: MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true leaves Wormhole keys in raw local files. "
|
||||
"Use this only for development/CI until a native keyring provider is available."
|
||||
)
|
||||
|
||||
# ── 6. Disabled cover traffic outside forced high-privacy mode ─────────
|
||||
if bool(getattr(settings, "MESH_RNS_ENABLED", False)) and int(getattr(settings, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
|
||||
logger.warning(
|
||||
"⚠️ PRIVACY: MESH_RNS_COVER_INTERVAL_S<=0 disables background RNS cover traffic outside high-privacy mode. "
|
||||
"Quiet nodes become easier to fingerprint by silence and burst timing."
|
||||
)
|
||||
|
||||
# ── 7. Clearnet fallback policy ──────────────────────────────────
|
||||
fallback_policy = str(getattr(settings, "MESH_PRIVATE_CLEARNET_FALLBACK", "block") or "block").strip().lower()
|
||||
if fallback_policy == "allow":
|
||||
logger.warning(
|
||||
"⚠️ PRIVACY: MESH_PRIVATE_CLEARNET_FALLBACK=allow — private-tier messages will fall "
|
||||
"back to clearnet relay when Tor/RNS is unavailable. Set to 'block' for safer defaults."
|
||||
)
|
||||
|
||||
|
||||
def validate_env(*, strict: bool = True) -> bool:
|
||||
"""Validate environment variables at startup.
|
||||
|
||||
@@ -38,14 +235,20 @@ def validate_env(*, strict: bool = True) -> bool:
|
||||
"""
|
||||
all_ok = True
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Required keys — must be set
|
||||
for key, desc in _REQUIRED.items():
|
||||
value = os.environ.get(key, "").strip()
|
||||
value = getattr(settings, key, "")
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
logger.error(
|
||||
"❌ REQUIRED env var %s is not set. %s\n"
|
||||
" Set it in .env or via Docker secrets (%s_FILE).",
|
||||
key, desc, key,
|
||||
key,
|
||||
desc,
|
||||
key,
|
||||
)
|
||||
all_ok = False
|
||||
|
||||
@@ -55,21 +258,32 @@ def validate_env(*, strict: bool = True) -> bool:
|
||||
|
||||
# Critical-warn keys — app works but security/functionality is degraded
|
||||
for key, desc in _CRITICAL_WARN.items():
|
||||
value = os.environ.get(key, "").strip()
|
||||
value = getattr(settings, key, "")
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
logger.critical(
|
||||
"🔓 CRITICAL: env var %s is not set — %s\n"
|
||||
" This is safe for local dev but MUST be set in production.",
|
||||
key, desc,
|
||||
allow_insecure = bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False))
|
||||
logger.warning(
|
||||
"⚠️ ADMIN_KEY is not set%s — %s",
|
||||
" and ALLOW_INSECURE_ADMIN=true" if allow_insecure else "",
|
||||
desc,
|
||||
)
|
||||
if not allow_insecure:
|
||||
logger.critical(
|
||||
"🔓 CRITICAL: env var %s is not set — this MUST be set in production.",
|
||||
key,
|
||||
)
|
||||
|
||||
# Optional keys — warn if missing
|
||||
for key, desc in _OPTIONAL.items():
|
||||
value = os.environ.get(key, "").strip()
|
||||
value = getattr(settings, key, "")
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
logger.warning(
|
||||
"⚠️ Optional env var %s is not set — %s", key, desc
|
||||
)
|
||||
logger.warning("⚠️ Optional env var %s is not set — %s", key, desc)
|
||||
|
||||
# ── Security posture audit ────────────────────────────────────────
|
||||
_audit_security_config(settings)
|
||||
|
||||
if all_ok:
|
||||
logger.info("✅ Environment validation passed.")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Fetch health registry — tracks per-source success/failure counts and timings."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from services.fetchers._store import _data_lock, source_freshness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_health: Dict[str, Dict[str, Any]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
def _update_source_freshness(source: str, *, ok: bool, error_msg: Optional[str] = None):
|
||||
"""Mirror health summary into shared store for visibility."""
|
||||
with _data_lock:
|
||||
entry = source_freshness.get(source, {})
|
||||
if ok:
|
||||
entry["last_ok"] = _now_iso()
|
||||
else:
|
||||
entry["last_error"] = _now_iso()
|
||||
if error_msg:
|
||||
entry["last_error_msg"] = error_msg[:200]
|
||||
source_freshness[source] = entry
|
||||
|
||||
|
||||
def record_success(source: str, duration_s: Optional[float] = None, count: Optional[int] = None):
|
||||
"""Record a successful fetch for a source."""
|
||||
now = _now_iso()
|
||||
with _lock:
|
||||
entry = _health.setdefault(
|
||||
source,
|
||||
{
|
||||
"ok_count": 0,
|
||||
"error_count": 0,
|
||||
"last_ok": None,
|
||||
"last_error": None,
|
||||
"last_error_msg": None,
|
||||
"last_duration_ms": None,
|
||||
"avg_duration_ms": None,
|
||||
"last_count": None,
|
||||
},
|
||||
)
|
||||
entry["ok_count"] += 1
|
||||
entry["last_ok"] = now
|
||||
if duration_s is not None:
|
||||
dur_ms = round(duration_s * 1000, 1)
|
||||
entry["last_duration_ms"] = dur_ms
|
||||
prev_avg = entry["avg_duration_ms"] or 0.0
|
||||
n = entry["ok_count"]
|
||||
entry["avg_duration_ms"] = round(((prev_avg * (n - 1)) + dur_ms) / n, 1)
|
||||
if count is not None:
|
||||
entry["last_count"] = count
|
||||
|
||||
_update_source_freshness(source, ok=True)
|
||||
|
||||
|
||||
def record_failure(source: str, error: Exception, duration_s: Optional[float] = None):
|
||||
"""Record a failed fetch for a source."""
|
||||
now = _now_iso()
|
||||
err_msg = str(error)
|
||||
with _lock:
|
||||
entry = _health.setdefault(
|
||||
source,
|
||||
{
|
||||
"ok_count": 0,
|
||||
"error_count": 0,
|
||||
"last_ok": None,
|
||||
"last_error": None,
|
||||
"last_error_msg": None,
|
||||
"last_duration_ms": None,
|
||||
"avg_duration_ms": None,
|
||||
"last_count": None,
|
||||
},
|
||||
)
|
||||
entry["error_count"] += 1
|
||||
entry["last_error"] = now
|
||||
entry["last_error_msg"] = err_msg[:200]
|
||||
if duration_s is not None:
|
||||
entry["last_duration_ms"] = round(duration_s * 1000, 1)
|
||||
|
||||
_update_source_freshness(source, ok=False, error_msg=err_msg)
|
||||
|
||||
|
||||
def get_health_snapshot() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a snapshot of current fetch health state."""
|
||||
with _lock:
|
||||
return {k: dict(v) for k, v in _health.items()}
|
||||
@@ -3,14 +3,68 @@
|
||||
Central location for latest_data, source_timestamps, and the data lock.
|
||||
Every fetcher imports from here instead of maintaining its own copy.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
|
||||
class DashboardData(TypedDict, total=False):
|
||||
"""Schema for the in-memory data store. Catches key typos at dev time."""
|
||||
|
||||
last_updated: Optional[str]
|
||||
news: List[Dict[str, Any]]
|
||||
stocks: Dict[str, Any]
|
||||
oil: Dict[str, Any]
|
||||
commercial_flights: List[Dict[str, Any]]
|
||||
private_flights: List[Dict[str, Any]]
|
||||
private_jets: List[Dict[str, Any]]
|
||||
flights: List[Dict[str, Any]]
|
||||
ships: List[Dict[str, Any]]
|
||||
military_flights: List[Dict[str, Any]]
|
||||
tracked_flights: List[Dict[str, Any]]
|
||||
cctv: List[Dict[str, Any]]
|
||||
weather: Optional[Dict[str, Any]]
|
||||
earthquakes: List[Dict[str, Any]]
|
||||
uavs: List[Dict[str, Any]]
|
||||
frontlines: Optional[Any]
|
||||
gdelt: List[Dict[str, Any]]
|
||||
liveuamap: List[Dict[str, Any]]
|
||||
kiwisdr: List[Dict[str, Any]]
|
||||
space_weather: Optional[Dict[str, Any]]
|
||||
internet_outages: List[Dict[str, Any]]
|
||||
firms_fires: List[Dict[str, Any]]
|
||||
datacenters: List[Dict[str, Any]]
|
||||
airports: List[Dict[str, Any]]
|
||||
gps_jamming: List[Dict[str, Any]]
|
||||
satellites: List[Dict[str, Any]]
|
||||
satellite_source: str
|
||||
prediction_markets: List[Dict[str, Any]]
|
||||
sigint: List[Dict[str, Any]]
|
||||
sigint_totals: Dict[str, Any]
|
||||
mesh_channel_stats: Dict[str, Any]
|
||||
meshtastic_map_nodes: List[Dict[str, Any]]
|
||||
meshtastic_map_fetched_at: Optional[float]
|
||||
weather_alerts: List[Dict[str, Any]]
|
||||
air_quality: List[Dict[str, Any]]
|
||||
volcanoes: List[Dict[str, Any]]
|
||||
fishing_activity: List[Dict[str, Any]]
|
||||
satnogs_stations: List[Dict[str, Any]]
|
||||
satnogs_observations: List[Dict[str, Any]]
|
||||
tinygs_satellites: List[Dict[str, Any]]
|
||||
ukraine_alerts: List[Dict[str, Any]]
|
||||
power_plants: List[Dict[str, Any]]
|
||||
viirs_change_nodes: List[Dict[str, Any]]
|
||||
fimi: Dict[str, Any]
|
||||
psk_reporter: List[Dict[str, Any]]
|
||||
correlations: List[Dict[str, Any]]
|
||||
|
||||
|
||||
# In-memory store
|
||||
latest_data = {
|
||||
latest_data: DashboardData = {
|
||||
"last_updated": None,
|
||||
"news": [],
|
||||
"stocks": {},
|
||||
@@ -32,17 +86,158 @@ latest_data = {
|
||||
"firms_fires": [],
|
||||
"datacenters": [],
|
||||
"military_bases": [],
|
||||
"power_plants": []
|
||||
"prediction_markets": [],
|
||||
"sigint": [],
|
||||
"sigint_totals": {},
|
||||
"mesh_channel_stats": {},
|
||||
"meshtastic_map_nodes": [],
|
||||
"meshtastic_map_fetched_at": None,
|
||||
"weather_alerts": [],
|
||||
"air_quality": [],
|
||||
"volcanoes": [],
|
||||
"fishing_activity": [],
|
||||
"satnogs_stations": [],
|
||||
"satnogs_observations": [],
|
||||
"tinygs_satellites": [],
|
||||
"ukraine_alerts": [],
|
||||
"power_plants": [],
|
||||
"viirs_change_nodes": [],
|
||||
"fimi": {},
|
||||
"psk_reporter": [],
|
||||
"correlations": [],
|
||||
}
|
||||
|
||||
# Per-source freshness timestamps
|
||||
source_timestamps = {}
|
||||
|
||||
# Per-source health/freshness metadata (last ok/error)
|
||||
source_freshness: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _mark_fresh(*keys):
|
||||
"""Record the current UTC time for one or more data source keys."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
for k in keys:
|
||||
source_timestamps[k] = now
|
||||
with _data_lock:
|
||||
for k in keys:
|
||||
source_timestamps[k] = now
|
||||
|
||||
|
||||
# Thread lock for safe reads/writes to latest_data
|
||||
_data_lock = threading.Lock()
|
||||
|
||||
# Monotonic version counter — incremented on each data update cycle.
|
||||
# Used for cheap ETag generation instead of MD5-hashing the full response.
|
||||
_data_version: int = 0
|
||||
|
||||
|
||||
def bump_data_version() -> None:
|
||||
"""Increment the data version counter after a fetch cycle completes."""
|
||||
global _data_version
|
||||
_data_version += 1
|
||||
|
||||
|
||||
def get_data_version() -> int:
|
||||
"""Return the current data version (for ETag generation)."""
|
||||
return _data_version
|
||||
|
||||
|
||||
_active_layers_version: int = 0
|
||||
|
||||
|
||||
def bump_active_layers_version() -> None:
|
||||
"""Increment the active-layer version when frontend toggles change response shape."""
|
||||
global _active_layers_version
|
||||
_active_layers_version += 1
|
||||
|
||||
|
||||
def get_active_layers_version() -> int:
|
||||
"""Return the current active-layer version (for ETag generation)."""
|
||||
return _active_layers_version
|
||||
|
||||
|
||||
def get_latest_data_subset(*keys: str) -> DashboardData:
|
||||
"""Return a shallow snapshot of only the requested top-level keys.
|
||||
|
||||
This avoids cloning the entire dashboard store for endpoints that only need
|
||||
a small tier-specific subset.
|
||||
"""
|
||||
with _data_lock:
|
||||
snap: DashboardData = {}
|
||||
for key in keys:
|
||||
value = latest_data.get(key)
|
||||
if isinstance(value, list):
|
||||
snap[key] = list(value)
|
||||
elif isinstance(value, dict):
|
||||
snap[key] = dict(value)
|
||||
else:
|
||||
snap[key] = value
|
||||
return snap
|
||||
|
||||
|
||||
def get_latest_data_subset_refs(*keys: str) -> DashboardData:
|
||||
"""Return direct top-level references for read-only hot paths.
|
||||
|
||||
Writers replace top-level values under the lock instead of mutating them
|
||||
in place, so readers can safely use these references after releasing the
|
||||
lock as long as they do not modify them.
|
||||
"""
|
||||
with _data_lock:
|
||||
snap: DashboardData = {}
|
||||
for key in keys:
|
||||
snap[key] = latest_data.get(key)
|
||||
return snap
|
||||
|
||||
|
||||
def get_source_timestamps_snapshot() -> dict[str, str]:
|
||||
"""Return a stable copy of per-source freshness timestamps."""
|
||||
with _data_lock:
|
||||
return dict(source_timestamps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active layers — frontend POSTs toggles, fetchers check before running.
|
||||
# Keep these aligned with the dashboard's default layer state so startup does
|
||||
# not fetch heavyweight feeds the UI starts with disabled.
|
||||
# ---------------------------------------------------------------------------
|
||||
active_layers: dict[str, bool] = {
|
||||
"flights": True,
|
||||
"private": True,
|
||||
"jets": True,
|
||||
"military": True,
|
||||
"tracked": True,
|
||||
"satellites": True,
|
||||
"ships_military": True,
|
||||
"ships_cargo": True,
|
||||
"ships_civilian": True,
|
||||
"ships_passenger": True,
|
||||
"ships_tracked_yachts": True,
|
||||
"earthquakes": True,
|
||||
"cctv": True,
|
||||
"ukraine_frontline": True,
|
||||
"global_incidents": True,
|
||||
"gps_jamming": True,
|
||||
"kiwisdr": True,
|
||||
"scanners": True,
|
||||
"firms": True,
|
||||
"internet_outages": True,
|
||||
"datacenters": True,
|
||||
"military_bases": True,
|
||||
"sigint_meshtastic": True,
|
||||
"sigint_aprs": True,
|
||||
"weather_alerts": True,
|
||||
"air_quality": True,
|
||||
"volcanoes": True,
|
||||
"fishing_activity": True,
|
||||
"satnogs": True,
|
||||
"tinygs": True,
|
||||
"ukraine_alerts": True,
|
||||
"power_plants": False,
|
||||
"viirs_nightlights": False,
|
||||
"psk_reporter": True,
|
||||
"correlations": True,
|
||||
}
|
||||
|
||||
|
||||
def is_any_active(*layer_names: str) -> bool:
|
||||
"""Return True if any of the given layer names is currently active."""
|
||||
return any(active_layers.get(name, True) for name in layer_names)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Earth-observation fetchers — earthquakes, FIRMS fires, space weather, weather radar."""
|
||||
"""Earth-observation fetchers — earthquakes, FIRMS fires, space weather, weather radar,
|
||||
severe weather alerts, air quality, volcanoes."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import heapq
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
@@ -15,6 +22,10 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=1, base_delay=1)
|
||||
def fetch_earthquakes():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("earthquakes"):
|
||||
return
|
||||
quakes = []
|
||||
try:
|
||||
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson"
|
||||
@@ -24,12 +35,16 @@ def fetch_earthquakes():
|
||||
for f in features[:50]:
|
||||
mag = f["properties"]["mag"]
|
||||
lng, lat, depth = f["geometry"]["coordinates"]
|
||||
quakes.append({
|
||||
"id": f["id"], "mag": mag,
|
||||
"lat": lat, "lng": lng,
|
||||
"place": f["properties"]["place"]
|
||||
})
|
||||
except Exception as e:
|
||||
quakes.append(
|
||||
{
|
||||
"id": f["id"],
|
||||
"mag": mag,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"place": f["properties"]["place"],
|
||||
}
|
||||
)
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching earthquakes: {e}")
|
||||
with _data_lock:
|
||||
latest_data["earthquakes"] = quakes
|
||||
@@ -43,6 +58,10 @@ def fetch_earthquakes():
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_firms_fires():
|
||||
"""Fetch global fire/thermal anomalies from NASA FIRMS (NOAA-20 VIIRS, 24h, no key needed)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("firms"):
|
||||
return
|
||||
fires = []
|
||||
try:
|
||||
url = "https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv"
|
||||
@@ -58,18 +77,23 @@ def fetch_firms_fires():
|
||||
conf = row.get("confidence", "nominal")
|
||||
daynight = row.get("daynight", "")
|
||||
bright = float(row.get("bright_ti4", 0))
|
||||
all_rows.append({
|
||||
"lat": lat, "lng": lng, "frp": frp,
|
||||
"brightness": bright, "confidence": conf,
|
||||
"daynight": daynight,
|
||||
"acq_date": row.get("acq_date", ""),
|
||||
"acq_time": row.get("acq_time", ""),
|
||||
})
|
||||
all_rows.append(
|
||||
{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"frp": frp,
|
||||
"brightness": bright,
|
||||
"confidence": conf,
|
||||
"daynight": daynight,
|
||||
"acq_date": row.get("acq_date", ""),
|
||||
"acq_time": row.get("acq_time", ""),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
fires = heapq.nlargest(5000, all_rows, key=lambda x: x["frp"])
|
||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||
with _data_lock:
|
||||
latest_data["firms_fires"] = fires
|
||||
@@ -77,6 +101,90 @@ def fetch_firms_fires():
|
||||
_mark_fresh("firms_fires")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NASA FIRMS Country-Scoped Fires (enriches global CSV with conflict zones)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conflict-zone countries of interest for higher-detail fire/thermal data
|
||||
_FIRMS_COUNTRIES = ["ISR", "IRN", "IRQ", "LBN", "SYR", "YEM", "SAU", "UKR", "RUS", "TUR"]
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_firms_country_fires():
|
||||
"""Fetch country-scoped fire hotspots from NASA FIRMS MAP_KEY API.
|
||||
|
||||
Supplements the global CSV feed with more granular data for conflict zones.
|
||||
Merges results into the existing firms_fires data store (no new frontend key).
|
||||
Requires FIRMS_MAP_KEY env var (free from NASA Earthdata). Skips if not set.
|
||||
"""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("firms"):
|
||||
return
|
||||
|
||||
map_key = os.environ.get("FIRMS_MAP_KEY", "")
|
||||
if not map_key:
|
||||
logger.debug("FIRMS_MAP_KEY not set, skipping country-scoped FIRMS fetch")
|
||||
return
|
||||
|
||||
# Build a set of existing (lat, lng) rounded to 0.01° for dedup
|
||||
with _data_lock:
|
||||
existing = set()
|
||||
for f in latest_data.get("firms_fires", []):
|
||||
existing.add((round(f["lat"], 2), round(f["lng"], 2)))
|
||||
|
||||
new_fires = []
|
||||
for country in _FIRMS_COUNTRIES:
|
||||
try:
|
||||
url = (
|
||||
f"https://firms.modaps.eosdis.nasa.gov/api/country/csv/"
|
||||
f"{map_key}/VIIRS_NOAA20_NRT/{country}/1"
|
||||
)
|
||||
response = fetch_with_curl(url, timeout=15)
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"FIRMS country {country}: HTTP {response.status_code}")
|
||||
continue
|
||||
|
||||
reader = csv.DictReader(io.StringIO(response.text))
|
||||
for row in reader:
|
||||
try:
|
||||
lat = float(row.get("latitude", 0))
|
||||
lng = float(row.get("longitude", 0))
|
||||
key = (round(lat, 2), round(lng, 2))
|
||||
if key in existing:
|
||||
continue # Already in global data
|
||||
existing.add(key)
|
||||
|
||||
frp = float(row.get("frp", 0))
|
||||
new_fires.append({
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"frp": frp,
|
||||
"brightness": float(row.get("bright_ti4", 0)),
|
||||
"confidence": row.get("confidence", "nominal"),
|
||||
"daynight": row.get("daynight", ""),
|
||||
"acq_date": row.get("acq_date", ""),
|
||||
"acq_time": row.get("acq_time", ""),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.debug(f"FIRMS country {country} failed: {e}")
|
||||
|
||||
if new_fires:
|
||||
with _data_lock:
|
||||
current = latest_data.get("firms_fires", [])
|
||||
merged = current + new_fires
|
||||
# Keep top 6000 by FRP (slightly more than global-only cap of 5000)
|
||||
if len(merged) > 6000:
|
||||
merged = heapq.nlargest(6000, merged, key=lambda x: x["frp"])
|
||||
latest_data["firms_fires"] = merged
|
||||
logger.info(f"FIRMS country enrichment: +{len(new_fires)} fires from {len(_FIRMS_COUNTRIES)} countries")
|
||||
_mark_fresh("firms_fires")
|
||||
else:
|
||||
logger.debug("FIRMS country enrichment: no new fires found")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Space Weather (NOAA SWPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -84,7 +192,9 @@ def fetch_firms_fires():
|
||||
def fetch_space_weather():
|
||||
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
||||
try:
|
||||
kp_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/planetary_k_index_1m.json", timeout=10)
|
||||
kp_resp = fetch_with_curl(
|
||||
"https://services.swpc.noaa.gov/json/planetary_k_index_1m.json", timeout=10
|
||||
)
|
||||
kp_value = None
|
||||
kp_text = "QUIET"
|
||||
if kp_resp.status_code == 200:
|
||||
@@ -102,16 +212,20 @@ def fetch_space_weather():
|
||||
kp_text = "UNSETTLED"
|
||||
|
||||
events = []
|
||||
ev_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/edited_events.json", timeout=10)
|
||||
ev_resp = fetch_with_curl(
|
||||
"https://services.swpc.noaa.gov/json/edited_events.json", timeout=10
|
||||
)
|
||||
if ev_resp.status_code == 200:
|
||||
all_events = ev_resp.json()
|
||||
for ev in all_events[-10:]:
|
||||
events.append({
|
||||
"type": ev.get("type", ""),
|
||||
"begin": ev.get("begin", ""),
|
||||
"end": ev.get("end", ""),
|
||||
"classtype": ev.get("classtype", ""),
|
||||
})
|
||||
events.append(
|
||||
{
|
||||
"type": ev.get("type", ""),
|
||||
"begin": ev.get("begin", ""),
|
||||
"end": ev.get("end", ""),
|
||||
"classtype": ev.get("classtype", ""),
|
||||
}
|
||||
)
|
||||
|
||||
with _data_lock:
|
||||
latest_data["space_weather"] = {
|
||||
@@ -121,7 +235,7 @@ def fetch_space_weather():
|
||||
}
|
||||
_mark_fresh("space_weather")
|
||||
logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching space weather: {e}")
|
||||
|
||||
|
||||
@@ -138,7 +252,347 @@ def fetch_weather():
|
||||
if "radar" in data and "past" in data["radar"]:
|
||||
latest_time = data["radar"]["past"][-1]["time"]
|
||||
with _data_lock:
|
||||
latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")}
|
||||
latest_data["weather"] = {
|
||||
"time": latest_time,
|
||||
"host": data.get("host", "https://tilecache.rainviewer.com"),
|
||||
}
|
||||
_mark_fresh("weather")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching weather: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOAA/NWS Severe Weather Alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_weather_alerts():
|
||||
"""Fetch active severe weather alerts from NOAA/NWS (US coverage, GeoJSON polygons)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("weather_alerts"):
|
||||
return
|
||||
alerts = []
|
||||
try:
|
||||
url = "https://api.weather.gov/alerts/active?status=actual"
|
||||
headers = {
|
||||
"User-Agent": "(ShadowBroker OSINT Dashboard, github.com/BigBodyCobain/Shadowbroker)",
|
||||
"Accept": "application/geo+json",
|
||||
}
|
||||
response = fetch_with_curl(url, timeout=15, headers=headers)
|
||||
if response.status_code == 200:
|
||||
features = response.json().get("features", [])
|
||||
for f in features:
|
||||
props = f.get("properties", {})
|
||||
geom = f.get("geometry")
|
||||
if not geom:
|
||||
continue # skip zone-only alerts with no polygon
|
||||
alerts.append(
|
||||
{
|
||||
"id": props.get("id", ""),
|
||||
"event": props.get("event", ""),
|
||||
"severity": props.get("severity", "Unknown"),
|
||||
"certainty": props.get("certainty", ""),
|
||||
"urgency": props.get("urgency", ""),
|
||||
"headline": props.get("headline", ""),
|
||||
"description": (props.get("description", "") or "")[:300],
|
||||
"expires": props.get("expires", ""),
|
||||
"geometry": geom,
|
||||
}
|
||||
)
|
||||
logger.info(f"Weather alerts: {len(alerts)} active (with polygons)")
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching weather alerts: {e}")
|
||||
with _data_lock:
|
||||
latest_data["weather_alerts"] = alerts
|
||||
if alerts:
|
||||
_mark_fresh("weather_alerts")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Air Quality (OpenAQ v3)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _pm25_to_aqi(pm25: float) -> int:
|
||||
"""Convert PM2.5 concentration (µg/m³) to US EPA AQI."""
|
||||
breakpoints = [
|
||||
(0, 12.0, 0, 50),
|
||||
(12.1, 35.4, 51, 100),
|
||||
(35.5, 55.4, 101, 150),
|
||||
(55.5, 150.4, 151, 200),
|
||||
(150.5, 250.4, 201, 300),
|
||||
(250.5, 500.4, 301, 500),
|
||||
]
|
||||
for c_lo, c_hi, i_lo, i_hi in breakpoints:
|
||||
if pm25 <= c_hi:
|
||||
return round(((i_hi - i_lo) / (c_hi - c_lo)) * (pm25 - c_lo) + i_lo)
|
||||
return 500
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_air_quality():
|
||||
"""Fetch global air quality stations with PM2.5 data from OpenAQ."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("air_quality"):
|
||||
return
|
||||
stations = []
|
||||
api_key = os.environ.get("OPENAQ_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.debug("OPENAQ_API_KEY not set, skipping air quality fetch")
|
||||
return
|
||||
try:
|
||||
url = "https://api.openaq.org/v3/locations?limit=5000¶meter_id=2&order_by=datetime&sort_order=desc"
|
||||
headers = {"X-API-Key": api_key}
|
||||
response = fetch_with_curl(url, timeout=30, headers=headers)
|
||||
if response.status_code == 200:
|
||||
results = response.json().get("results", [])
|
||||
for loc in results:
|
||||
coords = loc.get("coordinates", {})
|
||||
lat = coords.get("latitude")
|
||||
lng = coords.get("longitude")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
pm25 = None
|
||||
for p in loc.get("parameters", []):
|
||||
if p.get("id") == 2:
|
||||
pm25 = p.get("lastValue")
|
||||
break
|
||||
if pm25 is None:
|
||||
continue
|
||||
pm25_val = float(pm25)
|
||||
if pm25_val < 0:
|
||||
continue
|
||||
stations.append(
|
||||
{
|
||||
"id": loc.get("id"),
|
||||
"name": loc.get("name", "Unknown"),
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"pm25": round(pm25_val, 1),
|
||||
"aqi": _pm25_to_aqi(pm25_val),
|
||||
"country": loc.get("country", {}).get("code", ""),
|
||||
}
|
||||
)
|
||||
logger.info(f"Air quality: {len(stations)} stations")
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching air quality: {e}")
|
||||
with _data_lock:
|
||||
latest_data["air_quality"] = stations
|
||||
if stations:
|
||||
_mark_fresh("air_quality")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volcanoes (Smithsonian Global Volcanism Program)
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=2, base_delay=5)
|
||||
def fetch_volcanoes():
|
||||
"""Fetch Holocene volcanoes from Smithsonian GVP WFS (static reference data)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("volcanoes"):
|
||||
return
|
||||
volcanoes = []
|
||||
try:
|
||||
url = (
|
||||
"https://webservices.volcano.si.edu/geoserver/GVP-VOTW/wfs"
|
||||
"?service=WFS&version=2.0.0&request=GetFeature"
|
||||
"&typeName=GVP-VOTW:E3WebApp_HoloceneVolcanoes"
|
||||
"&outputFormat=application/json"
|
||||
)
|
||||
response = fetch_with_curl(url, timeout=30)
|
||||
if response.status_code == 200:
|
||||
features = response.json().get("features", [])
|
||||
for f in features:
|
||||
props = f.get("properties", {})
|
||||
geom = f.get("geometry", {})
|
||||
coords = geom.get("coordinates", [None, None])
|
||||
if coords[0] is None:
|
||||
continue
|
||||
last_eruption = props.get("LastEruption")
|
||||
last_eruption_year = None
|
||||
if last_eruption is not None:
|
||||
try:
|
||||
last_eruption_year = int(last_eruption)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
volcanoes.append(
|
||||
{
|
||||
"name": props.get("VolcanoName", "Unknown"),
|
||||
"type": props.get("VolcanoType", ""),
|
||||
"country": props.get("Country", ""),
|
||||
"region": props.get("TectonicSetting", ""),
|
||||
"elevation": props.get("Elevation", 0),
|
||||
"last_eruption_year": last_eruption_year,
|
||||
"lat": coords[1],
|
||||
"lng": coords[0],
|
||||
}
|
||||
)
|
||||
logger.info(f"Volcanoes: {len(volcanoes)} Holocene volcanoes loaded")
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching volcanoes: {e}")
|
||||
with _data_lock:
|
||||
latest_data["volcanoes"] = volcanoes
|
||||
if volcanoes:
|
||||
_mark_fresh("volcanoes")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VIIRS Night Lights Change Detection (Google Earth Engine — optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
_VIIRS_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "viirs_change_nodes.json"
|
||||
_VIIRS_CACHE_MAX_AGE_S = 86400 # 24 hours
|
||||
|
||||
# Conflict-zone AOIs: (name, south, west, north, east)
|
||||
_VIIRS_AOIS = [
|
||||
("Gaza Strip", 31.2, 34.2, 31.6, 34.6),
|
||||
("Kharkiv Oblast", 48.5, 35.0, 50.5, 38.5),
|
||||
("Donetsk Oblast", 47.0, 36.5, 49.0, 39.5),
|
||||
("Zaporizhzhia Oblast", 46.5, 34.5, 48.5, 37.0),
|
||||
("Aleppo", 35.8, 36.5, 36.5, 37.5),
|
||||
("Khartoum", 15.2, 32.2, 15.9, 32.9),
|
||||
("Sana'a", 14.9, 43.8, 15.6, 44.5),
|
||||
("Mosul", 36.0, 42.8, 36.7, 43.5),
|
||||
("Mariupol", 46.9, 37.2, 47.3, 37.8),
|
||||
("Southern Lebanon", 33.0, 35.0, 33.5, 36.0),
|
||||
]
|
||||
|
||||
_VIIRS_SEVERITY_THRESHOLDS = [
|
||||
(-100, -70, "severe"),
|
||||
(-70, -50, "high"),
|
||||
(-50, -30, "moderate"),
|
||||
(30, 100, "growth"),
|
||||
(100, 500, "rapid_growth"),
|
||||
]
|
||||
|
||||
|
||||
def _classify_viirs_severity(pct_change: float):
|
||||
for lo, hi, label in _VIIRS_SEVERITY_THRESHOLDS:
|
||||
if lo <= pct_change <= hi:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def _load_viirs_stale_cache():
|
||||
"""Load stale cache if available (when GEE is not configured)."""
|
||||
if _VIIRS_CACHE_PATH.exists():
|
||||
try:
|
||||
cached = json.loads(_VIIRS_CACHE_PATH.read_text(encoding="utf-8"))
|
||||
with _data_lock:
|
||||
latest_data["viirs_change_nodes"] = cached
|
||||
_mark_fresh("viirs_change_nodes")
|
||||
logger.info(f"VIIRS change nodes: loaded {len(cached)} from stale cache")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=5)
|
||||
def fetch_viirs_change_nodes():
|
||||
"""Compute VIIRS nighttime radiance change nodes via GEE (optional)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("viirs_nightlights"):
|
||||
return
|
||||
|
||||
# Check cache freshness first
|
||||
if _VIIRS_CACHE_PATH.exists():
|
||||
age = time.time() - _VIIRS_CACHE_PATH.stat().st_mtime
|
||||
if age < _VIIRS_CACHE_MAX_AGE_S:
|
||||
try:
|
||||
cached = json.loads(_VIIRS_CACHE_PATH.read_text(encoding="utf-8"))
|
||||
with _data_lock:
|
||||
latest_data["viirs_change_nodes"] = cached
|
||||
_mark_fresh("viirs_change_nodes")
|
||||
logger.info(f"VIIRS change nodes: loaded {len(cached)} from cache (age {age:.0f}s)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"VIIRS cache read failed: {e}")
|
||||
|
||||
# Try importing earthengine-api (optional dependency)
|
||||
try:
|
||||
import ee
|
||||
except ImportError:
|
||||
logger.debug("earthengine-api not installed, skipping VIIRS change detection")
|
||||
_load_viirs_stale_cache()
|
||||
return
|
||||
|
||||
# Authenticate with service account
|
||||
sa_key_path = os.environ.get("GEE_SERVICE_ACCOUNT_KEY", "")
|
||||
if not sa_key_path:
|
||||
logger.debug("GEE_SERVICE_ACCOUNT_KEY not set, skipping VIIRS change detection")
|
||||
_load_viirs_stale_cache()
|
||||
return
|
||||
|
||||
try:
|
||||
credentials = ee.ServiceAccountCredentials(None, key_file=sa_key_path)
|
||||
ee.Initialize(credentials)
|
||||
except Exception as e:
|
||||
logger.error(f"GEE authentication failed: {e}")
|
||||
_load_viirs_stale_cache()
|
||||
return
|
||||
|
||||
# Compute change nodes for each AOI
|
||||
nodes = []
|
||||
viirs = ee.ImageCollection("NOAA/VIIRS/DNB/MONTHLY_V1/VCMCFG").select("avg_rad")
|
||||
|
||||
for aoi_name, s_lat, w_lng, n_lat, e_lng in _VIIRS_AOIS:
|
||||
try:
|
||||
aoi = ee.Geometry.Rectangle([w_lng, s_lat, e_lng, n_lat])
|
||||
|
||||
# Most recent available date
|
||||
now = ee.Date(datetime.utcnow().isoformat()[:10])
|
||||
|
||||
# Current: 12-month rolling mean ending now
|
||||
current = viirs.filterDate(now.advance(-12, "month"), now).mean().clip(aoi)
|
||||
|
||||
# Baseline: 12-month mean ending 12 months ago
|
||||
baseline = viirs.filterDate(
|
||||
now.advance(-24, "month"), now.advance(-12, "month")
|
||||
).mean().clip(aoi)
|
||||
|
||||
# Floor baseline at 0.5 nW/cm²/sr to avoid div-by-zero in dark areas
|
||||
baseline_safe = baseline.max(0.5)
|
||||
|
||||
# Percentage change
|
||||
change = current.subtract(baseline).divide(baseline_safe).multiply(100)
|
||||
|
||||
# Only keep pixels with >30% absolute change
|
||||
sig_mask = change.abs().gt(30)
|
||||
change_masked = change.updateMask(sig_mask)
|
||||
|
||||
# Sample up to 200 points per AOI
|
||||
samples = change_masked.sample(
|
||||
region=aoi, scale=500, numPixels=200, geometries=True
|
||||
)
|
||||
sample_list = samples.getInfo()
|
||||
|
||||
for feat in sample_list.get("features", []):
|
||||
coords = feat["geometry"]["coordinates"]
|
||||
pct = feat["properties"].get("avg_rad", 0)
|
||||
severity = _classify_viirs_severity(pct)
|
||||
if severity is None:
|
||||
continue
|
||||
nodes.append({
|
||||
"lat": round(coords[1], 4),
|
||||
"lng": round(coords[0], 4),
|
||||
"mean_change_pct": round(pct, 1),
|
||||
"severity": severity,
|
||||
"aoi_name": aoi_name,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"VIIRS change detection failed for {aoi_name}: {e}")
|
||||
continue
|
||||
|
||||
# Save to cache
|
||||
try:
|
||||
_VIIRS_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_VIIRS_CACHE_PATH.write_text(
|
||||
json.dumps(nodes, separators=(",", ":")), encoding="utf-8"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write VIIRS cache: {e}")
|
||||
|
||||
with _data_lock:
|
||||
latest_data["viirs_change_nodes"] = nodes
|
||||
if nodes:
|
||||
_mark_fresh("viirs_change_nodes")
|
||||
logger.info(f"VIIRS change nodes: {len(nodes)} nodes from {len(_VIIRS_AOIS)} AOIs")
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Fuel burn & CO2 emissions estimator for private jets.
|
||||
Based on manufacturer-published cruise fuel burn rates (GPH at long-range cruise).
|
||||
1 US gallon of Jet-A produces ~21.1 lbs (9.57 kg) of CO2.
|
||||
"""
|
||||
|
||||
JET_A_CO2_KG_PER_GALLON = 9.57
|
||||
|
||||
# ICAO type code -> gallons per hour at long-range cruise
|
||||
FUEL_BURN_GPH: dict[str, int] = {
|
||||
# Gulfstream
|
||||
"GLF6": 430, # G650/G650ER
|
||||
"G700": 480, # G700
|
||||
"GLF5": 390, # G550
|
||||
"GVSP": 400, # GV-SP
|
||||
"GLF4": 330, # G-IV
|
||||
# Bombardier
|
||||
"GL7T": 490, # Global 7500
|
||||
"GLEX": 430, # Global Express/6000/6500
|
||||
"GL5T": 420, # Global 5000/5500
|
||||
"CL35": 220, # Challenger 350
|
||||
"CL60": 310, # Challenger 604/605
|
||||
"CL30": 200, # Challenger 300
|
||||
"CL65": 320, # Challenger 650
|
||||
# Dassault
|
||||
"F7X": 350, # Falcon 7X
|
||||
"F8X": 370, # Falcon 8X
|
||||
"F900": 285, # Falcon 900/900EX/900LX
|
||||
"F2TH": 230, # Falcon 2000
|
||||
"FA50": 240, # Falcon 50
|
||||
# Cessna
|
||||
"CITX": 280, # Citation X
|
||||
"C68A": 195, # Citation Latitude
|
||||
"C700": 230, # Citation Longitude
|
||||
"C680": 220, # Citation Sovereign
|
||||
"C560": 190, # Citation Excel/XLS
|
||||
"C510": 75, # Citation Mustang
|
||||
"CJ3": 120, # CJ3
|
||||
"CJ4": 135, # CJ4
|
||||
# Boeing
|
||||
"B737": 850, # BBJ (737)
|
||||
"B738": 920, # BBJ2 (737-800)
|
||||
"B752": 1100, # 757-200
|
||||
"B762": 1400, # 767-200
|
||||
"B788": 1200, # 787-8
|
||||
# Airbus
|
||||
"A318": 780, # ACJ318
|
||||
"A319": 850, # ACJ319
|
||||
"A320": 900, # ACJ320
|
||||
"A343": 1800, # A340-300
|
||||
"A346": 2100, # A340-600
|
||||
# Pilatus
|
||||
"PC24": 115, # PC-24
|
||||
"PC12": 60, # PC-12
|
||||
# Embraer
|
||||
"E55P": 185, # Legacy 500
|
||||
"E135": 300, # Legacy 600/650
|
||||
"E50P": 135, # Phenom 300
|
||||
"E500": 80, # Phenom 100
|
||||
# Learjet
|
||||
"LJ60": 195, # Learjet 60
|
||||
"LJ75": 185, # Learjet 75
|
||||
"LJ45": 175, # Learjet 45
|
||||
# Hawker
|
||||
"H25B": 210, # Hawker 800/800XP
|
||||
"H25C": 215, # Hawker 900XP
|
||||
# Beechcraft
|
||||
"B350": 100, # King Air 350
|
||||
"B200": 80, # King Air 200/250
|
||||
}
|
||||
|
||||
# Common string names -> ICAO type code
|
||||
_ALIASES: dict[str, str] = {
|
||||
"Gulfstream G650": "GLF6", "Gulfstream G650ER": "GLF6", "G650": "GLF6", "G650ER": "GLF6",
|
||||
"Gulfstream G700": "G700",
|
||||
"Gulfstream G550": "GLF5", "G550": "GLF5", "G500": "GLF5",
|
||||
"Gulfstream GV": "GVSP", "Gulfstream G-V": "GVSP", "GV": "GVSP",
|
||||
"Gulfstream G-IV": "GLF4", "Gulfstream GIV": "GLF4", "G450": "GLF4",
|
||||
"Global 7500": "GL7T", "Bombardier Global 7500": "GL7T",
|
||||
"Global 6000": "GLEX", "Global Express": "GLEX", "Bombardier Global 6000": "GLEX",
|
||||
"Global 5000": "GL5T",
|
||||
"Challenger 350": "CL35", "Challenger 300": "CL30",
|
||||
"Challenger 604": "CL60", "Challenger 605": "CL60", "Challenger 650": "CL65",
|
||||
"Falcon 7X": "F7X", "Dassault Falcon 7X": "F7X",
|
||||
"Falcon 8X": "F8X", "Dassault Falcon 8X": "F8X",
|
||||
"Falcon 900": "F900", "Falcon 900LX": "F900", "Falcon 900EX": "F900",
|
||||
"Falcon 2000": "F2TH",
|
||||
"Citation X": "CITX", "Citation Latitude": "C68A", "Citation Longitude": "C700",
|
||||
"Boeing 757-200": "B752", "757-200": "B752", "Boeing 757": "B752",
|
||||
"Boeing 767-200": "B762", "767-200": "B762", "Boeing 767": "B762",
|
||||
"Boeing 787-8": "B788", "Boeing 787": "B788",
|
||||
"Boeing 737": "B737", "737 BBJ": "B737", "BBJ": "B737",
|
||||
"Airbus A340-300": "A343", "A340-300": "A343", "A340": "A343",
|
||||
"Airbus A318": "A318",
|
||||
"Pilatus PC-24": "PC24", "PC-24": "PC24",
|
||||
"Legacy 500": "E55P", "Legacy 600": "E135", "Phenom 300": "E50P",
|
||||
"Learjet 60": "LJ60", "Learjet 75": "LJ75",
|
||||
"Hawker 800": "H25B", "Hawker 900XP": "H25C",
|
||||
"King Air 350": "B350", "King Air 200": "B200",
|
||||
}
|
||||
|
||||
|
||||
def get_emissions_info(model: str) -> dict | None:
|
||||
"""
|
||||
Given an aircraft model string (ICAO type code or common name),
|
||||
return emissions info dict or None if unknown.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
model_clean = model.strip()
|
||||
# Try direct ICAO code match first
|
||||
gph = FUEL_BURN_GPH.get(model_clean.upper())
|
||||
if gph is None:
|
||||
# Try alias lookup
|
||||
code = _ALIASES.get(model_clean)
|
||||
if code:
|
||||
gph = FUEL_BURN_GPH.get(code)
|
||||
if gph is None:
|
||||
# Fuzzy: check if any alias is a substring
|
||||
model_lower = model_clean.lower()
|
||||
for alias, code in _ALIASES.items():
|
||||
if alias.lower() in model_lower or model_lower in alias.lower():
|
||||
gph = FUEL_BURN_GPH.get(code)
|
||||
if gph:
|
||||
break
|
||||
if gph is None:
|
||||
return None
|
||||
return {
|
||||
"fuel_gph": gph,
|
||||
"co2_kg_per_hour": round(gph * JET_A_CO2_KG_PER_GALLON, 1),
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"""EUvsDisinfo FIMI (Foreign Information Manipulation & Interference) fetcher.
|
||||
|
||||
Parses the EUvsDisinfo RSS feed to extract disinformation narratives,
|
||||
debunked claims, threat actor mentions, and target country references.
|
||||
Refreshes every 12 hours (FIMI data updates weekly).
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import feedparser
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
_FIMI_FEED_URL = "https://euvsdisinfo.eu/feed/"
|
||||
|
||||
# ── Threat actor keywords ──────────────────────────────────────────────────
|
||||
# Map of keyword → canonical actor name. Checked case-insensitively.
|
||||
_THREAT_ACTORS: dict[str, str] = {
|
||||
"russia": "Russia",
|
||||
"russian": "Russia",
|
||||
"kremlin": "Russia",
|
||||
"pro-kremlin": "Russia",
|
||||
"moscow": "Russia",
|
||||
"china": "China",
|
||||
"chinese": "China",
|
||||
"beijing": "China",
|
||||
"iran": "Iran",
|
||||
"iranian": "Iran",
|
||||
"tehran": "Iran",
|
||||
"north korea": "North Korea",
|
||||
"pyongyang": "North Korea",
|
||||
"dprk": "North Korea",
|
||||
"belarus": "Belarus",
|
||||
"belarusian": "Belarus",
|
||||
"minsk": "Belarus",
|
||||
}
|
||||
|
||||
# ── Target country/region keywords ─────────────────────────────────────────
|
||||
_TARGET_KEYWORDS: dict[str, str] = {
|
||||
"ukraine": "Ukraine",
|
||||
"kyiv": "Ukraine",
|
||||
"moldova": "Moldova",
|
||||
"georgia": "Georgia",
|
||||
"tbilisi": "Georgia",
|
||||
"eu": "EU",
|
||||
"european union": "EU",
|
||||
"europe": "Europe",
|
||||
"nato": "NATO",
|
||||
"united states": "United States",
|
||||
"usa": "United States",
|
||||
"germany": "Germany",
|
||||
"france": "France",
|
||||
"poland": "Poland",
|
||||
"baltic": "Baltics",
|
||||
"lithuania": "Baltics",
|
||||
"latvia": "Baltics",
|
||||
"estonia": "Baltics",
|
||||
"romania": "Romania",
|
||||
"czech": "Czech Republic",
|
||||
"slovakia": "Slovakia",
|
||||
"armenia": "Armenia",
|
||||
"africa": "Africa",
|
||||
"middle east": "Middle East",
|
||||
"syria": "Syria",
|
||||
"israel": "Israel",
|
||||
"serbia": "Serbia",
|
||||
"india": "India",
|
||||
"brazil": "Brazil",
|
||||
}
|
||||
|
||||
# ── Disinformation topic keywords (for cross-referencing news) ─────────────
|
||||
_DISINFO_TOPICS = [
|
||||
"sanctions",
|
||||
"energy crisis",
|
||||
"gas supply",
|
||||
"nuclear threat",
|
||||
"nato expansion",
|
||||
"biolab",
|
||||
"biological weapon",
|
||||
"provocation",
|
||||
"false flag",
|
||||
"staged",
|
||||
"nazi",
|
||||
"genocide",
|
||||
"referendum",
|
||||
"regime change",
|
||||
"coup",
|
||||
"puppet government",
|
||||
"election interference",
|
||||
"election meddling",
|
||||
"voter fraud",
|
||||
"migrant invasion",
|
||||
"refugee crisis",
|
||||
"civil war",
|
||||
"food crisis",
|
||||
"grain deal",
|
||||
]
|
||||
|
||||
# Regex for extracting debunked report URLs from feed HTML
|
||||
_REPORT_URL_RE = re.compile(
|
||||
r'https?://euvsdisinfo\.eu/report/[a-z0-9\-]+/?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Regex for extracting the claim title from a report URL slug
|
||||
_SLUG_RE = re.compile(r'/report/([a-z0-9\-]+)/?$', re.IGNORECASE)
|
||||
|
||||
|
||||
def _slug_to_title(url: str) -> str:
|
||||
"""Convert a report URL slug to a human-readable title."""
|
||||
m = _SLUG_RE.search(url)
|
||||
if not m:
|
||||
return url
|
||||
return m.group(1).replace("-", " ").title()
|
||||
|
||||
|
||||
def _count_mentions(text: str, keywords: dict[str, str]) -> dict[str, int]:
|
||||
"""Count keyword mentions, mapping to canonical names."""
|
||||
counts: dict[str, int] = {}
|
||||
text_lower = text.lower()
|
||||
for kw, canonical in keywords.items():
|
||||
# Word-boundary match, case-insensitive
|
||||
pattern = r'\b' + re.escape(kw) + r'\b'
|
||||
matches = re.findall(pattern, text_lower)
|
||||
if matches:
|
||||
counts[canonical] = counts.get(canonical, 0) + len(matches)
|
||||
return counts
|
||||
|
||||
|
||||
def _extract_disinfo_keywords(text: str) -> list[str]:
|
||||
"""Return which disinformation topic keywords appear in the text."""
|
||||
text_lower = text.lower()
|
||||
found = []
|
||||
for topic in _DISINFO_TOPICS:
|
||||
if topic in text_lower:
|
||||
found.append(topic)
|
||||
return found
|
||||
|
||||
|
||||
def _is_major_wave(narratives: list[dict], targets: dict[str, int]) -> bool:
|
||||
"""Heuristic: detect a 'major disinformation wave'.
|
||||
|
||||
Triggers when:
|
||||
- 3+ narratives in the feed mention the same target, OR
|
||||
- A single target has 10+ total mentions across all narratives, OR
|
||||
- 5+ distinct debunked claims extracted in one fetch
|
||||
"""
|
||||
if not narratives:
|
||||
return False
|
||||
|
||||
# Check per-target narrative count
|
||||
target_narrative_counts: dict[str, int] = {}
|
||||
total_claims = 0
|
||||
for n in narratives:
|
||||
for t in n.get("targets", []):
|
||||
target_narrative_counts[t] = target_narrative_counts.get(t, 0) + 1
|
||||
total_claims += len(n.get("claims", []))
|
||||
|
||||
if any(c >= 3 for c in target_narrative_counts.values()):
|
||||
return True
|
||||
if any(c >= 10 for c in targets.values()):
|
||||
return True
|
||||
if total_claims >= 5:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=5)
|
||||
def fetch_fimi():
|
||||
"""Fetch and parse the EUvsDisinfo RSS feed."""
|
||||
try:
|
||||
resp = fetch_with_curl(_FIMI_FEED_URL, timeout=15)
|
||||
feed = feedparser.parse(resp.text)
|
||||
except Exception as e:
|
||||
logger.warning(f"FIMI feed fetch failed: {e}")
|
||||
return
|
||||
|
||||
if not feed.entries:
|
||||
logger.warning("FIMI feed: no entries found")
|
||||
return
|
||||
|
||||
narratives = []
|
||||
all_claims: list[dict] = []
|
||||
agg_actors: dict[str, int] = {}
|
||||
agg_targets: dict[str, int] = {}
|
||||
all_disinfo_kw: set[str] = set()
|
||||
|
||||
for entry in feed.entries[:15]: # Cap at 15 entries
|
||||
title = entry.get("title", "")
|
||||
link = entry.get("link", "")
|
||||
published = entry.get("published", "")
|
||||
summary_html = entry.get("summary", "") or entry.get("description", "")
|
||||
|
||||
# Strip HTML tags for text analysis
|
||||
summary_text = re.sub(r"<[^>]+>", " ", summary_html)
|
||||
summary_text = re.sub(r"\s+", " ", summary_text).strip()
|
||||
full_text = f"{title} {summary_text}"
|
||||
|
||||
# Extract debunked report URLs
|
||||
report_urls = list(set(_REPORT_URL_RE.findall(summary_html)))
|
||||
claims = [{"url": url, "title": _slug_to_title(url)} for url in report_urls]
|
||||
all_claims.extend(claims)
|
||||
|
||||
# Count threat actors
|
||||
actors = _count_mentions(full_text, _THREAT_ACTORS)
|
||||
for actor, count in actors.items():
|
||||
agg_actors[actor] = agg_actors.get(actor, 0) + count
|
||||
|
||||
# Count target countries
|
||||
targets = _count_mentions(full_text, _TARGET_KEYWORDS)
|
||||
for target, count in targets.items():
|
||||
agg_targets[target] = agg_targets.get(target, 0) + count
|
||||
|
||||
# Extract disinfo topic keywords
|
||||
disinfo_kw = _extract_disinfo_keywords(full_text)
|
||||
all_disinfo_kw.update(disinfo_kw)
|
||||
|
||||
# Truncate summary for storage
|
||||
snippet = summary_text[:300] + ("..." if len(summary_text) > 300 else "")
|
||||
|
||||
narratives.append({
|
||||
"title": title,
|
||||
"link": link,
|
||||
"published": published,
|
||||
"snippet": snippet,
|
||||
"claims": claims,
|
||||
"actors": list(actors.keys()),
|
||||
"targets": list(targets.keys()),
|
||||
"disinfo_keywords": disinfo_kw,
|
||||
})
|
||||
|
||||
# Sort actors and targets by count (descending)
|
||||
sorted_actors = dict(sorted(agg_actors.items(), key=lambda x: x[1], reverse=True))
|
||||
sorted_targets = dict(sorted(agg_targets.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
# Deduplicate claims
|
||||
seen_urls: set[str] = set()
|
||||
unique_claims = []
|
||||
for c in all_claims:
|
||||
if c["url"] not in seen_urls:
|
||||
seen_urls.add(c["url"])
|
||||
unique_claims.append(c)
|
||||
|
||||
major_wave = _is_major_wave(narratives, sorted_targets)
|
||||
|
||||
fimi_data = {
|
||||
"narratives": narratives,
|
||||
"claims": unique_claims,
|
||||
"threat_actors": sorted_actors,
|
||||
"targets": sorted_targets,
|
||||
"disinfo_keywords": sorted(all_disinfo_kw),
|
||||
"major_wave": major_wave,
|
||||
"major_wave_target": (
|
||||
max(sorted_targets, key=sorted_targets.get) if major_wave and sorted_targets else None
|
||||
),
|
||||
"last_fetched": datetime.now(timezone.utc).isoformat(),
|
||||
"source": "EUvsDisinfo",
|
||||
"source_url": "https://euvsdisinfo.eu",
|
||||
}
|
||||
|
||||
with _data_lock:
|
||||
latest_data["fimi"] = fimi_data
|
||||
_mark_fresh("fimi")
|
||||
logger.info(
|
||||
f"FIMI fetch complete: {len(narratives)} narratives, "
|
||||
f"{len(unique_claims)} claims, "
|
||||
f"{len(sorted_actors)} actors, "
|
||||
f"major_wave={major_wave}"
|
||||
)
|
||||
@@ -1,97 +1,161 @@
|
||||
"""Financial data fetchers — defense stocks and oil prices.
|
||||
|
||||
Uses yfinance batch download to minimise Yahoo Finance requests and avoid rate limiting.
|
||||
"""
|
||||
import logging
|
||||
import yfinance as yf
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_YFINANCE_REQUEST_DELAY_SECONDS = 0.5
|
||||
_YFINANCE_REQUEST_JITTER_SECONDS = 0.2
|
||||
|
||||
def _batch_fetch(symbols: list[str], period: str = "5d") -> dict:
|
||||
"""Fetch multiple tickers in a single yfinance request. Returns {symbol: {price, change_percent, up}}."""
|
||||
TICKERS_DEFENSE = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||
TICKERS_TECH = ["NVDA", "AMD", "TSM", "INTC", "GOOGL", "AMZN", "MSFT", "AAPL", "TSLA", "META", "NFLX", "SMCI", "ARM", "ASML"]
|
||||
TICKERS_CRYPTO = [
|
||||
("BTC", "BINANCE:BTCUSDT", "BTC-USD"),
|
||||
("ETH", "BINANCE:ETHUSDT", "ETH-USD"),
|
||||
("SOL", "BINANCE:SOLUSDT", "SOL-USD"),
|
||||
("XRP", "BINANCE:XRPUSDT", "XRP-USD"),
|
||||
("ADA", "BINANCE:ADAUSDT", "ADA-USD"),
|
||||
]
|
||||
|
||||
# Ticker priority for high-frequency updates (we update these every tick)
|
||||
PRIORITY_SYMBOLS = ["BTC", "ETH", "NVDA", "PLTR"]
|
||||
|
||||
# Persistence for state between short-lived scheduler ticks
|
||||
_last_fetch_results = {}
|
||||
_last_fetch_time = 0.0
|
||||
_rotating_index = 0
|
||||
_executor = ThreadPoolExecutor(max_workers=10)
|
||||
|
||||
|
||||
def _fetch_finnhub_quote(symbol: str, api_key: str):
|
||||
"""Fetch from Finnhub. Returns (symbol, data) or (symbol, None)."""
|
||||
url = f"https://finnhub.io/api/v1/quote?symbol={symbol}&token={api_key}"
|
||||
try:
|
||||
hist = yf.download(symbols, period=period, auto_adjust=True, progress=False)
|
||||
if hist.empty:
|
||||
return {}
|
||||
close = hist["Close"]
|
||||
result = {}
|
||||
for sym in symbols:
|
||||
try:
|
||||
col = close[sym] if len(symbols) > 1 else close
|
||||
col = col.dropna()
|
||||
if len(col) < 1:
|
||||
continue
|
||||
current = float(col.iloc[-1])
|
||||
prev = float(col.iloc[0]) if len(col) > 1 else current
|
||||
change = ((current - prev) / prev * 100) if prev else 0
|
||||
result[sym] = {
|
||||
"price": round(current, 2),
|
||||
"change_percent": round(change, 2),
|
||||
"up": bool(change >= 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not parse {sym}: {e}")
|
||||
return result
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
if "c" not in data or data["c"] == 0:
|
||||
return symbol, None
|
||||
current = float(data["c"])
|
||||
change_p = float(data.get("dp", 0.0) or 0.0)
|
||||
return symbol, {
|
||||
"price": round(current, 2),
|
||||
"change_percent": round(change_p, 2),
|
||||
"up": bool(change_p >= 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Batch fetch failed: {e}")
|
||||
return {}
|
||||
logger.debug(f"Finnhub error for {symbol}: {e}")
|
||||
return symbol, None
|
||||
|
||||
|
||||
_STOCK_TICKERS = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||
_OIL_MAP = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
|
||||
_ALL_TICKERS = _STOCK_TICKERS + list(_OIL_MAP.values())
|
||||
|
||||
_MARKET_COOLDOWN_SECONDS = 1800 # fetch at most once every 30 minutes
|
||||
_last_market_fetch: float = 0.0
|
||||
def _fetch_yfinance_single(symbol: str, period: str = "2d"):
|
||||
"""Fetch from yfinance. Returns (symbol, data) or (symbol, None)."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period=period)
|
||||
if len(hist) >= 1:
|
||||
current_price = hist["Close"].iloc[-1]
|
||||
prev_close = hist["Close"].iloc[0] if len(hist) > 1 else current_price
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
current_price_f = float(current_price)
|
||||
change_percent_f = float(change_percent)
|
||||
if not math.isfinite(current_price_f) or not math.isfinite(change_percent_f):
|
||||
return symbol, None
|
||||
return symbol, {
|
||||
"price": round(current_price_f, 2),
|
||||
"change_percent": round(change_percent_f, 2),
|
||||
"up": bool(change_percent_f >= 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Yfinance error for {symbol}: {e}")
|
||||
return symbol, None
|
||||
|
||||
|
||||
def _fetch_all_market_data():
|
||||
"""Single yfinance download for all market tickers to avoid rate limiting."""
|
||||
raw = _batch_fetch(_ALL_TICKERS, period="5d")
|
||||
stocks = {sym: raw[sym] for sym in _STOCK_TICKERS if sym in raw}
|
||||
oil = {name: raw[sym] for name, sym in _OIL_MAP.items() if sym in raw}
|
||||
return stocks, oil
|
||||
@with_retry(max_retries=1, base_delay=1)
|
||||
def fetch_financial_markets():
|
||||
"""Fetches full market list with smart throttling (3s for Finnhub, 60s for yfinance)."""
|
||||
global _last_fetch_time, _last_fetch_results, _rotating_index
|
||||
|
||||
finnhub_key = os.getenv("FINNHUB_API_KEY", "").strip()
|
||||
use_finnhub = bool(finnhub_key)
|
||||
|
||||
now = time.time()
|
||||
# Throttle logic: 3s for Finnhub, 60s for yfinance fallback
|
||||
throttle_s = 3.0 if use_finnhub else 60.0
|
||||
|
||||
if now - _last_fetch_time < throttle_s and _last_fetch_results:
|
||||
return # Skip if too frequent
|
||||
|
||||
_last_fetch_time = now
|
||||
|
||||
# Prepare symbol lists
|
||||
all_crypto = {label: (f_sym, y_sym) for label, f_sym, y_sym in TICKERS_CRYPTO}
|
||||
all_stocks = TICKERS_TECH + TICKERS_DEFENSE
|
||||
|
||||
subset_to_fetch = []
|
||||
|
||||
if use_finnhub:
|
||||
# Finnhub Free Limit: 60/min.
|
||||
# Ticking every 3s = 20 ticks/min.
|
||||
# To stay safe, we fetch only ~3 items per tick.
|
||||
# Priority items (BTC, ETH) + 1 rotating item.
|
||||
subset_to_fetch = ["BINANCE:BTCUSDT", "BINANCE:ETHUSDT"]
|
||||
|
||||
# Determine rotating ticker
|
||||
all_other_symbols = []
|
||||
for sym in all_stocks:
|
||||
all_other_symbols.append(sym)
|
||||
for label, (f_sym, y_sym) in all_crypto.items():
|
||||
if label not in ["BTC", "ETH"]:
|
||||
all_other_symbols.append(f_sym)
|
||||
|
||||
if all_other_symbols:
|
||||
rotated = all_other_symbols[_rotating_index % len(all_other_symbols)]
|
||||
subset_to_fetch.append(rotated)
|
||||
_rotating_index += 1
|
||||
|
||||
# Concurrently fetch
|
||||
futures = [_executor.submit(_fetch_finnhub_quote, s, finnhub_key) for s in subset_to_fetch]
|
||||
for f in futures:
|
||||
sym, data = f.result()
|
||||
if data:
|
||||
# Map back to readable label if it was crypto
|
||||
label = sym
|
||||
for l, (fs, ys) in all_crypto.items():
|
||||
if fs == sym:
|
||||
label = l
|
||||
break
|
||||
_last_fetch_results[label] = data
|
||||
else:
|
||||
# Yahoo Finance Fallback - fetch all (once per minute)
|
||||
logger.info("Finnhub key missing, using Yahoo Finance 60s update cycle.")
|
||||
to_fetch = all_stocks + [y_sym for l, (fs, y_sym) in all_crypto.items()]
|
||||
futures = [_executor.submit(_fetch_yfinance_single, s) for s in to_fetch]
|
||||
for f in futures:
|
||||
sym, data = f.result()
|
||||
if data:
|
||||
# Map back to readable label if it was crypto
|
||||
label = sym
|
||||
for l, (fs, ys) in all_crypto.items():
|
||||
if ys == sym:
|
||||
label = l
|
||||
break
|
||||
_last_fetch_results[label] = data
|
||||
|
||||
@with_retry(max_retries=2, base_delay=10)
|
||||
def fetch_defense_stocks():
|
||||
global _last_market_fetch
|
||||
import time
|
||||
if time.time() - _last_market_fetch < _MARKET_COOLDOWN_SECONDS:
|
||||
if not _last_fetch_results:
|
||||
return
|
||||
try:
|
||||
stocks, oil = _fetch_all_market_data()
|
||||
if stocks:
|
||||
_last_market_fetch = time.time()
|
||||
with _data_lock:
|
||||
latest_data['stocks'] = stocks
|
||||
if oil:
|
||||
latest_data['oil'] = oil
|
||||
_mark_fresh("stocks")
|
||||
if oil:
|
||||
_mark_fresh("oil")
|
||||
logger.info(f"Markets: {len(stocks)} stocks, {len(oil)} oil tickers")
|
||||
else:
|
||||
logger.warning("Markets: empty result from yfinance (rate limited?)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching market data: {e}")
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=10)
|
||||
def fetch_oil_prices():
|
||||
# Oil is now fetched together with stocks in fetch_defense_stocks to use a single request.
|
||||
# This function is kept for scheduler compatibility but is a no-op if stocks already ran.
|
||||
|
||||
with _data_lock:
|
||||
if latest_data.get('oil'):
|
||||
return # Already populated by fetch_defense_stocks
|
||||
try:
|
||||
_, oil = _fetch_all_market_data()
|
||||
if oil:
|
||||
with _data_lock:
|
||||
latest_data['oil'] = oil
|
||||
_mark_fresh("oil")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching oil: {e}")
|
||||
latest_data["stocks"] = dict(_last_fetch_results)
|
||||
latest_data["financial_source"] = "finnhub" if use_finnhub else "yfinance"
|
||||
_mark_fresh("stocks")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Commercial flight fetching — ADS-B, OpenSky, supplemental sources, routes,
|
||||
trail accumulation, GPS jamming detection, and holding pattern detection."""
|
||||
|
||||
import copy
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
@@ -8,19 +10,23 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import random
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from cachetools import TTLCache
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.plane_alert import enrich_with_plane_alert, enrich_with_tracked_names
|
||||
from services.fetchers.emissions import get_emissions_info
|
||||
from services.fetchers.retry import with_retry
|
||||
from services.constants import GPS_JAMMING_NACP_THRESHOLD, GPS_JAMMING_MIN_RATIO, GPS_JAMMING_MIN_AIRCRAFT
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
# Pre-compiled regex patterns for airline code extraction (used in hot loop)
|
||||
_RE_AIRLINE_CODE_1 = re.compile(r'^([A-Z]{3})\d')
|
||||
_RE_AIRLINE_CODE_2 = re.compile(r'^([A-Z]{3})[A-Z\d]')
|
||||
_RE_AIRLINE_CODE_1 = re.compile(r"^([A-Z]{3})\d")
|
||||
_RE_AIRLINE_CODE_2 = re.compile(r"^([A-Z]{3})[A-Z\d]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenSky Network API Client (OAuth2)
|
||||
@@ -39,7 +45,7 @@ class OpenSkyClient:
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
try:
|
||||
r = requests.post(url, data=data, timeout=10)
|
||||
@@ -51,13 +57,20 @@ class OpenSkyClient:
|
||||
return self.token
|
||||
else:
|
||||
logger.error(f"OpenSky Auth Failed: {r.status_code} {r.text}")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
) as e:
|
||||
logger.error(f"OpenSky Auth Exception: {e}")
|
||||
return None
|
||||
|
||||
|
||||
opensky_client = OpenSkyClient(
|
||||
client_id=os.environ.get("OPENSKY_CLIENT_ID", ""),
|
||||
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "")
|
||||
client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
# Throttling and caching for OpenSky (400 req/day limit)
|
||||
@@ -68,46 +81,173 @@ cached_opensky_flights = []
|
||||
# Supplemental ADS-B sources for blind-spot gap-filling
|
||||
# ---------------------------------------------------------------------------
|
||||
_BLIND_SPOT_REGIONS = [
|
||||
{"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250},
|
||||
{"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250},
|
||||
{"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250},
|
||||
{"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250},
|
||||
{"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250},
|
||||
{"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250},
|
||||
{"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250},
|
||||
{"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250},
|
||||
{"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250},
|
||||
{"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250},
|
||||
{"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250},
|
||||
{"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250},
|
||||
{"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250},
|
||||
{"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250},
|
||||
{"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250},
|
||||
{"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250},
|
||||
]
|
||||
_SUPPLEMENTAL_FETCH_INTERVAL = 120
|
||||
# The blind-spot supplement previously burst several airplanes.live point
|
||||
# queries in parallel and triggered repeated 429s in real startup logs, so we
|
||||
# keep it on a long cache interval and pace each regional point query serially.
|
||||
_SUPPLEMENTAL_FETCH_INTERVAL = 1800
|
||||
_AIRPLANES_LIVE_DELAY_SECONDS = 1.2
|
||||
_AIRPLANES_LIVE_DELAY_JITTER_SECONDS = 0.4
|
||||
last_supplemental_fetch = 0
|
||||
cached_supplemental_flights = []
|
||||
|
||||
# Helicopter type codes (backend classification)
|
||||
_HELI_TYPES_BACKEND = {
|
||||
"R22", "R44", "R66", "B06", "B06T", "B204", "B205", "B206", "B212", "B222", "B230",
|
||||
"B407", "B412", "B427", "B429", "B430", "B505", "B525",
|
||||
"AS32", "AS35", "AS50", "AS55", "AS65",
|
||||
"EC20", "EC25", "EC30", "EC35", "EC45", "EC55", "EC75",
|
||||
"H125", "H130", "H135", "H145", "H155", "H160", "H175", "H215", "H225",
|
||||
"S55", "S58", "S61", "S64", "S70", "S76", "S92",
|
||||
"A109", "A119", "A139", "A169", "A189", "AW09",
|
||||
"MD52", "MD60", "MDHI", "MD90", "NOTR",
|
||||
"B47G", "HUEY", "GAMA", "CABR", "EXE",
|
||||
"R22",
|
||||
"R44",
|
||||
"R66",
|
||||
"B06",
|
||||
"B06T",
|
||||
"B204",
|
||||
"B205",
|
||||
"B206",
|
||||
"B212",
|
||||
"B222",
|
||||
"B230",
|
||||
"B407",
|
||||
"B412",
|
||||
"B427",
|
||||
"B429",
|
||||
"B430",
|
||||
"B505",
|
||||
"B525",
|
||||
"AS32",
|
||||
"AS35",
|
||||
"AS50",
|
||||
"AS55",
|
||||
"AS65",
|
||||
"EC20",
|
||||
"EC25",
|
||||
"EC30",
|
||||
"EC35",
|
||||
"EC45",
|
||||
"EC55",
|
||||
"EC75",
|
||||
"H125",
|
||||
"H130",
|
||||
"H135",
|
||||
"H145",
|
||||
"H155",
|
||||
"H160",
|
||||
"H175",
|
||||
"H215",
|
||||
"H225",
|
||||
"S55",
|
||||
"S58",
|
||||
"S61",
|
||||
"S64",
|
||||
"S70",
|
||||
"S76",
|
||||
"S92",
|
||||
"A109",
|
||||
"A119",
|
||||
"A139",
|
||||
"A169",
|
||||
"A189",
|
||||
"AW09",
|
||||
"MD52",
|
||||
"MD60",
|
||||
"MDHI",
|
||||
"MD90",
|
||||
"NOTR",
|
||||
"B47G",
|
||||
"HUEY",
|
||||
"GAMA",
|
||||
"CABR",
|
||||
"EXE",
|
||||
}
|
||||
|
||||
# Private jet ICAO type designator codes
|
||||
PRIVATE_JET_TYPES = {
|
||||
"G150", "G200", "G280", "GLEX", "G500", "G550", "G600", "G650", "G700",
|
||||
"GLF2", "GLF3", "GLF4", "GLF5", "GLF6", "GL5T", "GL7T", "GV", "GIV",
|
||||
"CL30", "CL35", "CL60", "BD70", "BD10", "GL5T", "GL7T",
|
||||
"CRJ1", "CRJ2",
|
||||
"C25A", "C25B", "C25C", "C500", "C501", "C510", "C525", "C526",
|
||||
"C550", "C560", "C56X", "C680", "C68A", "C700", "C750",
|
||||
"FA10", "FA20", "FA50", "FA7X", "FA8X", "F900", "F2TH", "ASTR",
|
||||
"E35L", "E545", "E550", "E55P", "LEGA", "PH10", "PH30",
|
||||
"LJ23", "LJ24", "LJ25", "LJ28", "LJ31", "LJ35", "LJ36",
|
||||
"LJ40", "LJ45", "LJ55", "LJ60", "LJ70", "LJ75",
|
||||
"H25A", "H25B", "H25C", "HA4T", "BE40", "PRM1",
|
||||
"HDJT", "PC24", "EA50", "SF50", "GALX",
|
||||
"G150",
|
||||
"G200",
|
||||
"G280",
|
||||
"GLEX",
|
||||
"G500",
|
||||
"G550",
|
||||
"G600",
|
||||
"G650",
|
||||
"G700",
|
||||
"GLF2",
|
||||
"GLF3",
|
||||
"GLF4",
|
||||
"GLF5",
|
||||
"GLF6",
|
||||
"GL5T",
|
||||
"GL7T",
|
||||
"GV",
|
||||
"GIV",
|
||||
"CL30",
|
||||
"CL35",
|
||||
"CL60",
|
||||
"BD70",
|
||||
"BD10",
|
||||
"GL5T",
|
||||
"GL7T",
|
||||
"CRJ1",
|
||||
"CRJ2",
|
||||
"C25A",
|
||||
"C25B",
|
||||
"C25C",
|
||||
"C500",
|
||||
"C501",
|
||||
"C510",
|
||||
"C525",
|
||||
"C526",
|
||||
"C550",
|
||||
"C560",
|
||||
"C56X",
|
||||
"C680",
|
||||
"C68A",
|
||||
"C700",
|
||||
"C750",
|
||||
"FA10",
|
||||
"FA20",
|
||||
"FA50",
|
||||
"FA7X",
|
||||
"FA8X",
|
||||
"F900",
|
||||
"F2TH",
|
||||
"ASTR",
|
||||
"E35L",
|
||||
"E545",
|
||||
"E550",
|
||||
"E55P",
|
||||
"LEGA",
|
||||
"PH10",
|
||||
"PH30",
|
||||
"LJ23",
|
||||
"LJ24",
|
||||
"LJ25",
|
||||
"LJ28",
|
||||
"LJ31",
|
||||
"LJ35",
|
||||
"LJ36",
|
||||
"LJ40",
|
||||
"LJ45",
|
||||
"LJ55",
|
||||
"LJ60",
|
||||
"LJ70",
|
||||
"LJ75",
|
||||
"H25A",
|
||||
"H25B",
|
||||
"H25C",
|
||||
"HA4T",
|
||||
"BE40",
|
||||
"PRM1",
|
||||
"HDJT",
|
||||
"PC24",
|
||||
"EA50",
|
||||
"SF50",
|
||||
"GALX",
|
||||
}
|
||||
|
||||
# Flight trails state
|
||||
@@ -127,35 +267,59 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
|
||||
now = time.time()
|
||||
if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL:
|
||||
return [f for f in cached_supplemental_flights
|
||||
if f.get("hex", "").lower().strip() not in seen_hex]
|
||||
return [
|
||||
f
|
||||
for f in cached_supplemental_flights
|
||||
if f.get("hex", "").lower().strip() not in seen_hex
|
||||
]
|
||||
|
||||
new_supplemental = []
|
||||
supplemental_hex = set()
|
||||
|
||||
def _fetch_airplaneslive(region):
|
||||
try:
|
||||
url = (f"https://api.airplanes.live/v2/point/"
|
||||
f"{region['lat']}/{region['lon']}/{region['radius_nm']}")
|
||||
url = (
|
||||
f"https://api.airplanes.live/v2/point/"
|
||||
f"{region['lat']}/{region['lon']}/{region['radius_nm']}"
|
||||
)
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
return data.get("ac", [])
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.debug(f"airplanes.live {region['name']} failed: {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(_fetch_airplaneslive, _BLIND_SPOT_REGIONS))
|
||||
for region_flights in results:
|
||||
for idx, region in enumerate(_BLIND_SPOT_REGIONS):
|
||||
region_flights = _fetch_airplaneslive(region)
|
||||
for f in region_flights:
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h and h not in seen_hex and h not in supplemental_hex:
|
||||
f["supplemental_source"] = "airplanes.live"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
if idx < len(_BLIND_SPOT_REGIONS) - 1:
|
||||
time.sleep(
|
||||
_AIRPLANES_LIVE_DELAY_SECONDS
|
||||
+ random.uniform(0.0, _AIRPLANES_LIVE_DELAY_JITTER_SECONDS)
|
||||
)
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning(f"airplanes.live supplemental fetch failed: {e}")
|
||||
|
||||
ap_count = len(new_supplemental)
|
||||
@@ -163,8 +327,10 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
try:
|
||||
for region in _BLIND_SPOT_REGIONS:
|
||||
try:
|
||||
url = (f"https://opendata.adsb.fi/api/v3/lat/"
|
||||
f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}")
|
||||
url = (
|
||||
f"https://opendata.adsb.fi/api/v3/lat/"
|
||||
f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}"
|
||||
)
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
@@ -174,10 +340,25 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
f["supplemental_source"] = "adsb.fi"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.debug(f"adsb.fi {region['name']} failed: {e}")
|
||||
time.sleep(1.1)
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning(f"adsb.fi supplemental fetch failed: {e}")
|
||||
|
||||
fi_count = len(new_supplemental) - ap_count
|
||||
@@ -187,8 +368,10 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
if new_supplemental:
|
||||
_mark_fresh("supplemental_flights")
|
||||
|
||||
logger.info(f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot "
|
||||
f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})")
|
||||
logger.info(
|
||||
f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot "
|
||||
f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})"
|
||||
)
|
||||
return new_supplemental
|
||||
|
||||
|
||||
@@ -204,18 +387,24 @@ def fetch_routes_background(sampled):
|
||||
for f in sampled:
|
||||
c_sign = str(f.get("flight", "")).strip()
|
||||
if c_sign and c_sign != "UNKNOWN":
|
||||
callsigns_to_query.append({
|
||||
"callsign": c_sign,
|
||||
"lat": f.get("lat", 0),
|
||||
"lng": f.get("lon", 0)
|
||||
})
|
||||
callsigns_to_query.append(
|
||||
{"callsign": c_sign, "lat": f.get("lat", 0), "lng": f.get("lon", 0)}
|
||||
)
|
||||
|
||||
batch_size = 100
|
||||
batches = [callsigns_to_query[i:i+batch_size] for i in range(0, len(callsigns_to_query), batch_size)]
|
||||
batches = [
|
||||
callsigns_to_query[i : i + batch_size]
|
||||
for i in range(0, len(callsigns_to_query), batch_size)
|
||||
]
|
||||
|
||||
for batch in batches:
|
||||
try:
|
||||
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": batch}, timeout=15)
|
||||
r = fetch_with_curl(
|
||||
"https://api.adsb.lol/api/0/routeset",
|
||||
method="POST",
|
||||
json_data={"planes": batch},
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
route_data = r.json()
|
||||
route_list = []
|
||||
@@ -238,7 +427,15 @@ def fetch_routes_background(sampled):
|
||||
"dest_loc": [dest_apt.get("lon", 0), dest_apt.get("lat", 0)],
|
||||
}
|
||||
time.sleep(0.25)
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.debug(f"Route batch request failed: {e}")
|
||||
finally:
|
||||
with _routes_lock:
|
||||
@@ -259,7 +456,9 @@ def _classify_and_publish(all_adsb_flights):
|
||||
with _routes_lock:
|
||||
already_running = routes_fetch_in_progress
|
||||
if not already_running:
|
||||
threading.Thread(target=fetch_routes_background, args=(all_adsb_flights,), daemon=True).start()
|
||||
threading.Thread(
|
||||
target=fetch_routes_background, args=(all_adsb_flights,), daemon=True
|
||||
).start()
|
||||
|
||||
for f in all_adsb_flights:
|
||||
try:
|
||||
@@ -308,27 +507,29 @@ def _classify_and_publish(all_adsb_flights):
|
||||
|
||||
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
|
||||
|
||||
flights.append({
|
||||
"callsign": flight_str,
|
||||
"country": f.get("r", "N/A"),
|
||||
"lng": float(lng),
|
||||
"lat": float(lat),
|
||||
"alt": alt_value,
|
||||
"heading": heading,
|
||||
"type": "flight",
|
||||
"origin_loc": origin_loc,
|
||||
"dest_loc": dest_loc,
|
||||
"origin_name": origin_name,
|
||||
"dest_name": dest_name,
|
||||
"registration": f.get("r", "N/A"),
|
||||
"model": f.get("t", "Unknown"),
|
||||
"icao24": f.get("hex", ""),
|
||||
"speed_knots": speed_knots,
|
||||
"squawk": f.get("squawk", ""),
|
||||
"airline_code": airline_code,
|
||||
"aircraft_category": ac_category,
|
||||
"nac_p": f.get("nac_p")
|
||||
})
|
||||
flights.append(
|
||||
{
|
||||
"callsign": flight_str,
|
||||
"country": f.get("r", "N/A"),
|
||||
"lng": float(lng),
|
||||
"lat": float(lat),
|
||||
"alt": alt_value,
|
||||
"heading": heading,
|
||||
"type": "flight",
|
||||
"origin_loc": origin_loc,
|
||||
"dest_loc": dest_loc,
|
||||
"origin_name": origin_name,
|
||||
"dest_name": dest_name,
|
||||
"registration": f.get("r", "N/A"),
|
||||
"model": f.get("t", "Unknown"),
|
||||
"icao24": f.get("hex", ""),
|
||||
"speed_knots": speed_knots,
|
||||
"squawk": f.get("squawk", ""),
|
||||
"airline_code": airline_code,
|
||||
"aircraft_category": ac_category,
|
||||
"nac_p": f.get("nac_p"),
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, AttributeError) as loop_e:
|
||||
logger.error(f"Flight interpolation error: {loop_e}")
|
||||
continue
|
||||
@@ -342,80 +543,97 @@ def _classify_and_publish(all_adsb_flights):
|
||||
for f in flights:
|
||||
enrich_with_plane_alert(f)
|
||||
enrich_with_tracked_names(f)
|
||||
# Attach fuel-burn / CO2 emissions estimate when model is known
|
||||
model = f.get("model")
|
||||
if model:
|
||||
emi = get_emissions_info(model)
|
||||
if emi:
|
||||
f["emissions"] = emi
|
||||
|
||||
callsign = f.get('callsign', '').strip().upper()
|
||||
is_commercial_format = bool(re.match(r'^[A-Z]{3}\d{1,4}[A-Z]{0,2}$', callsign))
|
||||
callsign = f.get("callsign", "").strip().upper()
|
||||
is_commercial_format = bool(re.match(r"^[A-Z]{3}\d{1,4}[A-Z]{0,2}$", callsign))
|
||||
|
||||
if f.get('alert_category'):
|
||||
f['type'] = 'tracked_flight'
|
||||
if f.get("alert_category"):
|
||||
f["type"] = "tracked_flight"
|
||||
tracked.append(f)
|
||||
elif f.get('airline_code') or is_commercial_format:
|
||||
f['type'] = 'commercial_flight'
|
||||
elif f.get("airline_code") or is_commercial_format:
|
||||
f["type"] = "commercial_flight"
|
||||
commercial.append(f)
|
||||
elif f.get('model', '').upper() in PRIVATE_JET_TYPES:
|
||||
f['type'] = 'private_jet'
|
||||
elif f.get("model", "").upper() in PRIVATE_JET_TYPES:
|
||||
f["type"] = "private_jet"
|
||||
private_jets.append(f)
|
||||
else:
|
||||
f['type'] = 'private_ga'
|
||||
f["type"] = "private_ga"
|
||||
private_ga.append(f)
|
||||
|
||||
# --- Smart merge: protect against partial API failures ---
|
||||
prev_commercial_count = len(latest_data.get('commercial_flights', []))
|
||||
prev_total = prev_commercial_count + len(latest_data.get('private_jets', [])) + len(latest_data.get('private_flights', []))
|
||||
with _data_lock:
|
||||
prev_commercial_count = len(latest_data.get("commercial_flights", []))
|
||||
prev_private_jets_count = len(latest_data.get("private_jets", []))
|
||||
prev_private_flights_count = len(latest_data.get("private_flights", []))
|
||||
prev_total = prev_commercial_count + prev_private_jets_count + prev_private_flights_count
|
||||
new_total = len(commercial) + len(private_jets) + len(private_ga)
|
||||
|
||||
if new_total == 0:
|
||||
logger.warning("No civilian flights found! Skipping overwrite to prevent clearing the map.")
|
||||
elif prev_total > 100 and new_total < prev_total * 0.5:
|
||||
logger.warning(f"Flight count dropped from {prev_total} to {new_total} (>50% loss). Keeping previous data to prevent flicker.")
|
||||
logger.warning(
|
||||
f"Flight count dropped from {prev_total} to {new_total} (>50% loss). Keeping previous data to prevent flicker."
|
||||
)
|
||||
else:
|
||||
_now = time.time()
|
||||
|
||||
def _merge_category(new_list, old_list, max_stale_s=120):
|
||||
by_icao = {}
|
||||
for f in old_list:
|
||||
icao = f.get('icao24', '')
|
||||
icao = f.get("icao24", "")
|
||||
if icao:
|
||||
f.setdefault('_seen_at', _now)
|
||||
if (_now - f.get('_seen_at', _now)) < max_stale_s:
|
||||
f.setdefault("_seen_at", _now)
|
||||
if (_now - f.get("_seen_at", _now)) < max_stale_s:
|
||||
by_icao[icao] = f
|
||||
for f in new_list:
|
||||
icao = f.get('icao24', '')
|
||||
icao = f.get("icao24", "")
|
||||
if icao:
|
||||
f['_seen_at'] = _now
|
||||
f["_seen_at"] = _now
|
||||
by_icao[icao] = f
|
||||
else:
|
||||
continue
|
||||
return list(by_icao.values())
|
||||
|
||||
with _data_lock:
|
||||
latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', []))
|
||||
latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', []))
|
||||
latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', []))
|
||||
latest_data["commercial_flights"] = _merge_category(
|
||||
commercial, latest_data.get("commercial_flights", [])
|
||||
)
|
||||
latest_data["private_jets"] = _merge_category(
|
||||
private_jets, latest_data.get("private_jets", [])
|
||||
)
|
||||
latest_data["private_flights"] = _merge_category(
|
||||
private_ga, latest_data.get("private_flights", [])
|
||||
)
|
||||
|
||||
_mark_fresh("commercial_flights", "private_jets", "private_flights")
|
||||
|
||||
with _data_lock:
|
||||
if flights:
|
||||
latest_data['flights'] = flights
|
||||
latest_data["flights"] = flights
|
||||
|
||||
# Merge tracked civilian flights with tracked military flights
|
||||
with _data_lock:
|
||||
existing_tracked = list(latest_data.get('tracked_flights', []))
|
||||
existing_tracked = copy.deepcopy(latest_data.get("tracked_flights", []))
|
||||
|
||||
fresh_tracked_map = {}
|
||||
for t in tracked:
|
||||
icao = t.get('icao24', '').upper()
|
||||
icao = t.get("icao24", "").upper()
|
||||
if icao:
|
||||
fresh_tracked_map[icao] = t
|
||||
|
||||
merged_tracked = []
|
||||
seen_icaos = set()
|
||||
for old_t in existing_tracked:
|
||||
icao = old_t.get('icao24', '').upper()
|
||||
icao = old_t.get("icao24", "").upper()
|
||||
if icao in fresh_tracked_map:
|
||||
fresh = fresh_tracked_map[icao]
|
||||
for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'):
|
||||
for key in ("alert_category", "alert_operator", "alert_special", "alert_flag"):
|
||||
if key in old_t and key not in fresh:
|
||||
fresh[key] = old_t[key]
|
||||
merged_tracked.append(fresh)
|
||||
@@ -429,36 +647,47 @@ def _classify_and_publish(all_adsb_flights):
|
||||
merged_tracked.append(t)
|
||||
|
||||
with _data_lock:
|
||||
latest_data['tracked_flights'] = merged_tracked
|
||||
logger.info(f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian)")
|
||||
latest_data["tracked_flights"] = merged_tracked
|
||||
logger.info(
|
||||
f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian)"
|
||||
)
|
||||
|
||||
# --- Trail Accumulation ---
|
||||
def _accumulate_trail(f, now_ts, check_route=True):
|
||||
hex_id = f.get('icao24', '').lower()
|
||||
hex_id = f.get("icao24", "").lower()
|
||||
if not hex_id:
|
||||
return 0, None
|
||||
if check_route and f.get('origin_name', 'UNKNOWN') != 'UNKNOWN':
|
||||
f['trail'] = []
|
||||
if check_route and f.get("origin_name", "UNKNOWN") != "UNKNOWN":
|
||||
f["trail"] = []
|
||||
return 0, hex_id
|
||||
lat, lng, alt = f.get('lat'), f.get('lng'), f.get('alt', 0)
|
||||
lat, lng, alt = f.get("lat"), f.get("lng"), f.get("alt", 0)
|
||||
if lat is None or lng is None:
|
||||
f['trail'] = flight_trails.get(hex_id, {}).get('points', [])
|
||||
f["trail"] = flight_trails.get(hex_id, {}).get("points", [])
|
||||
return 0, hex_id
|
||||
point = [round(lat, 5), round(lng, 5), round(alt, 1), round(now_ts)]
|
||||
if hex_id not in flight_trails:
|
||||
flight_trails[hex_id] = {'points': [], 'last_seen': now_ts}
|
||||
flight_trails[hex_id] = {"points": [], "last_seen": now_ts}
|
||||
trail_data = flight_trails[hex_id]
|
||||
if trail_data['points'] and trail_data['points'][-1][0] == point[0] and trail_data['points'][-1][1] == point[1]:
|
||||
trail_data['last_seen'] = now_ts
|
||||
if (
|
||||
trail_data["points"]
|
||||
and trail_data["points"][-1][0] == point[0]
|
||||
and trail_data["points"][-1][1] == point[1]
|
||||
):
|
||||
trail_data["last_seen"] = now_ts
|
||||
else:
|
||||
trail_data['points'].append(point)
|
||||
trail_data['last_seen'] = now_ts
|
||||
if len(trail_data['points']) > 200:
|
||||
trail_data['points'] = trail_data['points'][-200:]
|
||||
f['trail'] = trail_data['points']
|
||||
trail_data["points"].append(point)
|
||||
trail_data["last_seen"] = now_ts
|
||||
if len(trail_data["points"]) > 200:
|
||||
trail_data["points"] = trail_data["points"][-200:]
|
||||
f["trail"] = trail_data["points"]
|
||||
return 1, hex_id
|
||||
|
||||
now_ts = datetime.utcnow().timestamp()
|
||||
with _data_lock:
|
||||
military_snapshot = copy.deepcopy(latest_data.get("military_flights", []))
|
||||
tracked_snapshot = copy.deepcopy(latest_data.get("tracked_flights", []))
|
||||
raw_flights_snapshot = list(latest_data.get("flights", []))
|
||||
|
||||
all_lists = [commercial, private_jets, private_ga, existing_tracked]
|
||||
seen_hexes = set()
|
||||
trail_count = 0
|
||||
@@ -470,97 +699,121 @@ def _classify_and_publish(all_adsb_flights):
|
||||
if hex_id:
|
||||
seen_hexes.add(hex_id)
|
||||
|
||||
for mf in latest_data.get('military_flights', []):
|
||||
for mf in military_snapshot:
|
||||
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
|
||||
trail_count += count
|
||||
if hex_id:
|
||||
seen_hexes.add(hex_id)
|
||||
|
||||
tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
|
||||
tracked_hexes = {t.get("icao24", "").lower() for t in tracked_snapshot}
|
||||
stale_keys = []
|
||||
for k, v in flight_trails.items():
|
||||
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 300
|
||||
if v['last_seen'] < cutoff:
|
||||
if v["last_seen"] < cutoff:
|
||||
stale_keys.append(k)
|
||||
for k in stale_keys:
|
||||
del flight_trails[k]
|
||||
|
||||
if len(flight_trails) > _MAX_TRACKED_TRAILS:
|
||||
sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen'])
|
||||
sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]["last_seen"])
|
||||
evict_count = len(flight_trails) - _MAX_TRACKED_TRAILS
|
||||
for k in sorted_keys[:evict_count]:
|
||||
del flight_trails[k]
|
||||
|
||||
logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total")
|
||||
logger.info(
|
||||
f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total"
|
||||
)
|
||||
|
||||
# --- GPS Jamming Detection ---
|
||||
# Uses NACp (Navigation Accuracy Category – Position) from ADS-B to infer
|
||||
# GPS interference zones, similar to GPSJam.org / Flightradar24.
|
||||
# NACp < 8 = position accuracy worse than the FAA-mandated 0.05 NM.
|
||||
#
|
||||
# Denoising (to suppress false positives from old GA transponders):
|
||||
# 1. Skip nac_p == 0 ("unknown accuracy") — old transponders that never
|
||||
# computed accuracy, NOT evidence of jamming. Real jamming shows 1-7.
|
||||
# 2. Require minimum aircraft per grid cell for statistical validity.
|
||||
# 3. Subtract 1 from degraded count per cell (GPSJam's technique) so a
|
||||
# single quirky transponder can't flag an entire zone.
|
||||
# 4. Require the adjusted ratio to exceed the threshold.
|
||||
try:
|
||||
jamming_grid = {}
|
||||
raw_flights = latest_data.get('flights', [])
|
||||
raw_flights = raw_flights_snapshot
|
||||
for rf in raw_flights:
|
||||
rlat = rf.get('lat')
|
||||
rlng = rf.get('lng') or rf.get('lon')
|
||||
rlat = rf.get("lat")
|
||||
rlng = rf.get("lng") or rf.get("lon")
|
||||
if rlat is None or rlng is None:
|
||||
continue
|
||||
nacp = rf.get('nac_p')
|
||||
if nacp is None:
|
||||
nacp = rf.get("nac_p")
|
||||
if nacp is None or nacp == 0:
|
||||
continue
|
||||
grid_key = f"{int(rlat)},{int(rlng)}"
|
||||
if grid_key not in jamming_grid:
|
||||
jamming_grid[grid_key] = {"degraded": 0, "total": 0}
|
||||
jamming_grid[grid_key]["total"] += 1
|
||||
if nacp < 8:
|
||||
if nacp < GPS_JAMMING_NACP_THRESHOLD:
|
||||
jamming_grid[grid_key]["degraded"] += 1
|
||||
|
||||
jamming_zones = []
|
||||
for gk, counts in jamming_grid.items():
|
||||
if counts["total"] < 3:
|
||||
if counts["total"] < GPS_JAMMING_MIN_AIRCRAFT:
|
||||
continue
|
||||
ratio = counts["degraded"] / counts["total"]
|
||||
if ratio > 0.25:
|
||||
adjusted_degraded = max(counts["degraded"] - 1, 0)
|
||||
if adjusted_degraded == 0:
|
||||
continue
|
||||
ratio = adjusted_degraded / counts["total"]
|
||||
if ratio > GPS_JAMMING_MIN_RATIO:
|
||||
lat_i, lng_i = gk.split(",")
|
||||
severity = "low" if ratio < 0.5 else "medium" if ratio < 0.75 else "high"
|
||||
jamming_zones.append({
|
||||
"lat": int(lat_i) + 0.5,
|
||||
"lng": int(lng_i) + 0.5,
|
||||
"severity": severity,
|
||||
"ratio": round(ratio, 2),
|
||||
"degraded": counts["degraded"],
|
||||
"total": counts["total"]
|
||||
})
|
||||
jamming_zones.append(
|
||||
{
|
||||
"lat": int(lat_i) + 0.5,
|
||||
"lng": int(lng_i) + 0.5,
|
||||
"severity": severity,
|
||||
"ratio": round(ratio, 2),
|
||||
"degraded": counts["degraded"],
|
||||
"total": counts["total"],
|
||||
}
|
||||
)
|
||||
with _data_lock:
|
||||
latest_data['gps_jamming'] = jamming_zones
|
||||
latest_data["gps_jamming"] = jamming_zones
|
||||
if jamming_zones:
|
||||
logger.info(f"GPS Jamming: {len(jamming_zones)} interference zones detected")
|
||||
except (ValueError, TypeError, KeyError, ZeroDivisionError) as e:
|
||||
logger.error(f"GPS Jamming detection error: {e}")
|
||||
with _data_lock:
|
||||
latest_data['gps_jamming'] = []
|
||||
latest_data["gps_jamming"] = []
|
||||
|
||||
# --- Holding Pattern Detection ---
|
||||
try:
|
||||
holding_count = 0
|
||||
all_flight_lists = [commercial, private_jets, private_ga,
|
||||
latest_data.get('tracked_flights', []),
|
||||
latest_data.get('military_flights', [])]
|
||||
all_flight_lists = [
|
||||
commercial,
|
||||
private_jets,
|
||||
private_ga,
|
||||
tracked_snapshot,
|
||||
military_snapshot,
|
||||
]
|
||||
with _trails_lock:
|
||||
trails_snapshot = {k: v.get('points', [])[:] for k, v in flight_trails.items()}
|
||||
trails_snapshot = {k: v.get("points", [])[:] for k, v in flight_trails.items()}
|
||||
for flist in all_flight_lists:
|
||||
for f in flist:
|
||||
hex_id = f.get('icao24', '').lower()
|
||||
hex_id = f.get("icao24", "").lower()
|
||||
trail = trails_snapshot.get(hex_id, [])
|
||||
if len(trail) < 6:
|
||||
f['holding'] = False
|
||||
f["holding"] = False
|
||||
continue
|
||||
pts = trail[-8:]
|
||||
total_turn = 0.0
|
||||
prev_bearing = 0.0
|
||||
for i in range(1, len(pts)):
|
||||
lat1, lng1 = math.radians(pts[i-1][0]), math.radians(pts[i-1][1])
|
||||
lat1, lng1 = math.radians(pts[i - 1][0]), math.radians(pts[i - 1][1])
|
||||
lat2, lng2 = math.radians(pts[i][0]), math.radians(pts[i][1])
|
||||
dlng = lng2 - lng1
|
||||
x = math.sin(dlng) * math.cos(lat2)
|
||||
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlng)
|
||||
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(
|
||||
lat2
|
||||
) * math.cos(dlng)
|
||||
bearing = math.degrees(math.atan2(x, y)) % 360
|
||||
if i > 1:
|
||||
delta = abs(bearing - prev_bearing)
|
||||
@@ -568,8 +821,8 @@ def _classify_and_publish(all_adsb_flights):
|
||||
delta = 360 - delta
|
||||
total_turn += delta
|
||||
prev_bearing = bearing
|
||||
f['holding'] = total_turn > 300
|
||||
if f['holding']:
|
||||
f["holding"] = total_turn > 300
|
||||
if f["holding"]:
|
||||
holding_count += 1
|
||||
if holding_count:
|
||||
logger.info(f"Holding patterns: {holding_count} aircraft circling")
|
||||
@@ -577,7 +830,7 @@ def _classify_and_publish(all_adsb_flights):
|
||||
logger.error(f"Holding pattern detection error: {e}")
|
||||
|
||||
with _data_lock:
|
||||
latest_data['last_updated'] = datetime.utcnow().isoformat()
|
||||
latest_data["last_updated"] = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
def _fetch_adsb_lol_regions():
|
||||
@@ -588,7 +841,7 @@ def _fetch_adsb_lol_regions():
|
||||
{"lat": 35.0, "lon": 105.0, "dist": 2000},
|
||||
{"lat": -25.0, "lon": 133.0, "dist": 2000},
|
||||
{"lat": 0.0, "lon": 20.0, "dist": 2500},
|
||||
{"lat": -15.0, "lon": -60.0, "dist": 2000}
|
||||
{"lat": -15.0, "lon": -60.0, "dist": 2000},
|
||||
]
|
||||
|
||||
def _fetch_region(r):
|
||||
@@ -598,7 +851,15 @@ def _fetch_adsb_lol_regions():
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
return data.get("ac", [])
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning(f"Region fetch failed for lat={r['lat']}: {e}")
|
||||
return []
|
||||
|
||||
@@ -632,9 +893,18 @@ def _enrich_with_opensky_and_supplemental(adsb_flights):
|
||||
token = opensky_client.get_token()
|
||||
if token:
|
||||
opensky_regions = [
|
||||
{"name": "Africa", "bbox": {"lamin": -35.0, "lomin": -20.0, "lamax": 38.0, "lomax": 55.0}},
|
||||
{"name": "Asia", "bbox": {"lamin": 0.0, "lomin": 30.0, "lamax": 75.0, "lomax": 150.0}},
|
||||
{"name": "South America", "bbox": {"lamin": -60.0, "lomin": -95.0, "lamax": 15.0, "lomax": -30.0}}
|
||||
{
|
||||
"name": "Africa",
|
||||
"bbox": {"lamin": -35.0, "lomin": -20.0, "lamax": 38.0, "lomax": 55.0},
|
||||
},
|
||||
{
|
||||
"name": "Asia",
|
||||
"bbox": {"lamin": 0.0, "lomin": 30.0, "lamax": 75.0, "lomax": 150.0},
|
||||
},
|
||||
{
|
||||
"name": "South America",
|
||||
"bbox": {"lamin": -60.0, "lomin": -95.0, "lamax": 15.0, "lomax": -30.0},
|
||||
},
|
||||
]
|
||||
|
||||
new_opensky_flights = []
|
||||
@@ -648,24 +918,38 @@ def _enrich_with_opensky_and_supplemental(adsb_flights):
|
||||
if os_res.status_code == 200:
|
||||
os_data = os_res.json()
|
||||
states = os_data.get("states") or []
|
||||
logger.info(f"OpenSky: Fetched {len(states)} states for {os_reg['name']}")
|
||||
logger.info(
|
||||
f"OpenSky: Fetched {len(states)} states for {os_reg['name']}"
|
||||
)
|
||||
|
||||
for s in states:
|
||||
new_opensky_flights.append({
|
||||
"hex": s[0],
|
||||
"flight": s[1].strip() if s[1] else "UNKNOWN",
|
||||
"r": s[2],
|
||||
"lon": s[5],
|
||||
"lat": s[6],
|
||||
"alt_baro": (s[7] * 3.28084) if s[7] else 0,
|
||||
"track": s[10] or 0,
|
||||
"gs": (s[9] * 1.94384) if s[9] else 0,
|
||||
"t": "Unknown",
|
||||
"is_opensky": True
|
||||
})
|
||||
new_opensky_flights.append(
|
||||
{
|
||||
"hex": s[0],
|
||||
"flight": s[1].strip() if s[1] else "UNKNOWN",
|
||||
"r": s[2],
|
||||
"lon": s[5],
|
||||
"lat": s[6],
|
||||
"alt_baro": (s[7] * 3.28084) if s[7] else 0,
|
||||
"track": s[10] or 0,
|
||||
"gs": (s[9] * 1.94384) if s[9] else 0,
|
||||
"t": "Unknown",
|
||||
"is_opensky": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(f"OpenSky API {os_reg['name']} failed: {os_res.status_code}")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as ex:
|
||||
logger.warning(
|
||||
f"OpenSky API {os_reg['name']} failed: {os_res.status_code}"
|
||||
)
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as ex:
|
||||
logger.error(f"OpenSky fetching error for {os_reg['name']}: {ex}")
|
||||
|
||||
cached_opensky_flights = new_opensky_flights
|
||||
@@ -688,12 +972,21 @@ def _enrich_with_opensky_and_supplemental(adsb_flights):
|
||||
seen_hex.add(h)
|
||||
if gap_fill:
|
||||
logger.info(f"Gap-fill: added {len(gap_fill)} aircraft to pipeline")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning(f"Supplemental source fetch failed (non-fatal): {e}")
|
||||
|
||||
# Re-publish with enriched data
|
||||
if len(all_flights) > len(adsb_flights):
|
||||
logger.info(f"Enrichment: {len(all_flights) - len(adsb_flights)} additional aircraft from OpenSky + supplemental")
|
||||
logger.info(
|
||||
f"Enrichment: {len(all_flights) - len(adsb_flights)} additional aircraft from OpenSky + supplemental"
|
||||
)
|
||||
_classify_and_publish(all_flights)
|
||||
except Exception as e:
|
||||
logger.error(f"OpenSky/supplemental enrichment error: {e}")
|
||||
@@ -705,6 +998,10 @@ def fetch_flights():
|
||||
Phase 1 (fast): Fetch adsb.lol → classify → publish immediately (~3-5s)
|
||||
Phase 2 (background): Merge OpenSky + supplemental → re-publish (~15-30s)
|
||||
"""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("flights", "private", "jets", "tracked", "gps_jamming"):
|
||||
return
|
||||
try:
|
||||
# Phase 1: adsb.lol — fast, parallel, publish immediately
|
||||
adsb_flights = _fetch_adsb_lol_regions()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Ship and geopolitics fetchers — AIS vessels, carriers, frontlines, GDELT, LiveUAmap."""
|
||||
"""Ship and geopolitics fetchers — AIS vessels, carriers, frontlines, GDELT, LiveUAmap, fishing."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import logging
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
@@ -16,6 +18,12 @@ logger = logging.getLogger(__name__)
|
||||
@with_retry(max_retries=1, base_delay=1)
|
||||
def fetch_ships():
|
||||
"""Fetch real-time AIS vessel data and combine with OSINT carrier positions."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active(
|
||||
"ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts"
|
||||
):
|
||||
return
|
||||
from services.ais_stream import get_ais_vessels
|
||||
from services.carrier_tracker import get_carrier_positions
|
||||
|
||||
@@ -23,19 +31,20 @@ def fetch_ships():
|
||||
try:
|
||||
carriers = get_carrier_positions()
|
||||
ships.extend(carriers)
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Carrier tracker error (non-fatal): {e}")
|
||||
carriers = []
|
||||
|
||||
try:
|
||||
ais_vessels = get_ais_vessels()
|
||||
ships.extend(ais_vessels)
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"AIS stream error (non-fatal): {e}")
|
||||
ais_vessels = []
|
||||
|
||||
# Enrich ships with yacht alert data (tracked superyachts)
|
||||
from services.fetchers.yacht_alert import enrich_with_yacht_alert
|
||||
|
||||
for ship in ships:
|
||||
enrich_with_yacht_alert(ship)
|
||||
|
||||
@@ -46,7 +55,7 @@ def fetch_ships():
|
||||
|
||||
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
|
||||
with _data_lock:
|
||||
latest_data['ships'] = ships
|
||||
latest_data["ships"] = ships
|
||||
_mark_fresh("ships")
|
||||
|
||||
|
||||
@@ -62,16 +71,19 @@ def find_nearest_airport(lat, lng, max_distance_nm=200):
|
||||
return None
|
||||
|
||||
best = None
|
||||
best_dist = float('inf')
|
||||
best_dist = float("inf")
|
||||
lat_r = math.radians(lat)
|
||||
lng_r = math.radians(lng)
|
||||
|
||||
for apt in cached_airports:
|
||||
apt_lat_r = math.radians(apt['lat'])
|
||||
apt_lng_r = math.radians(apt['lng'])
|
||||
apt_lat_r = math.radians(apt["lat"])
|
||||
apt_lng_r = math.radians(apt["lng"])
|
||||
dlat = apt_lat_r - lat_r
|
||||
dlng = apt_lng_r - lng_r
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(lat_r) * math.cos(apt_lat_r) * math.sin(dlng / 2) ** 2
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat_r) * math.cos(apt_lat_r) * math.sin(dlng / 2) ** 2
|
||||
)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
dist_nm = 3440.065 * c
|
||||
|
||||
@@ -81,9 +93,11 @@ def find_nearest_airport(lat, lng, max_distance_nm=200):
|
||||
|
||||
if best and best_dist <= max_distance_nm:
|
||||
return {
|
||||
"iata": best['iata'], "name": best['name'],
|
||||
"lat": best['lat'], "lng": best['lng'],
|
||||
"distance_nm": round(best_dist, 1)
|
||||
"iata": best["iata"],
|
||||
"name": best["name"],
|
||||
"lat": best["lat"],
|
||||
"lng": best["lng"],
|
||||
"distance_nm": round(best_dist, 1),
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -99,21 +113,23 @@ def fetch_airports():
|
||||
f = io.StringIO(response.text)
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
if row['type'] == 'large_airport' and row['iata_code']:
|
||||
cached_airports.append({
|
||||
"id": row['ident'],
|
||||
"name": row['name'],
|
||||
"iata": row['iata_code'],
|
||||
"lat": float(row['latitude_deg']),
|
||||
"lng": float(row['longitude_deg']),
|
||||
"type": "airport"
|
||||
})
|
||||
if row["type"] == "large_airport" and row["iata_code"]:
|
||||
cached_airports.append(
|
||||
{
|
||||
"id": row["ident"],
|
||||
"name": row["name"],
|
||||
"iata": row["iata_code"],
|
||||
"lat": float(row["latitude_deg"]),
|
||||
"lng": float(row["longitude_deg"]),
|
||||
"type": "airport",
|
||||
}
|
||||
)
|
||||
logger.info(f"Loaded {len(cached_airports)} large airports into cache.")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching airports: {e}")
|
||||
|
||||
with _data_lock:
|
||||
latest_data['airports'] = cached_airports
|
||||
latest_data["airports"] = cached_airports
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -122,28 +138,38 @@ def fetch_airports():
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_frontlines():
|
||||
"""Fetch Ukraine frontline data (fast — single GitHub API call)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("ukraine_frontline"):
|
||||
return
|
||||
try:
|
||||
from services.geopolitics import fetch_ukraine_frontlines
|
||||
|
||||
frontlines = fetch_ukraine_frontlines()
|
||||
if frontlines:
|
||||
with _data_lock:
|
||||
latest_data['frontlines'] = frontlines
|
||||
latest_data["frontlines"] = frontlines
|
||||
_mark_fresh("frontlines")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching frontlines: {e}")
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=3)
|
||||
def fetch_gdelt():
|
||||
"""Fetch GDELT global military incidents (slow — downloads 32 ZIP files)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("global_incidents"):
|
||||
return
|
||||
try:
|
||||
from services.geopolitics import fetch_global_military_incidents
|
||||
|
||||
gdelt = fetch_global_military_incidents()
|
||||
if gdelt is not None:
|
||||
with _data_lock:
|
||||
latest_data['gdelt'] = gdelt
|
||||
latest_data["gdelt"] = gdelt
|
||||
_mark_fresh("gdelt")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching GDELT: {e}")
|
||||
|
||||
|
||||
@@ -154,13 +180,72 @@ def fetch_geopolitics():
|
||||
|
||||
|
||||
def update_liveuamap():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("global_incidents"):
|
||||
return
|
||||
logger.info("Running scheduled Liveuamap scraper...")
|
||||
try:
|
||||
from services.liveuamap_scraper import fetch_liveuamap
|
||||
|
||||
res = fetch_liveuamap()
|
||||
if res:
|
||||
with _data_lock:
|
||||
latest_data['liveuamap'] = res
|
||||
latest_data["liveuamap"] = res
|
||||
_mark_fresh("liveuamap")
|
||||
except Exception as e:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Liveuamap scraper error: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fishing Activity (Global Fishing Watch)
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=1, base_delay=5)
|
||||
def fetch_fishing_activity():
|
||||
"""Fetch recent fishing events from Global Fishing Watch (~5 day lag)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("fishing_activity"):
|
||||
return
|
||||
token = os.environ.get("GFW_API_TOKEN", "")
|
||||
if not token:
|
||||
logger.debug("GFW_API_TOKEN not set, skipping fishing activity fetch")
|
||||
return
|
||||
events = []
|
||||
try:
|
||||
url = (
|
||||
"https://gateway.api.globalfishingwatch.org/v3/events"
|
||||
"?datasets[0]=public-global-fishing-events:latest"
|
||||
"&limit=500&sort=start&sort-direction=DESC"
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = fetch_with_curl(url, timeout=30, headers=headers)
|
||||
if response.status_code == 200:
|
||||
entries = response.json().get("entries", [])
|
||||
for e in entries:
|
||||
pos = e.get("position", {})
|
||||
lat = pos.get("lat")
|
||||
lng = pos.get("lon")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
dur = e.get("event", {}).get("duration", 0) or 0
|
||||
events.append(
|
||||
{
|
||||
"id": e.get("id", ""),
|
||||
"type": e.get("type", "fishing"),
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"start": e.get("start", ""),
|
||||
"end": e.get("end", ""),
|
||||
"vessel_name": (e.get("vessel") or {}).get("name", "Unknown"),
|
||||
"vessel_flag": (e.get("vessel") or {}).get("flag", ""),
|
||||
"duration_hrs": round(dur / 3600, 1),
|
||||
}
|
||||
)
|
||||
logger.info(f"Fishing activity: {len(events)} events")
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching fishing activity: {e}")
|
||||
with _data_lock:
|
||||
latest_data["fishing_activity"] = events
|
||||
if events:
|
||||
_mark_fresh("fishing_activity")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Infrastructure fetchers — internet outages (IODA), data centers, CCTV, KiwiSDR."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import heapq
|
||||
@@ -25,6 +26,7 @@ def _geocode_region(region_name: str, country_name: str) -> tuple:
|
||||
return _region_geocode_cache[cache_key]
|
||||
try:
|
||||
import urllib.parse
|
||||
|
||||
query = urllib.parse.quote(f"{region_name}, {country_name}")
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1"
|
||||
response = fetch_with_curl(url, timeout=8, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
|
||||
@@ -35,7 +37,7 @@ def _geocode_region(region_name: str, country_name: str) -> tuple:
|
||||
lon = float(results[0]["lon"])
|
||||
_region_geocode_cache[cache_key] = (lat, lon)
|
||||
return (lat, lon)
|
||||
except Exception:
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError):
|
||||
pass
|
||||
_region_geocode_cache[cache_key] = None
|
||||
return None
|
||||
@@ -44,6 +46,10 @@ def _geocode_region(region_name: str, country_name: str) -> tuple:
|
||||
@with_retry(max_retries=1, base_delay=1)
|
||||
def fetch_internet_outages():
|
||||
"""Fetch regional internet outage alerts from IODA (Georgia Tech)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("internet_outages"):
|
||||
return
|
||||
RELIABLE_DATASOURCES = {"bgp", "ping-slash24"}
|
||||
outages = []
|
||||
try:
|
||||
@@ -96,7 +102,15 @@ def fetch_internet_outages():
|
||||
geocoded.append(r)
|
||||
outages = heapq.nlargest(100, geocoded, key=lambda x: x["severity"])
|
||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
||||
except Exception as e:
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching internet outages: {e}")
|
||||
with _data_lock:
|
||||
latest_data["internet_outages"] = outages
|
||||
@@ -104,6 +118,116 @@ def fetch_internet_outages():
|
||||
_mark_fresh("internet_outages")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RIPE Atlas — complement IODA with probe-level disconnection data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@with_retry(max_retries=1, base_delay=3)
|
||||
def fetch_ripe_atlas_probes():
|
||||
"""Fetch disconnected RIPE Atlas probes and merge into internet_outages (complementing IODA)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("internet_outages"):
|
||||
return
|
||||
try:
|
||||
# 1. Fetch disconnected probes (status=2) — ~2,000 probes, no auth needed
|
||||
url_disc = "https://atlas.ripe.net/api/v2/probes/?status=2&page_size=500&format=json"
|
||||
resp_disc = fetch_with_curl(url_disc, timeout=20)
|
||||
if resp_disc.status_code != 200:
|
||||
logger.warning(f"RIPE Atlas probes API returned {resp_disc.status_code}")
|
||||
return
|
||||
disc_data = resp_disc.json()
|
||||
disconnected = disc_data.get("results", [])
|
||||
|
||||
# 2. Fetch connected probe count (page_size=1 — we only need the count)
|
||||
url_conn = "https://atlas.ripe.net/api/v2/probes/?status=1&page_size=1&format=json"
|
||||
resp_conn = fetch_with_curl(url_conn, timeout=10)
|
||||
total_connected = 0
|
||||
if resp_conn.status_code == 200:
|
||||
total_connected = resp_conn.json().get("count", 0)
|
||||
|
||||
# 3. Group disconnected probes by country
|
||||
country_disc: dict = {}
|
||||
for p in disconnected:
|
||||
cc = p.get("country_code", "")
|
||||
if not cc:
|
||||
continue
|
||||
if cc not in country_disc:
|
||||
country_disc[cc] = []
|
||||
country_disc[cc].append(p)
|
||||
|
||||
# 4. Get IODA-covered countries to avoid double-reporting
|
||||
with _data_lock:
|
||||
ioda_outages = list(latest_data.get("internet_outages", []))
|
||||
ioda_countries = {
|
||||
o.get("country_code", "").upper()
|
||||
for o in ioda_outages
|
||||
if o.get("datasource") != "ripe-atlas"
|
||||
}
|
||||
|
||||
# 5. Build RIPE-only alerts for countries NOT already in IODA
|
||||
ripe_alerts = []
|
||||
for cc, probes in country_disc.items():
|
||||
if cc.upper() in ioda_countries:
|
||||
continue # IODA already covers this country
|
||||
if len(probes) < 3:
|
||||
continue # Too few probes to be meaningful
|
||||
|
||||
# Use centroid of disconnected probes as marker location
|
||||
lats = [
|
||||
p["geometry"]["coordinates"][1]
|
||||
for p in probes
|
||||
if p.get("geometry") and p["geometry"].get("coordinates")
|
||||
]
|
||||
lngs = [
|
||||
p["geometry"]["coordinates"][0]
|
||||
for p in probes
|
||||
if p.get("geometry") and p["geometry"].get("coordinates")
|
||||
]
|
||||
if not lats:
|
||||
continue
|
||||
|
||||
disc_count = len(probes)
|
||||
# Severity: scale 10-80 based on disconnected probe count
|
||||
severity = min(80, 10 + disc_count * 2)
|
||||
|
||||
ripe_alerts.append({
|
||||
"region_code": f"RIPE-{cc}",
|
||||
"region_name": f"{cc} (Atlas probes)",
|
||||
"country_code": cc,
|
||||
"country_name": cc,
|
||||
"level": "critical" if disc_count >= 10 else "warning",
|
||||
"datasource": "ripe-atlas",
|
||||
"severity": severity,
|
||||
"lat": sum(lats) / len(lats),
|
||||
"lng": sum(lngs) / len(lngs),
|
||||
"probe_count": disc_count,
|
||||
})
|
||||
|
||||
# 6. Merge into internet_outages — keep IODA entries, replace old RIPE entries
|
||||
with _data_lock:
|
||||
current = latest_data.get("internet_outages", [])
|
||||
ioda_only = [o for o in current if o.get("datasource") != "ripe-atlas"]
|
||||
latest_data["internet_outages"] = ioda_only + ripe_alerts
|
||||
|
||||
if ripe_alerts:
|
||||
_mark_fresh("internet_outages")
|
||||
logger.info(
|
||||
f"RIPE Atlas: {len(ripe_alerts)} countries with probe disconnections "
|
||||
f"(from {len(disconnected)} disconnected / ~{total_connected} connected probes)"
|
||||
)
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching RIPE Atlas probes: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Centers (local geocoded JSON)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,6 +236,10 @@ _DC_GEOCODED_PATH = Path(__file__).parent.parent.parent / "data" / "datacenters_
|
||||
|
||||
def fetch_datacenters():
|
||||
"""Load geocoded data centers (5K+ street-level precise locations)."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("datacenters"):
|
||||
return
|
||||
dcs = []
|
||||
try:
|
||||
if not _DC_GEOCODED_PATH.exists():
|
||||
@@ -125,17 +253,28 @@ def fetch_datacenters():
|
||||
continue
|
||||
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
||||
continue
|
||||
dcs.append({
|
||||
"name": entry.get("name", "Unknown"),
|
||||
"company": entry.get("company", ""),
|
||||
"street": entry.get("street", ""),
|
||||
"city": entry.get("city", ""),
|
||||
"country": entry.get("country", ""),
|
||||
"zip": entry.get("zip", ""),
|
||||
"lat": lat, "lng": lng,
|
||||
})
|
||||
dcs.append(
|
||||
{
|
||||
"name": entry.get("name", "Unknown"),
|
||||
"company": entry.get("company", ""),
|
||||
"street": entry.get("street", ""),
|
||||
"city": entry.get("city", ""),
|
||||
"country": entry.get("country", ""),
|
||||
"zip": entry.get("zip", ""),
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
}
|
||||
)
|
||||
logger.info(f"Data centers: {len(dcs)} geocoded locations loaded")
|
||||
except Exception as e:
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error loading data centers: {e}")
|
||||
with _data_lock:
|
||||
latest_data["datacenters"] = dcs
|
||||
@@ -222,16 +361,34 @@ def fetch_power_plants():
|
||||
# CCTV Cameras
|
||||
# ---------------------------------------------------------------------------
|
||||
def fetch_cctv():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("cctv"):
|
||||
return
|
||||
try:
|
||||
from services.cctv_pipeline import get_all_cameras
|
||||
|
||||
cameras = get_all_cameras()
|
||||
if len(cameras) < 500:
|
||||
# Serve the current DB snapshot immediately and let the scheduled
|
||||
# ingest cycle populate/refresh cameras asynchronously.
|
||||
logger.info(
|
||||
"CCTV DB currently has %d cameras — serving cached snapshot and waiting for scheduled ingest",
|
||||
len(cameras),
|
||||
)
|
||||
with _data_lock:
|
||||
latest_data["cctv"] = cameras
|
||||
_mark_fresh("cctv")
|
||||
except Exception as e:
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching cctv from DB: {e}")
|
||||
with _data_lock:
|
||||
latest_data["cctv"] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,13 +396,332 @@ def fetch_cctv():
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=2, base_delay=2)
|
||||
def fetch_kiwisdr():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("kiwisdr"):
|
||||
return
|
||||
try:
|
||||
from services.kiwisdr_fetcher import fetch_kiwisdr_nodes
|
||||
|
||||
nodes = fetch_kiwisdr_nodes()
|
||||
with _data_lock:
|
||||
latest_data["kiwisdr"] = nodes
|
||||
_mark_fresh("kiwisdr")
|
||||
except Exception as e:
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
||||
with _data_lock:
|
||||
latest_data["kiwisdr"] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SatNOGS Ground Stations + Observations
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=2, base_delay=2)
|
||||
def fetch_satnogs():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("satnogs"):
|
||||
return
|
||||
try:
|
||||
from services.satnogs_fetcher import fetch_satnogs_stations, fetch_satnogs_observations
|
||||
|
||||
stations = fetch_satnogs_stations()
|
||||
obs = fetch_satnogs_observations()
|
||||
with _data_lock:
|
||||
latest_data["satnogs_stations"] = stations
|
||||
latest_data["satnogs_observations"] = obs
|
||||
_mark_fresh("satnogs_stations", "satnogs_observations")
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching SatNOGS: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PSK Reporter — HF Digital Mode Spots
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=2, base_delay=2)
|
||||
def fetch_psk_reporter():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("psk_reporter"):
|
||||
return
|
||||
try:
|
||||
from services.psk_reporter_fetcher import fetch_psk_reporter_spots
|
||||
|
||||
spots = fetch_psk_reporter_spots()
|
||||
with _data_lock:
|
||||
latest_data["psk_reporter"] = spots
|
||||
_mark_fresh("psk_reporter")
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching PSK Reporter: {e}")
|
||||
with _data_lock:
|
||||
latest_data["psk_reporter"] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TinyGS LoRa Satellites
|
||||
# ---------------------------------------------------------------------------
|
||||
@with_retry(max_retries=2, base_delay=2)
|
||||
def fetch_tinygs():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("tinygs"):
|
||||
return
|
||||
try:
|
||||
from services.tinygs_fetcher import fetch_tinygs_satellites
|
||||
|
||||
sats = fetch_tinygs_satellites()
|
||||
with _data_lock:
|
||||
latest_data["tinygs_satellites"] = sats
|
||||
_mark_fresh("tinygs_satellites")
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching TinyGS: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Police Scanners (OpenMHZ) — geocode city+state via local GeoNames DB
|
||||
# ---------------------------------------------------------------------------
|
||||
_scanner_geo_cache: dict = {} # city|state -> (lat, lng) — populated once from GeoNames
|
||||
|
||||
|
||||
def _build_scanner_geo_lookup():
|
||||
"""Build a US city/county→coords lookup from reverse_geocoder's bundled GeoNames CSV."""
|
||||
if _scanner_geo_cache:
|
||||
return
|
||||
try:
|
||||
import csv, os, reverse_geocoder as rg
|
||||
|
||||
geo_file = os.path.join(os.path.dirname(rg.__file__), "rg_cities1000.csv")
|
||||
# US state abbreviation → admin1 name mapping
|
||||
_abbr = {
|
||||
"AL": "Alabama",
|
||||
"AK": "Alaska",
|
||||
"AZ": "Arizona",
|
||||
"AR": "Arkansas",
|
||||
"CA": "California",
|
||||
"CO": "Colorado",
|
||||
"CT": "Connecticut",
|
||||
"DE": "Delaware",
|
||||
"FL": "Florida",
|
||||
"GA": "Georgia",
|
||||
"HI": "Hawaii",
|
||||
"ID": "Idaho",
|
||||
"IL": "Illinois",
|
||||
"IN": "Indiana",
|
||||
"IA": "Iowa",
|
||||
"KS": "Kansas",
|
||||
"KY": "Kentucky",
|
||||
"LA": "Louisiana",
|
||||
"ME": "Maine",
|
||||
"MD": "Maryland",
|
||||
"MA": "Massachusetts",
|
||||
"MI": "Michigan",
|
||||
"MN": "Minnesota",
|
||||
"MS": "Mississippi",
|
||||
"MO": "Missouri",
|
||||
"MT": "Montana",
|
||||
"NE": "Nebraska",
|
||||
"NV": "Nevada",
|
||||
"NH": "New Hampshire",
|
||||
"NJ": "New Jersey",
|
||||
"NM": "New Mexico",
|
||||
"NY": "New York",
|
||||
"NC": "North Carolina",
|
||||
"ND": "North Dakota",
|
||||
"OH": "Ohio",
|
||||
"OK": "Oklahoma",
|
||||
"OR": "Oregon",
|
||||
"PA": "Pennsylvania",
|
||||
"RI": "Rhode Island",
|
||||
"SC": "South Carolina",
|
||||
"SD": "South Dakota",
|
||||
"TN": "Tennessee",
|
||||
"TX": "Texas",
|
||||
"UT": "Utah",
|
||||
"VT": "Vermont",
|
||||
"VA": "Virginia",
|
||||
"WA": "Washington",
|
||||
"WV": "West Virginia",
|
||||
"WI": "Wisconsin",
|
||||
"WY": "Wyoming",
|
||||
"DC": "Washington, D.C.",
|
||||
}
|
||||
state_full = {v.lower(): k for k, v in _abbr.items()}
|
||||
state_full["washington, d.c."] = "DC"
|
||||
|
||||
county_coords = {} # admin2(county)|state -> (lat, lon) — first city per county
|
||||
with open(geo_file, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader, None) # skip header
|
||||
for row in reader:
|
||||
if len(row) < 6 or row[5] != "US":
|
||||
continue
|
||||
lat_s, lon_s, name, admin1, admin2 = row[0], row[1], row[2], row[3], row[4]
|
||||
st = state_full.get(admin1.lower(), "")
|
||||
if not st:
|
||||
continue
|
||||
coords = (float(lat_s), float(lon_s))
|
||||
# City name → coords
|
||||
_scanner_geo_cache[f"{name.lower()}|{st}"] = coords
|
||||
# County name → coords (keep first match per county, usually the largest city)
|
||||
if admin2:
|
||||
county_key = f"{admin2.lower()}|{st}"
|
||||
if county_key not in county_coords:
|
||||
county_coords[county_key] = coords
|
||||
# Also strip " County" suffix for matching
|
||||
stripped = admin2.lower().replace(" county", "").strip()
|
||||
stripped_key = f"{stripped}|{st}"
|
||||
if stripped_key not in county_coords:
|
||||
county_coords[stripped_key] = coords
|
||||
|
||||
# Merge county lookups (don't override city entries)
|
||||
for k, v in county_coords.items():
|
||||
if k not in _scanner_geo_cache:
|
||||
_scanner_geo_cache[k] = v
|
||||
# Special case: DC
|
||||
_scanner_geo_cache["washington|DC"] = (38.89511, -77.03637)
|
||||
logger.info(f"Scanner geo lookup: {len(_scanner_geo_cache)} US entries loaded")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to build scanner geo lookup: {e}")
|
||||
|
||||
|
||||
def _geocode_scanner(city: str, state: str):
|
||||
"""Look up city+state coordinates from local GeoNames cache."""
|
||||
_build_scanner_geo_lookup()
|
||||
if not city or not state:
|
||||
return None
|
||||
st = state.upper()
|
||||
# Strip trailing state from city (e.g. "Lehigh, PA")
|
||||
c = city.strip()
|
||||
if ", " in c:
|
||||
parts = c.rsplit(", ", 1)
|
||||
if len(parts[1]) <= 2:
|
||||
c = parts[0]
|
||||
name = c.lower()
|
||||
# Try exact city match
|
||||
result = _scanner_geo_cache.get(f"{name}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# Strip "County" / "Co" suffix
|
||||
stripped = name.replace(" county", "").replace(" co", "").strip()
|
||||
result = _scanner_geo_cache.get(f"{stripped}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# Normalize "St." / "St" → "Saint"
|
||||
import re
|
||||
|
||||
normed = re.sub(r"\bst\.?\s", "saint ", name)
|
||||
if normed != name:
|
||||
result = _scanner_geo_cache.get(f"{normed}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# Also try with "s" suffix: "St. Marys" → "Saint Marys" and "Saint Mary's"
|
||||
for variant in [normed.rstrip("s"), normed.replace("ys", "y's")]:
|
||||
result = _scanner_geo_cache.get(f"{variant}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# "Prince Georges" → "Prince George's" (apostrophe variants)
|
||||
if "georges" in name:
|
||||
key = name.replace("georges", "george's") + "|" + st
|
||||
result = _scanner_geo_cache.get(key)
|
||||
if result:
|
||||
return result
|
||||
# Multi-location: "Scott and Carver" → try first part
|
||||
if " and " in name:
|
||||
first = name.split(" and ")[0].strip()
|
||||
result = _scanner_geo_cache.get(f"{first}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# Comma-separated list: "Adams, Jackson, Juneau" → try first
|
||||
if ", " in name:
|
||||
first = name.split(", ")[0].strip()
|
||||
result = _scanner_geo_cache.get(f"{first}|{st}")
|
||||
if result:
|
||||
return result
|
||||
# Drop directional prefix: "North Fulton" → "Fulton"
|
||||
for prefix in ("north ", "south ", "east ", "west "):
|
||||
if name.startswith(prefix):
|
||||
result = _scanner_geo_cache.get(f"{name[len(prefix):]}|{st}")
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
@with_retry(max_retries=2, base_delay=2)
|
||||
def fetch_scanners():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("scanners"):
|
||||
return
|
||||
try:
|
||||
from services.radio_intercept import get_openmhz_systems
|
||||
|
||||
systems = get_openmhz_systems()
|
||||
scanners = []
|
||||
for s in systems:
|
||||
city = s.get("city", "") or s.get("county", "") or ""
|
||||
state = s.get("state", "")
|
||||
coords = _geocode_scanner(city, state)
|
||||
if not coords:
|
||||
continue
|
||||
lat, lng = coords
|
||||
scanners.append(
|
||||
{
|
||||
"shortName": s.get("shortName", ""),
|
||||
"name": s.get("name", "Unknown Scanner"),
|
||||
"lat": round(lat, 5),
|
||||
"lng": round(lng, 5),
|
||||
"city": city,
|
||||
"state": state,
|
||||
"clientCount": s.get("clientCount", 0),
|
||||
"description": s.get("description", ""),
|
||||
}
|
||||
)
|
||||
with _data_lock:
|
||||
latest_data["scanners"] = scanners
|
||||
if scanners:
|
||||
_mark_fresh("scanners")
|
||||
logger.info(f"Scanners: {len(scanners)}/{len(systems)} geocoded")
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching scanners: {e}")
|
||||
with _data_lock:
|
||||
latest_data["scanners"] = []
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Meshtastic Map fetcher — pulls global node positions from meshtastic.liamcottle.net.
|
||||
|
||||
Bootstrap + top-up strategy:
|
||||
- On startup: fetch all nodes with positions to seed the map
|
||||
- Every 4 hours: refresh from the API
|
||||
- Persists to JSON cache so data survives restarts
|
||||
- MQTT bridge provides real-time updates between API fetches
|
||||
|
||||
API source: https://meshtastic.liamcottle.net/api/v1/nodes (community project by Liam Cottle)
|
||||
Polling interval deliberately kept low (4h) to be respectful to the service.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
_API_URL = "https://meshtastic.liamcottle.net/api/v1/nodes"
|
||||
_CACHE_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "meshtastic_nodes_cache.json"
|
||||
_FETCH_TIMEOUT = 90 # seconds — response is ~37MB, needs time on slow connections
|
||||
_MAX_AGE_HOURS = 4 # discard nodes not seen within this window (matches refresh interval)
|
||||
|
||||
# Track when we last fetched so the frontend can show staleness
|
||||
_last_fetch_ts: float = 0.0
|
||||
|
||||
|
||||
def _parse_node(node: dict) -> dict | None:
|
||||
"""Convert an API node into a slim signal-like dict."""
|
||||
lat_i = node.get("latitude")
|
||||
lng_i = node.get("longitude")
|
||||
if lat_i is None or lng_i is None:
|
||||
return None
|
||||
|
||||
lat = lat_i / 1e7
|
||||
lng = lng_i / 1e7
|
||||
|
||||
# Basic validity
|
||||
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
||||
return None
|
||||
if abs(lat) < 0.1 and abs(lng) < 0.1:
|
||||
return None
|
||||
|
||||
callsign = node.get("node_id_hex", "")
|
||||
if not callsign:
|
||||
nid = node.get("node_id")
|
||||
callsign = f"!{int(nid):08x}" if nid else ""
|
||||
if not callsign:
|
||||
return None
|
||||
|
||||
# Position age from API — reject nodes older than _MAX_AGE_HOURS
|
||||
pos_updated = node.get("position_updated_at") or node.get("updated_at", "")
|
||||
if pos_updated:
|
||||
try:
|
||||
ts = datetime.fromisoformat(pos_updated.replace("Z", "+00:00"))
|
||||
if datetime.now(timezone.utc) - ts > timedelta(hours=_MAX_AGE_HOURS):
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
return None # no timestamp at all — skip
|
||||
|
||||
return {
|
||||
"callsign": callsign[:20],
|
||||
"lat": round(lat, 5),
|
||||
"lng": round(lng, 5),
|
||||
"source": "meshtastic",
|
||||
"confidence": 0.5,
|
||||
"timestamp": pos_updated,
|
||||
"position_updated_at": pos_updated,
|
||||
"from_api": True,
|
||||
"long_name": (node.get("long_name") or "")[:40],
|
||||
"short_name": (node.get("short_name") or "")[:4],
|
||||
"hardware": node.get("hardware_model_name", ""),
|
||||
"role": node.get("role_name", ""),
|
||||
"battery_level": node.get("battery_level"),
|
||||
"voltage": node.get("voltage"),
|
||||
"altitude": node.get("altitude"),
|
||||
}
|
||||
|
||||
|
||||
def _is_fresh(node: dict) -> bool:
|
||||
"""Check if a cached node is still within the _MAX_AGE_HOURS window."""
|
||||
ts_str = node.get("position_updated_at") or node.get("timestamp", "")
|
||||
if not ts_str:
|
||||
return False
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
return datetime.now(timezone.utc) - ts <= timedelta(hours=_MAX_AGE_HOURS)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _load_cache() -> list[dict]:
|
||||
"""Load cached nodes from disk, filtering out stale entries."""
|
||||
if _CACHE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
nodes = data.get("nodes", [])
|
||||
fresh = [n for n in nodes if _is_fresh(n)]
|
||||
logger.info(f"Meshtastic map cache loaded: {len(fresh)} fresh / {len(nodes)} total")
|
||||
return fresh
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load meshtastic cache: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _save_cache(nodes: list[dict], fetch_ts: float):
|
||||
"""Persist processed nodes to disk."""
|
||||
try:
|
||||
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_FILE.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"fetched_at": fetch_ts,
|
||||
"count": len(nodes),
|
||||
"nodes": nodes,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save meshtastic cache: {e}")
|
||||
|
||||
|
||||
def fetch_meshtastic_nodes():
|
||||
"""Fetch global Meshtastic node positions from Liam Cottle's map API.
|
||||
|
||||
Stores processed nodes in latest_data["meshtastic_map_nodes"].
|
||||
Persists to JSON cache for restart resilience.
|
||||
"""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("sigint_meshtastic"):
|
||||
return
|
||||
global _last_fetch_ts
|
||||
|
||||
try:
|
||||
logger.info("Fetching Meshtastic map nodes from API...")
|
||||
resp = requests.get(
|
||||
_API_URL,
|
||||
timeout=_FETCH_TIMEOUT,
|
||||
headers={
|
||||
"User-Agent": "ShadowBroker/1.0 (OSINT dashboard, 4h polling)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
raw = resp.json()
|
||||
raw_nodes = raw.get("nodes", []) if isinstance(raw, dict) else raw
|
||||
|
||||
# Parse and filter to only nodes with valid positions
|
||||
parsed = []
|
||||
for node in raw_nodes:
|
||||
sig = _parse_node(node)
|
||||
if sig:
|
||||
parsed.append(sig)
|
||||
|
||||
_last_fetch_ts = time.time()
|
||||
_save_cache(parsed, _last_fetch_ts)
|
||||
|
||||
with _data_lock:
|
||||
latest_data["meshtastic_map_nodes"] = parsed
|
||||
latest_data["meshtastic_map_fetched_at"] = _last_fetch_ts
|
||||
try:
|
||||
from services.fetchers.sigint import refresh_sigint_snapshot
|
||||
|
||||
refresh_sigint_snapshot()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Meshtastic map: SIGINT snapshot refresh skipped: {exc}")
|
||||
|
||||
logger.info(
|
||||
f"Meshtastic map: {len(parsed)} nodes with positions " f"(from {len(raw_nodes)} total)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Meshtastic map fetch failed: {e}")
|
||||
# Fall back to cache if available and we have nothing in memory
|
||||
with _data_lock:
|
||||
if not latest_data.get("meshtastic_map_nodes"):
|
||||
cached = _load_cache()
|
||||
if cached:
|
||||
latest_data["meshtastic_map_nodes"] = cached
|
||||
latest_data["meshtastic_map_fetched_at"] = (
|
||||
_CACHE_FILE.stat().st_mtime if _CACHE_FILE.exists() else 0
|
||||
)
|
||||
logger.info(
|
||||
f"Meshtastic map: using {len(cached)} cached nodes (API unavailable)"
|
||||
)
|
||||
try:
|
||||
from services.fetchers.sigint import refresh_sigint_snapshot
|
||||
|
||||
refresh_sigint_snapshot()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Meshtastic map cache: SIGINT snapshot refresh skipped: {exc}")
|
||||
|
||||
_mark_fresh("meshtastic_map")
|
||||
|
||||
|
||||
def load_meshtastic_cache_if_available():
|
||||
"""On startup, load cached nodes immediately (before first API fetch)."""
|
||||
global _last_fetch_ts
|
||||
cached = _load_cache()
|
||||
if cached:
|
||||
with _data_lock:
|
||||
latest_data["meshtastic_map_nodes"] = cached
|
||||
_last_fetch_ts = _CACHE_FILE.stat().st_mtime if _CACHE_FILE.exists() else 0
|
||||
latest_data["meshtastic_map_fetched_at"] = _last_fetch_ts
|
||||
try:
|
||||
from services.fetchers.sigint import refresh_sigint_snapshot
|
||||
|
||||
refresh_sigint_snapshot()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Meshtastic preload: SIGINT snapshot refresh skipped: {exc}")
|
||||
logger.info(f"Meshtastic map: preloaded {len(cached)} nodes from cache")
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Military flight tracking and UAV detection from ADS-B data."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
@@ -13,7 +14,21 @@ logger = logging.getLogger("services.data_fetcher")
|
||||
# ---------------------------------------------------------------------------
|
||||
_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"}
|
||||
_UAV_CALLSIGN_PREFIXES = ("FORTE", "GHAWK", "REAP", "BAMS", "UAV", "UAS")
|
||||
_UAV_MODEL_KEYWORDS = ("RQ-", "MQ-", "RQ4", "MQ9", "MQ4", "MQ1", "REAPER", "GLOBALHAWK", "TRITON", "PREDATOR", "HERMES", "HERON", "BAYRAKTAR")
|
||||
_UAV_MODEL_KEYWORDS = (
|
||||
"RQ-",
|
||||
"MQ-",
|
||||
"RQ4",
|
||||
"MQ9",
|
||||
"MQ4",
|
||||
"MQ1",
|
||||
"REAPER",
|
||||
"GLOBALHAWK",
|
||||
"TRITON",
|
||||
"PREDATOR",
|
||||
"HERMES",
|
||||
"HERON",
|
||||
"BAYRAKTAR",
|
||||
)
|
||||
_UAV_WIKI = {
|
||||
"RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
"RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk",
|
||||
@@ -137,13 +152,41 @@ def _classify_uav(model: str, callsign: str):
|
||||
|
||||
|
||||
def fetch_military_flights():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("military"):
|
||||
return
|
||||
military_flights = []
|
||||
detected_uavs = []
|
||||
# Fetch from primary + supplemental military endpoints
|
||||
all_mil_ac = []
|
||||
seen_hex = set()
|
||||
try:
|
||||
url = "https://api.adsb.lol/v2/mil"
|
||||
response = fetch_with_curl(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
ac = response.json().get('ac', [])
|
||||
for a in response.json().get("ac", []):
|
||||
h = a.get("hex", "").lower()
|
||||
if h and h not in seen_hex:
|
||||
seen_hex.add(h)
|
||||
all_mil_ac.append(a)
|
||||
except Exception as e:
|
||||
logger.warning(f"adsb.lol mil fetch failed: {e}")
|
||||
# Supplemental: airplanes.live military endpoint
|
||||
try:
|
||||
resp2 = fetch_with_curl("https://api.airplanes.live/v2/mil", timeout=10)
|
||||
if resp2.status_code == 200:
|
||||
for a in resp2.json().get("ac", []):
|
||||
h = a.get("hex", "").lower()
|
||||
if h and h not in seen_hex:
|
||||
seen_hex.add(h)
|
||||
all_mil_ac.append(a)
|
||||
logger.info(f"airplanes.live mil: +{len(resp2.json().get('ac', []))} raw, {len(all_mil_ac)} total unique")
|
||||
except Exception as e:
|
||||
logger.debug(f"airplanes.live mil supplemental failed: {e}")
|
||||
try:
|
||||
if all_mil_ac:
|
||||
ac = all_mil_ac
|
||||
for f in ac:
|
||||
try:
|
||||
lat = f.get("lat")
|
||||
@@ -218,18 +261,25 @@ def fetch_military_flights():
|
||||
except Exception as loop_e:
|
||||
logger.error(f"Mil flight interpolation error: {loop_e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching military flights: {e}")
|
||||
|
||||
if not military_flights and not detected_uavs:
|
||||
logger.warning("No military flights retrieved — keeping previous data if available")
|
||||
with _data_lock:
|
||||
if latest_data.get('military_flights'):
|
||||
if latest_data.get("military_flights"):
|
||||
return
|
||||
|
||||
with _data_lock:
|
||||
latest_data['military_flights'] = military_flights
|
||||
latest_data['uavs'] = detected_uavs
|
||||
latest_data["military_flights"] = military_flights
|
||||
latest_data["uavs"] = detected_uavs
|
||||
_mark_fresh("military_flights", "uavs")
|
||||
logger.info(f"UAVs: {len(detected_uavs)} real drones detected via ADS-B")
|
||||
|
||||
@@ -238,30 +288,30 @@ def fetch_military_flights():
|
||||
remaining_mil = []
|
||||
for mf in military_flights:
|
||||
enrich_with_plane_alert(mf)
|
||||
if mf.get('alert_category'):
|
||||
mf['type'] = 'tracked_flight'
|
||||
if mf.get("alert_category"):
|
||||
mf["type"] = "tracked_flight"
|
||||
tracked_mil.append(mf)
|
||||
else:
|
||||
remaining_mil.append(mf)
|
||||
with _data_lock:
|
||||
latest_data['military_flights'] = remaining_mil
|
||||
latest_data["military_flights"] = remaining_mil
|
||||
|
||||
# Store tracked military flights — update positions for existing entries
|
||||
with _data_lock:
|
||||
existing_tracked = list(latest_data.get('tracked_flights', []))
|
||||
existing_tracked = list(latest_data.get("tracked_flights", []))
|
||||
fresh_mil_map = {}
|
||||
for t in tracked_mil:
|
||||
icao = t.get('icao24', '').upper()
|
||||
icao = t.get("icao24", "").upper()
|
||||
if icao:
|
||||
fresh_mil_map[icao] = t
|
||||
|
||||
updated_tracked = []
|
||||
seen_icaos = set()
|
||||
for old_t in existing_tracked:
|
||||
icao = old_t.get('icao24', '').upper()
|
||||
icao = old_t.get("icao24", "").upper()
|
||||
if icao in fresh_mil_map:
|
||||
fresh = fresh_mil_map[icao]
|
||||
for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'):
|
||||
for key in ("alert_category", "alert_operator", "alert_special", "alert_flag"):
|
||||
if key in old_t and key not in fresh:
|
||||
fresh[key] = old_t[key]
|
||||
updated_tracked.append(fresh)
|
||||
@@ -273,5 +323,5 @@ def fetch_military_flights():
|
||||
if icao not in seen_icaos:
|
||||
updated_tracked.append(t)
|
||||
with _data_lock:
|
||||
latest_data['tracked_flights'] = updated_tracked
|
||||
latest_data["tracked_flights"] = updated_tracked
|
||||
logger.info(f"Tracked flights: {len(updated_tracked)} total ({len(tracked_mil)} from military)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import feedparser
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
from services.oracle_service import enrich_news_items, compute_global_threat_level, detect_breaking_events
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
@@ -170,7 +171,7 @@ def fetch_news():
|
||||
logger.warning(f"Feed {source_name} failed: {e}")
|
||||
return source_name, None
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(feeds)) as pool:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(feeds), 6)) as pool:
|
||||
feed_results = list(pool.map(_fetch_feed, feeds.items()))
|
||||
|
||||
for source_name, feed in feed_results:
|
||||
@@ -191,7 +192,14 @@ def fetch_news():
|
||||
elif alert_level == "Orange": risk_score = 7
|
||||
else: risk_score = 4
|
||||
else:
|
||||
risk_keywords = ['war', 'missile', 'strike', 'attack', 'crisis', 'tension', 'military', 'conflict', 'defense', 'clash', 'nuclear']
|
||||
risk_keywords = [
|
||||
'war', 'missile', 'strike', 'attack', 'crisis', 'tension',
|
||||
'military', 'conflict', 'defense', 'clash', 'nuclear',
|
||||
'sanctions', 'ceasefire', 'invasion', 'drone', 'artillery',
|
||||
'blockade', 'escalation', 'casualties', 'airspace',
|
||||
'mobilization', 'proxy', 'insurgent', 'coup',
|
||||
'assassination', 'bioweapon', 'chemical',
|
||||
]
|
||||
text = (title + " " + summary).lower()
|
||||
|
||||
risk_score = 1
|
||||
@@ -268,6 +276,36 @@ def fetch_news():
|
||||
})
|
||||
|
||||
news_items.sort(key=lambda x: x['risk_score'], reverse=True)
|
||||
|
||||
# Oracle enrichment: sentiment, oracle scores, prediction market odds
|
||||
try:
|
||||
with _data_lock:
|
||||
markets = list(latest_data.get("prediction_markets", []))
|
||||
enrich_news_items(news_items, source_weights, markets)
|
||||
detect_breaking_events(news_items)
|
||||
except Exception as e:
|
||||
logger.warning(f"Oracle enrichment failed (news still usable): {e}")
|
||||
|
||||
# Global threat level computation (fuses news + markets + military + jamming)
|
||||
try:
|
||||
with _data_lock:
|
||||
markets = list(latest_data.get("prediction_markets", []))
|
||||
mil_flights = list(latest_data.get("military_flights", []))
|
||||
jam_zones = list(latest_data.get("gps_jamming", []))
|
||||
ships = list(latest_data.get("ships", []))
|
||||
corr_alerts = list(latest_data.get("correlations", []))
|
||||
threat_level = compute_global_threat_level(
|
||||
news_items, markets,
|
||||
military_flights=mil_flights,
|
||||
gps_jamming=jam_zones,
|
||||
ships=ships,
|
||||
correlations=corr_alerts,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Threat level computation failed: {e}")
|
||||
threat_level = {"score": 0, "level": "GREEN", "color": "#22c55e", "drivers": []}
|
||||
|
||||
with _data_lock:
|
||||
latest_data['news'] = news_items
|
||||
latest_data['threat_level'] = threat_level
|
||||
_mark_fresh("news")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Plane-Alert DB — load and enrich aircraft with tracked metadata."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
@@ -71,38 +72,126 @@ _CATEGORY_COLOR: dict[str, str] = {
|
||||
"Radiohead": "purple",
|
||||
}
|
||||
|
||||
|
||||
def _category_to_color(cat: str) -> str:
|
||||
"""O(1) exact lookup. Unknown categories default to purple."""
|
||||
return _CATEGORY_COLOR.get(cat, "purple")
|
||||
|
||||
|
||||
_PLANE_ALERT_DB: dict = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POTUS Fleet — override colors and operator names for presidential aircraft.
|
||||
# ---------------------------------------------------------------------------
|
||||
_POTUS_FLEET: dict[str, dict] = {
|
||||
"ADFDF8": {"color": "#ff1493", "operator": "Air Force One (82-8000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"},
|
||||
"ADFDF9": {"color": "#ff1493", "operator": "Air Force One (92-9000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"},
|
||||
"ADFEB7": {"color": "blue", "operator": "Air Force Two (98-0001)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEB8": {"color": "blue", "operator": "Air Force Two (98-0002)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEB9": {"color": "blue", "operator": "Air Force Two (99-0003)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEBA": {"color": "blue", "operator": "Air Force Two (99-0004)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AE6": {"color": "blue", "operator": "Air Force Two (09-0015)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AE8": {"color": "blue", "operator": "Air Force Two (09-0016)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AEA": {"color": "blue", "operator": "Air Force Two (09-0017)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AEC": {"color": "blue", "operator": "Air Force Two (19-0018)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE0865": {"color": "#ff1493", "operator": "Marine One (VH-3D)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E76": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E77": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E79": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"ADFDF8": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Air Force One (82-8000)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Air_Force_One",
|
||||
"fleet": "AF1",
|
||||
},
|
||||
"ADFDF9": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Air Force One (92-9000)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Air_Force_One",
|
||||
"fleet": "AF1",
|
||||
},
|
||||
"ADFEB7": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (98-0001)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"ADFEB8": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (98-0002)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"ADFEB9": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (99-0003)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"ADFEBA": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (99-0004)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"AE4AE6": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (09-0015)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"AE4AE8": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (09-0016)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"AE4AEA": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (09-0017)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"AE4AEC": {
|
||||
"color": "blue",
|
||||
"operator": "Air Force Two (19-0018)",
|
||||
"category": "Governments",
|
||||
"wiki": "Air_Force_Two",
|
||||
"fleet": "AF2",
|
||||
},
|
||||
"AE0865": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Marine One (VH-3D)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Marine_One",
|
||||
"fleet": "M1",
|
||||
},
|
||||
"AE5E76": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Marine One (VH-92A)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Marine_One",
|
||||
"fleet": "M1",
|
||||
},
|
||||
"AE5E77": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Marine One (VH-92A)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Marine_One",
|
||||
"fleet": "M1",
|
||||
},
|
||||
"AE5E79": {
|
||||
"color": "#ff1493",
|
||||
"operator": "Marine One (VH-92A)",
|
||||
"category": "Head of State",
|
||||
"wiki": "Marine_One",
|
||||
"fleet": "M1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load_plane_alert_db():
|
||||
"""Load plane_alert_db.json (exported from SQLite) into memory."""
|
||||
global _PLANE_ALERT_DB
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "plane_alert_db.json"
|
||||
"data",
|
||||
"plane_alert_db.json",
|
||||
)
|
||||
if not os.path.exists(json_path):
|
||||
logger.warning(f"Plane-Alert DB not found at {json_path}")
|
||||
@@ -124,8 +213,10 @@ def _load_plane_alert_db():
|
||||
except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e:
|
||||
logger.error(f"Failed to load Plane-Alert DB: {e}")
|
||||
|
||||
|
||||
_load_plane_alert_db()
|
||||
|
||||
|
||||
def enrich_with_plane_alert(flight: dict) -> dict:
|
||||
"""If flight's icao24 is in the Plane-Alert DB, add alert metadata."""
|
||||
icao = flight.get("icao24", "").strip().upper()
|
||||
@@ -145,13 +236,16 @@ def enrich_with_plane_alert(flight: dict) -> dict:
|
||||
flight["registration"] = info["registration"]
|
||||
return flight
|
||||
|
||||
|
||||
_TRACKED_NAMES_DB: dict = {}
|
||||
|
||||
|
||||
def _load_tracked_names():
|
||||
global _TRACKED_NAMES_DB
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "tracked_names.json"
|
||||
"data",
|
||||
"tracked_names.json",
|
||||
)
|
||||
if not os.path.exists(json_path):
|
||||
return
|
||||
@@ -160,16 +254,22 @@ def _load_tracked_names():
|
||||
data = json.load(f)
|
||||
for name, info in data.get("details", {}).items():
|
||||
cat = info.get("category", "Other")
|
||||
socials = info.get("socials")
|
||||
for reg in info.get("registrations", []):
|
||||
reg_clean = reg.strip().upper()
|
||||
if reg_clean:
|
||||
_TRACKED_NAMES_DB[reg_clean] = {"name": name, "category": cat}
|
||||
entry = {"name": name, "category": cat}
|
||||
if socials:
|
||||
entry["socials"] = socials
|
||||
_TRACKED_NAMES_DB[reg_clean] = entry
|
||||
logger.info(f"Tracked Names DB loaded: {len(_TRACKED_NAMES_DB)} registrations")
|
||||
except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e:
|
||||
logger.error(f"Failed to load Tracked Names DB: {e}")
|
||||
|
||||
|
||||
_load_tracked_names()
|
||||
|
||||
|
||||
def enrich_with_tracked_names(flight: dict) -> dict:
|
||||
"""If flight's registration matches our Excel extraction, tag it as tracked."""
|
||||
icao = flight.get("icao24", "").strip().upper()
|
||||
@@ -189,11 +289,50 @@ def enrich_with_tracked_names(flight: dict) -> dict:
|
||||
name = match["name"]
|
||||
flight["alert_operator"] = name
|
||||
flight["alert_category"] = match["category"]
|
||||
if match.get("socials"):
|
||||
flight["alert_socials"] = match["socials"]
|
||||
|
||||
name_lower = name.lower()
|
||||
is_gov = any(w in name_lower for w in ['state of ', 'government', 'republic', 'ministry', 'department', 'federal', 'cia'])
|
||||
is_law = any(w in name_lower for w in ['police', 'marshal', 'sheriff', 'douane', 'customs', 'patrol', 'gendarmerie', 'guardia', 'law enforcement'])
|
||||
is_med = any(w in name_lower for w in ['fire', 'bomberos', 'ambulance', 'paramedic', 'medevac', 'rescue', 'hospital', 'medical', 'lifeflight'])
|
||||
is_gov = any(
|
||||
w in name_lower
|
||||
for w in [
|
||||
"state of ",
|
||||
"government",
|
||||
"republic",
|
||||
"ministry",
|
||||
"department",
|
||||
"federal",
|
||||
"cia",
|
||||
]
|
||||
)
|
||||
is_law = any(
|
||||
w in name_lower
|
||||
for w in [
|
||||
"police",
|
||||
"marshal",
|
||||
"sheriff",
|
||||
"douane",
|
||||
"customs",
|
||||
"patrol",
|
||||
"gendarmerie",
|
||||
"guardia",
|
||||
"law enforcement",
|
||||
]
|
||||
)
|
||||
is_med = any(
|
||||
w in name_lower
|
||||
for w in [
|
||||
"fire",
|
||||
"bomberos",
|
||||
"ambulance",
|
||||
"paramedic",
|
||||
"medevac",
|
||||
"rescue",
|
||||
"hospital",
|
||||
"medical",
|
||||
"lifeflight",
|
||||
]
|
||||
)
|
||||
|
||||
if is_gov or is_law:
|
||||
flight["alert_color"] = "blue"
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Prediction market fetcher — Polymarket (Gamma API) + Kalshi.
|
||||
|
||||
Fetches active prediction market events from both platforms, merges them by
|
||||
topic similarity, classifies into categories, and stores merged odds with
|
||||
full metadata (volume, end dates, descriptions, source badges).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
_market_cache = TTLCache(maxsize=1, ttl=60) # 60-second TTL — markets change fast
|
||||
|
||||
# Delta tracking: {market_title: previous_consensus_pct}
|
||||
_prev_probabilities: dict[str, float] = {}
|
||||
|
||||
|
||||
def _finite_or_none(value):
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return n if math.isfinite(n) else None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category classification
|
||||
# ---------------------------------------------------------------------------
|
||||
CATEGORIES = ["POLITICS", "CONFLICT", "NEWS", "FINANCE", "CRYPTO"]
|
||||
|
||||
_KALSHI_CATEGORY_MAP = {
|
||||
"Politics": "POLITICS",
|
||||
"World": "NEWS",
|
||||
"Economics": "FINANCE",
|
||||
"Financials": "FINANCE",
|
||||
"Tech": "FINANCE",
|
||||
"Science": "NEWS",
|
||||
"Climate and Weather": "NEWS",
|
||||
"Sports": "NEWS",
|
||||
"Culture": "NEWS",
|
||||
}
|
||||
|
||||
_TAG_CATEGORY_MAP = {
|
||||
"Politics": "POLITICS",
|
||||
"Elections": "POLITICS",
|
||||
"US Politics": "POLITICS",
|
||||
"Trump": "POLITICS",
|
||||
"Congress": "POLITICS",
|
||||
"Supreme Court": "POLITICS",
|
||||
"Geopolitics": "CONFLICT",
|
||||
"War": "CONFLICT",
|
||||
"Military": "CONFLICT",
|
||||
"Finance": "FINANCE",
|
||||
"Stocks": "FINANCE",
|
||||
"Economy": "FINANCE",
|
||||
"Business": "FINANCE",
|
||||
"IPOs": "FINANCE",
|
||||
"Crypto": "CRYPTO",
|
||||
"Bitcoin": "CRYPTO",
|
||||
"Ethereum": "CRYPTO",
|
||||
"AI": "NEWS",
|
||||
"Science": "NEWS",
|
||||
"Sports": "NEWS",
|
||||
"Culture": "NEWS",
|
||||
"Entertainment": "NEWS",
|
||||
"Tech": "FINANCE",
|
||||
}
|
||||
|
||||
_KEYWORD_CATEGORIES = {
|
||||
"CONFLICT": [
|
||||
"war",
|
||||
"military",
|
||||
"attack",
|
||||
"missile",
|
||||
"invasion",
|
||||
"ukraine",
|
||||
"russia",
|
||||
"gaza",
|
||||
"israel",
|
||||
"nato",
|
||||
"troops",
|
||||
"bombing",
|
||||
"nuclear",
|
||||
"sanctions",
|
||||
"ceasefire",
|
||||
"houthi",
|
||||
"iran",
|
||||
"china taiwan",
|
||||
"clash",
|
||||
"conflict",
|
||||
"strike",
|
||||
"weapon",
|
||||
],
|
||||
"POLITICS": [
|
||||
"trump",
|
||||
"biden",
|
||||
"election",
|
||||
"congress",
|
||||
"senate",
|
||||
"governor",
|
||||
"president",
|
||||
"democrat",
|
||||
"republican",
|
||||
"vote",
|
||||
"party",
|
||||
"cabinet",
|
||||
"impeach",
|
||||
"legislation",
|
||||
"scotus",
|
||||
"poll",
|
||||
"vance",
|
||||
"speaker",
|
||||
"parliament",
|
||||
"prime minister",
|
||||
"macron",
|
||||
"starmer",
|
||||
],
|
||||
"CRYPTO": [
|
||||
"bitcoin",
|
||||
"btc",
|
||||
"ethereum",
|
||||
"eth",
|
||||
"crypto",
|
||||
"blockchain",
|
||||
"solana",
|
||||
"defi",
|
||||
"nft",
|
||||
"binance",
|
||||
"coinbase",
|
||||
"token",
|
||||
"microstrategy",
|
||||
"stablecoin",
|
||||
],
|
||||
"FINANCE": [
|
||||
"stock",
|
||||
"fed",
|
||||
"interest rate",
|
||||
"inflation",
|
||||
"gdp",
|
||||
"recession",
|
||||
"s&p",
|
||||
"nasdaq",
|
||||
"dow",
|
||||
"oil",
|
||||
"gold",
|
||||
"treasury",
|
||||
"tariff",
|
||||
"ipo",
|
||||
"earnings",
|
||||
"market cap",
|
||||
"revenue",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _classify_category(title: str, poly_tags: list[str], kalshi_category: str) -> str:
|
||||
"""Classify a market into one of the 5 categories."""
|
||||
# 1. Kalshi native category
|
||||
if kalshi_category:
|
||||
mapped = _KALSHI_CATEGORY_MAP.get(kalshi_category)
|
||||
if mapped:
|
||||
return mapped
|
||||
# 2. Polymarket tag labels
|
||||
for tag in poly_tags:
|
||||
mapped = _TAG_CATEGORY_MAP.get(tag)
|
||||
if mapped:
|
||||
return mapped
|
||||
# 3. Keyword matching
|
||||
title_lower = title.lower()
|
||||
for cat, keywords in _KEYWORD_CATEGORIES.items():
|
||||
for kw in keywords:
|
||||
if kw in title_lower:
|
||||
return cat
|
||||
# 4. Default
|
||||
return "NEWS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polymarket
|
||||
# ---------------------------------------------------------------------------
|
||||
def _fetch_polymarket_events() -> list[dict]:
|
||||
"""Fetch active events from Polymarket Gamma API (no auth required).
|
||||
|
||||
Fetches up to 500 events (multiple pages) for better search coverage.
|
||||
"""
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
all_events = []
|
||||
for offset in range(0, 500, 100):
|
||||
try:
|
||||
resp = fetch_with_curl(
|
||||
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=100&offset={offset}",
|
||||
timeout=15,
|
||||
)
|
||||
if not resp or resp.status_code != 200:
|
||||
break
|
||||
page = resp.json()
|
||||
if not isinstance(page, list) or not page:
|
||||
break
|
||||
all_events.extend(page)
|
||||
except Exception as e:
|
||||
logger.warning(f"Polymarket page offset={offset} error: {e}")
|
||||
break
|
||||
|
||||
if not all_events:
|
||||
return []
|
||||
|
||||
try:
|
||||
results = []
|
||||
for ev in all_events:
|
||||
title = ev.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
# Extract best probability + outcomes from markets
|
||||
markets = ev.get("markets", [])
|
||||
best_pct = None
|
||||
total_volume = 0
|
||||
outcomes = []
|
||||
for m in markets:
|
||||
# Use outcomePrices[0] (Yes price) when available — lastTradePrice
|
||||
# can be for either Yes or No side, causing "99%" for unlikely events
|
||||
raw_op = m.get("outcomePrices")
|
||||
price = None
|
||||
try:
|
||||
op = json.loads(raw_op) if isinstance(raw_op, str) else raw_op
|
||||
if isinstance(op, list) and len(op) >= 1:
|
||||
price = _finite_or_none(op[0])
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
if price is None:
|
||||
price = _finite_or_none(m.get("lastTradePrice") or m.get("bestBid"))
|
||||
pct = None
|
||||
if price is not None:
|
||||
try:
|
||||
pct = round(price * 100, 1)
|
||||
if best_pct is None or pct > best_pct:
|
||||
best_pct = pct
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
volume = _finite_or_none(m.get("volume", 0) or 0)
|
||||
if volume is not None:
|
||||
total_volume += volume
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Collect named outcomes for multi-outcome events
|
||||
oname = m.get("groupItemTitle") or ""
|
||||
if oname and pct is not None:
|
||||
outcomes.append({"name": oname, "pct": pct})
|
||||
# Only keep outcomes for multi-outcome markets (3+ named outcomes)
|
||||
if len(outcomes) > 2:
|
||||
outcomes.sort(key=lambda x: x["pct"], reverse=True)
|
||||
else:
|
||||
outcomes = []
|
||||
|
||||
# Extract tag labels
|
||||
tag_labels = [t.get("label", "") for t in ev.get("tags", []) if t.get("label")]
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"source": "polymarket",
|
||||
"pct": best_pct,
|
||||
"slug": ev.get("slug", ""),
|
||||
"description": ev.get("description") or "",
|
||||
"end_date": ev.get("endDate"),
|
||||
"volume": round(total_volume, 2),
|
||||
"volume_24h": round(_finite_or_none(ev.get("volume24hr", 0) or 0) or 0, 2),
|
||||
"tags": tag_labels,
|
||||
"outcomes": outcomes,
|
||||
}
|
||||
)
|
||||
logger.info(f"Polymarket: fetched {len(results)} active events")
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Polymarket fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kalshi
|
||||
# ---------------------------------------------------------------------------
|
||||
def _fetch_kalshi_events() -> list[dict]:
|
||||
"""Fetch active events from Kalshi public API (no auth required)."""
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
try:
|
||||
resp = fetch_with_curl(
|
||||
"https://api.elections.kalshi.com/v1/events?status=open&limit=100",
|
||||
timeout=15,
|
||||
)
|
||||
if not resp or resp.status_code != 200:
|
||||
logger.warning(f"Kalshi API returned {getattr(resp, 'status_code', 'N/A')}")
|
||||
return []
|
||||
data = resp.json()
|
||||
events = data.get("events", []) if isinstance(data, dict) else []
|
||||
|
||||
results = []
|
||||
for ev in events:
|
||||
title = ev.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
markets = ev.get("markets", [])
|
||||
best_pct = None
|
||||
total_volume = 0
|
||||
close_dates = []
|
||||
outcomes = []
|
||||
for m in markets:
|
||||
price = m.get("yes_price") or m.get("last_price")
|
||||
pct = None
|
||||
if price is not None:
|
||||
try:
|
||||
price = _finite_or_none(price)
|
||||
if price is None:
|
||||
raise ValueError("non-finite")
|
||||
pct = round(price, 1)
|
||||
if pct <= 1:
|
||||
pct = round(pct * 100, 1)
|
||||
if best_pct is None or pct > best_pct:
|
||||
best_pct = pct
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
volume = _finite_or_none(
|
||||
m.get("dollar_volume", 0) or m.get("volume", 0) or 0
|
||||
)
|
||||
if volume is not None:
|
||||
total_volume += int(volume)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
cd = m.get("close_date")
|
||||
if cd:
|
||||
close_dates.append(cd)
|
||||
# Collect named outcomes for multi-outcome events
|
||||
oname = m.get("title") or m.get("subtitle", "")
|
||||
if oname and pct is not None:
|
||||
outcomes.append({"name": oname, "pct": pct})
|
||||
# Only keep outcomes for multi-outcome markets (3+ named outcomes)
|
||||
if len(outcomes) > 2:
|
||||
outcomes.sort(key=lambda x: x["pct"], reverse=True)
|
||||
else:
|
||||
outcomes = []
|
||||
|
||||
# Description: settle_details or underlying
|
||||
desc = (ev.get("settle_details") or ev.get("underlying") or "").strip()
|
||||
sub = ev.get("sub_title", "")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"source": "kalshi",
|
||||
"pct": best_pct,
|
||||
"ticker": ev.get("ticker", ""),
|
||||
"description": desc,
|
||||
"sub_title": sub,
|
||||
"end_date": max(close_dates) if close_dates else None,
|
||||
"volume": total_volume,
|
||||
"category": ev.get("category", ""),
|
||||
"outcomes": outcomes,
|
||||
}
|
||||
)
|
||||
logger.info(f"Kalshi: fetched {len(results)} active events")
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Kalshi fetch error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge + classify
|
||||
# ---------------------------------------------------------------------------
|
||||
def _jaccard(a: str, b: str) -> float:
|
||||
"""Word-level Jaccard similarity between two strings."""
|
||||
wa = set(a.lower().split())
|
||||
wb = set(b.lower().split())
|
||||
if not wa or not wb:
|
||||
return 0.0
|
||||
return len(wa & wb) / len(wa | wb)
|
||||
|
||||
|
||||
def _merge_markets(poly_events: list[dict], kalshi_events: list[dict]) -> list[dict]:
|
||||
"""Merge Polymarket and Kalshi events by title similarity.
|
||||
|
||||
Returns a unified list with full metadata, categorized.
|
||||
"""
|
||||
merged = []
|
||||
used_kalshi = set()
|
||||
|
||||
for pe in poly_events:
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
for i, ke in enumerate(kalshi_events):
|
||||
if i in used_kalshi:
|
||||
continue
|
||||
score = _jaccard(pe["title"], ke["title"])
|
||||
if score > best_score and score >= 0.25:
|
||||
best_score = score
|
||||
best_match = (i, ke)
|
||||
|
||||
poly_pct = _finite_or_none(pe.get("pct"))
|
||||
kalshi_pct = None
|
||||
kalshi_vol = 0
|
||||
kalshi_cat = ""
|
||||
kalshi_end = None
|
||||
kalshi_desc = ""
|
||||
kalshi_ticker = ""
|
||||
|
||||
if best_match:
|
||||
used_kalshi.add(best_match[0])
|
||||
ke = best_match[1]
|
||||
kalshi_pct = _finite_or_none(ke.get("pct"))
|
||||
kalshi_vol = _finite_or_none(ke.get("volume", 0)) or 0
|
||||
kalshi_cat = ke.get("category", "")
|
||||
kalshi_end = ke.get("end_date")
|
||||
kalshi_desc = ke.get("description", "")
|
||||
kalshi_ticker = ke.get("ticker", "")
|
||||
|
||||
pcts = [p for p in [poly_pct, kalshi_pct] if p is not None]
|
||||
consensus = round(sum(pcts) / len(pcts), 1) if pcts else None
|
||||
|
||||
# Build sources list
|
||||
sources = []
|
||||
if poly_pct is not None:
|
||||
sources.append({"name": "POLY", "pct": poly_pct})
|
||||
if kalshi_pct is not None:
|
||||
sources.append({"name": "KALSHI", "pct": kalshi_pct})
|
||||
|
||||
category = _classify_category(pe["title"], pe.get("tags", []), kalshi_cat)
|
||||
|
||||
# Use best available description
|
||||
desc = pe.get("description", "") or kalshi_desc
|
||||
end_date = pe.get("end_date") or kalshi_end
|
||||
|
||||
# Use whichever source has more outcomes
|
||||
poly_outcomes = pe.get("outcomes", [])
|
||||
kalshi_outcomes = best_match[1].get("outcomes", []) if best_match else []
|
||||
outcomes = poly_outcomes if len(poly_outcomes) >= len(kalshi_outcomes) else kalshi_outcomes
|
||||
|
||||
merged.append(
|
||||
{
|
||||
"title": pe["title"],
|
||||
"polymarket_pct": poly_pct,
|
||||
"kalshi_pct": kalshi_pct,
|
||||
"consensus_pct": consensus,
|
||||
"description": desc,
|
||||
"end_date": end_date,
|
||||
"volume": _finite_or_none(pe.get("volume", 0)) or 0,
|
||||
"volume_24h": _finite_or_none(pe.get("volume_24h", 0)) or 0,
|
||||
"kalshi_volume": kalshi_vol,
|
||||
"category": category,
|
||||
"sources": sources,
|
||||
"slug": pe.get("slug", ""),
|
||||
"kalshi_ticker": kalshi_ticker,
|
||||
"outcomes": outcomes,
|
||||
}
|
||||
)
|
||||
|
||||
# Unmatched Kalshi events
|
||||
for i, ke in enumerate(kalshi_events):
|
||||
if i in used_kalshi:
|
||||
continue
|
||||
pct = _finite_or_none(ke.get("pct"))
|
||||
sources = []
|
||||
if pct is not None:
|
||||
sources.append({"name": "KALSHI", "pct": pct})
|
||||
category = _classify_category(ke["title"], [], ke.get("category", ""))
|
||||
merged.append(
|
||||
{
|
||||
"title": ke["title"],
|
||||
"polymarket_pct": None,
|
||||
"kalshi_pct": pct,
|
||||
"consensus_pct": pct,
|
||||
"description": ke.get("description", ""),
|
||||
"end_date": ke.get("end_date"),
|
||||
"volume": 0,
|
||||
"volume_24h": 0,
|
||||
"kalshi_volume": _finite_or_none(ke.get("volume", 0)) or 0,
|
||||
"category": category,
|
||||
"sources": sources,
|
||||
"slug": "",
|
||||
"kalshi_ticker": ke.get("ticker", ""),
|
||||
"outcomes": ke.get("outcomes", []),
|
||||
}
|
||||
)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
@cached(_market_cache)
|
||||
def fetch_prediction_markets_raw() -> list[dict]:
|
||||
"""Fetch and merge prediction markets from both sources. Cached 5 min."""
|
||||
poly = _fetch_polymarket_events()
|
||||
kalshi = _fetch_kalshi_events()
|
||||
merged = _merge_markets(poly, kalshi)
|
||||
logger.info(
|
||||
f"Prediction markets: {len(merged)} merged events "
|
||||
f"({len(poly)} Polymarket, {len(kalshi)} Kalshi)"
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def fetch_prediction_markets():
|
||||
"""Fetcher entry point — writes merged markets to latest_data."""
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
global _prev_probabilities
|
||||
|
||||
markets = fetch_prediction_markets_raw()
|
||||
|
||||
# Compute probability deltas vs previous fetch
|
||||
new_probs: dict[str, float] = {}
|
||||
for m in markets:
|
||||
title = m.get("title", "")
|
||||
pct = m.get("consensus_pct")
|
||||
if title and pct is not None:
|
||||
prev = _prev_probabilities.get(title)
|
||||
if prev is not None:
|
||||
m["delta_pct"] = round(pct - prev, 1)
|
||||
else:
|
||||
m["delta_pct"] = None
|
||||
new_probs[title] = pct
|
||||
else:
|
||||
m["delta_pct"] = None
|
||||
_prev_probabilities = new_probs
|
||||
|
||||
# Build trending list (top 10 by absolute delta)
|
||||
trending = sorted(
|
||||
[m for m in markets if m.get("delta_pct") is not None and m["delta_pct"] != 0],
|
||||
key=lambda x: abs(x["delta_pct"]),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
with _data_lock:
|
||||
latest_data["prediction_markets"] = markets
|
||||
latest_data["trending_markets"] = trending
|
||||
_mark_fresh("prediction_markets")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct API search (not limited to cached data)
|
||||
# ---------------------------------------------------------------------------
|
||||
def search_polymarket_direct(query: str, limit: int = 20) -> list[dict]:
|
||||
"""Search Polymarket by scanning API pages for title matches.
|
||||
|
||||
The Gamma API has no text search parameter, so we scan cached events
|
||||
plus additional pages until we find enough matches or exhaust the scan.
|
||||
"""
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
q_lower = query.lower()
|
||||
q_words = set(q_lower.split())
|
||||
results = []
|
||||
|
||||
# Scan up to 2000 events (10 pages of 200) looking for title matches
|
||||
for offset in range(0, 2000, 200):
|
||||
try:
|
||||
resp = fetch_with_curl(
|
||||
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=200&offset={offset}",
|
||||
timeout=15,
|
||||
)
|
||||
if not resp or resp.status_code != 200:
|
||||
break
|
||||
events = resp.json()
|
||||
if not isinstance(events, list) or not events:
|
||||
break
|
||||
|
||||
for ev in events:
|
||||
title = ev.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
title_lower = title.lower()
|
||||
# Check if query appears in title or word overlap
|
||||
if q_lower not in title_lower and not any(w in title_lower for w in q_words):
|
||||
continue
|
||||
|
||||
# Extract same fields as regular fetch
|
||||
markets = ev.get("markets", [])
|
||||
best_pct = None
|
||||
total_volume = 0
|
||||
outcomes = []
|
||||
for m in markets:
|
||||
# Use outcomePrices[0] (Yes price) when available
|
||||
raw_op = m.get("outcomePrices")
|
||||
price = None
|
||||
try:
|
||||
op = json.loads(raw_op) if isinstance(raw_op, str) else raw_op
|
||||
if isinstance(op, list) and len(op) >= 1:
|
||||
price = _finite_or_none(op[0])
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
if price is None:
|
||||
price = _finite_or_none(m.get("lastTradePrice") or m.get("bestBid"))
|
||||
pct = None
|
||||
if price is not None:
|
||||
try:
|
||||
pct = round(price * 100, 1)
|
||||
if best_pct is None or pct > best_pct:
|
||||
best_pct = pct
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
volume = _finite_or_none(m.get("volume", 0) or 0)
|
||||
if volume is not None:
|
||||
total_volume += volume
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
oname = m.get("groupItemTitle") or ""
|
||||
if oname and pct is not None:
|
||||
outcomes.append({"name": oname, "pct": pct})
|
||||
if len(outcomes) > 2:
|
||||
outcomes.sort(key=lambda x: x["pct"], reverse=True)
|
||||
else:
|
||||
outcomes = []
|
||||
|
||||
tag_labels = [t.get("label", "") for t in ev.get("tags", []) if t.get("label")]
|
||||
category = _classify_category(title, tag_labels, "")
|
||||
sources = []
|
||||
if best_pct is not None:
|
||||
sources.append({"name": "POLY", "pct": best_pct})
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"polymarket_pct": best_pct,
|
||||
"kalshi_pct": None,
|
||||
"consensus_pct": best_pct,
|
||||
"description": ev.get("description") or "",
|
||||
"end_date": ev.get("endDate"),
|
||||
"volume": round(total_volume, 2),
|
||||
"volume_24h": round(_finite_or_none(ev.get("volume24hr", 0) or 0) or 0, 2),
|
||||
"kalshi_volume": 0,
|
||||
"category": category,
|
||||
"sources": sources,
|
||||
"slug": ev.get("slug", ""),
|
||||
"outcomes": outcomes,
|
||||
}
|
||||
)
|
||||
# Stop scanning if we have enough results
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Polymarket search scan offset={offset} error: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"Polymarket search '{query}': {len(results)} results (scanned API)")
|
||||
return results[:limit]
|
||||
@@ -5,22 +5,37 @@ Usage:
|
||||
def fetch_something():
|
||||
...
|
||||
"""
|
||||
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import functools
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only retry on transient network/OS errors — not on parse errors, key errors, etc.
|
||||
TRANSIENT_ERRORS = (
|
||||
TimeoutError,
|
||||
ConnectionError,
|
||||
OSError,
|
||||
requests.RequestException,
|
||||
)
|
||||
|
||||
|
||||
def with_retry(max_retries: int = 3, base_delay: float = 2.0, max_delay: float = 30.0):
|
||||
"""Decorator: retries the wrapped function on any exception with exponential backoff + jitter.
|
||||
"""Decorator: retries the wrapped function on transient errors with exponential backoff + jitter.
|
||||
|
||||
Only retries on network/OS errors (TimeoutError, ConnectionError, OSError,
|
||||
requests.RequestException). Non-transient errors (ValueError, KeyError, etc.)
|
||||
propagate immediately.
|
||||
|
||||
Args:
|
||||
max_retries: Number of retry attempts after the initial failure.
|
||||
base_delay: Base delay (seconds) for exponential backoff (2 → 4 → 8 …).
|
||||
max_delay: Cap on the delay between retries.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
@@ -28,22 +43,30 @@ def with_retry(max_retries: int = 3, base_delay: float = 2.0, max_delay: float =
|
||||
for attempt in range(1 + max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
except TRANSIENT_ERRORS as exc:
|
||||
last_exc = exc
|
||||
if attempt < max_retries:
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
delay = min(base_delay * (2**attempt), max_delay)
|
||||
jitter = random.uniform(0, delay * 0.25)
|
||||
total = delay + jitter
|
||||
logger.warning(
|
||||
"%s failed (attempt %d/%d): %s — retrying in %.1fs",
|
||||
func.__name__, attempt + 1, max_retries + 1, exc, total,
|
||||
func.__name__,
|
||||
attempt + 1,
|
||||
max_retries + 1,
|
||||
exc,
|
||||
total,
|
||||
)
|
||||
time.sleep(total)
|
||||
else:
|
||||
logger.error(
|
||||
"%s failed after %d attempts: %s",
|
||||
func.__name__, max_retries + 1, exc,
|
||||
func.__name__,
|
||||
max_retries + 1,
|
||||
exc,
|
||||
)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -6,6 +6,7 @@ CelesTrak Fair Use Policy (https://celestrak.org/NORAD/elements/):
|
||||
- No parallel/concurrent connections — one request at a time
|
||||
- Set a descriptive User-Agent
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import json
|
||||
@@ -24,7 +25,9 @@ logger = logging.getLogger("services.data_fetcher")
|
||||
def _gmst(jd_ut1):
|
||||
"""Greenwich Mean Sidereal Time in radians from Julian Date."""
|
||||
t = (jd_ut1 - 2451545.0) / 36525.0
|
||||
gmst_sec = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
|
||||
gmst_sec = (
|
||||
67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
|
||||
)
|
||||
gmst_rad = (gmst_sec % 86400) / 86400.0 * 2 * math.pi
|
||||
return gmst_rad
|
||||
|
||||
@@ -38,17 +41,21 @@ _sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
|
||||
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
|
||||
_SAT_CACHE_META_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache_meta.json"
|
||||
|
||||
|
||||
def _load_sat_cache():
|
||||
"""Load satellite GP data from local disk cache."""
|
||||
try:
|
||||
if _SAT_CACHE_PATH.exists():
|
||||
import os
|
||||
|
||||
age_hours = (time.time() - os.path.getmtime(str(_SAT_CACHE_PATH))) / 3600
|
||||
if age_hours < 48:
|
||||
with open(_SAT_CACHE_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list) and len(data) > 10:
|
||||
logger.info(f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)")
|
||||
logger.info(
|
||||
f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)"
|
||||
)
|
||||
# Restore last_modified from metadata
|
||||
_load_cache_meta()
|
||||
return data
|
||||
@@ -58,6 +65,7 @@ def _load_sat_cache():
|
||||
logger.warning(f"Satellites: Failed to load disk cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_sat_cache(data):
|
||||
"""Save satellite GP data to local disk cache."""
|
||||
try:
|
||||
@@ -69,6 +77,7 @@ def _save_sat_cache(data):
|
||||
except (IOError, OSError) as e:
|
||||
logger.warning(f"Satellites: Failed to save disk cache: {e}")
|
||||
|
||||
|
||||
def _load_cache_meta():
|
||||
"""Load cache metadata (Last-Modified timestamp) from disk."""
|
||||
try:
|
||||
@@ -79,6 +88,7 @@ def _load_cache_meta():
|
||||
except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError):
|
||||
pass
|
||||
|
||||
|
||||
def _save_cache_meta():
|
||||
"""Save cache metadata to disk."""
|
||||
try:
|
||||
@@ -90,54 +100,357 @@ def _save_cache_meta():
|
||||
|
||||
# Satellite intelligence classification database
|
||||
_SAT_INTEL_DB = [
|
||||
("USA 224", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
|
||||
("USA 245", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
|
||||
("USA 290", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
|
||||
("USA 314", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
|
||||
("USA 338", {"country": "USA", "mission": "military_recon", "sat_type": "Keyhole Successor", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
|
||||
("TOPAZ", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
|
||||
("PERSONA", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
|
||||
("KONDOR", {"country": "Russia", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)"}),
|
||||
("BARS-M", {"country": "Russia", "mission": "military_recon", "sat_type": "Mapping Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Bars-M"}),
|
||||
("YAOGAN", {"country": "China", "mission": "military_recon", "sat_type": "Remote Sensing / ELINT", "wiki": "https://en.wikipedia.org/wiki/Yaogan"}),
|
||||
("GAOFEN", {"country": "China", "mission": "military_recon", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/Gaofen"}),
|
||||
("JILIN", {"country": "China", "mission": "commercial_imaging", "sat_type": "Video / Imaging", "wiki": "https://en.wikipedia.org/wiki/Jilin-1"}),
|
||||
("OFEK", {"country": "Israel", "mission": "military_recon", "sat_type": "Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Ofeq"}),
|
||||
("CSO", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)"}),
|
||||
("IGS", {"country": "Japan", "mission": "military_recon", "sat_type": "Intelligence Gathering", "wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite"}),
|
||||
("CAPELLA", {"country": "USA", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/Capella_Space"}),
|
||||
("ICEYE", {"country": "Finland", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/ICEYE"}),
|
||||
("COSMO", {"country": "Italy", "mission": "sar", "sat_type": "SAR Constellation", "wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed"}),
|
||||
("TANDEM", {"country": "Germany", "mission": "sar", "sat_type": "SAR Interferometry", "wiki": "https://en.wikipedia.org/wiki/TanDEM-X"}),
|
||||
("PAZ", {"country": "Spain", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)"}),
|
||||
("WORLDVIEW", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar High-Res", "wiki": "https://en.wikipedia.org/wiki/WorldView-3"}),
|
||||
("GEOEYE", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Imaging", "wiki": "https://en.wikipedia.org/wiki/GeoEye-1"}),
|
||||
("PLEIADES", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Imaging", "wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)"}),
|
||||
("SPOT", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Medium-Res", "wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)"}),
|
||||
("PLANET", {"country": "USA", "mission": "commercial_imaging", "sat_type": "PlanetScope", "wiki": "https://en.wikipedia.org/wiki/Planet_Labs"}),
|
||||
("SKYSAT", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Planet Video", "wiki": "https://en.wikipedia.org/wiki/SkySat"}),
|
||||
("BLACKSKY", {"country": "USA", "mission": "commercial_imaging", "sat_type": "BlackSky Imaging", "wiki": "https://en.wikipedia.org/wiki/BlackSky"}),
|
||||
("NROL", {"country": "USA", "mission": "sigint", "sat_type": "Classified NRO", "wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office"}),
|
||||
("MENTOR", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT / ELINT", "wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)"}),
|
||||
("LUCH", {"country": "Russia", "mission": "sigint", "sat_type": "Relay / SIGINT", "wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)"}),
|
||||
("SHIJIAN", {"country": "China", "mission": "sigint", "sat_type": "ELINT / Tech Demo", "wiki": "https://en.wikipedia.org/wiki/Shijian"}),
|
||||
("NAVSTAR", {"country": "USA", "mission": "navigation", "sat_type": "GPS", "wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks"}),
|
||||
("GLONASS", {"country": "Russia", "mission": "navigation", "sat_type": "GLONASS", "wiki": "https://en.wikipedia.org/wiki/GLONASS"}),
|
||||
("BEIDOU", {"country": "China", "mission": "navigation", "sat_type": "BeiDou", "wiki": "https://en.wikipedia.org/wiki/BeiDou"}),
|
||||
("GALILEO", {"country": "EU", "mission": "navigation", "sat_type": "Galileo", "wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)"}),
|
||||
("SBIRS", {"country": "USA", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System"}),
|
||||
("TUNDRA", {"country": "Russia", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)"}),
|
||||
("ISS", {"country": "Intl", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/International_Space_Station"}),
|
||||
("TIANGONG", {"country": "China", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station"}),
|
||||
("CSS", {"country": "China", "mission": "space_station", "sat_type": "Chinese Space Station", "wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station"}),
|
||||
# Russian military — COSMOS covers the bulk of active Russian military/SIGINT satellites
|
||||
("COSMOS", {"country": "Russia", "mission": "military_recon", "sat_type": "Russian Military / COSMOS", "wiki": "https://en.wikipedia.org/wiki/Kosmos_(satellite)"}),
|
||||
# US military communications
|
||||
("WGS", {"country": "USA", "mission": "sigint", "sat_type": "Wideband Global SATCOM", "wiki": "https://en.wikipedia.org/wiki/Wideband_Global_SATCOM"}),
|
||||
("AEHF", {"country": "USA", "mission": "sigint", "sat_type": "Advanced EHF MILSATCOM", "wiki": "https://en.wikipedia.org/wiki/Advanced_Extremely_High_Frequency"}),
|
||||
("MUOS", {"country": "USA", "mission": "sigint", "sat_type": "Mobile User Objective System", "wiki": "https://en.wikipedia.org/wiki/Mobile_User_Objective_System"}),
|
||||
# EU Earth observation
|
||||
("SENTINEL", {"country": "EU", "mission": "commercial_imaging", "sat_type": "ESA Copernicus", "wiki": "https://en.wikipedia.org/wiki/Sentinel_(satellite)"}),
|
||||
(
|
||||
"USA 224",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "KH-11 Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"USA 245",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "KH-11 Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"USA 290",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "KH-11 Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"USA 314",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "KH-11 Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"USA 338",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Keyhole Successor",
|
||||
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"TOPAZ",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Optical Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"PERSONA",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Optical Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"KONDOR",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "military_sar",
|
||||
"sat_type": "SAR Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"BARS-M",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Mapping Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Bars-M",
|
||||
},
|
||||
),
|
||||
(
|
||||
"YAOGAN",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Remote Sensing / ELINT",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Yaogan",
|
||||
},
|
||||
),
|
||||
(
|
||||
"GAOFEN",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "High-Res Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Gaofen",
|
||||
},
|
||||
),
|
||||
(
|
||||
"JILIN",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Video / Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Jilin-1",
|
||||
},
|
||||
),
|
||||
(
|
||||
"OFEK",
|
||||
{
|
||||
"country": "Israel",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Ofeq",
|
||||
},
|
||||
),
|
||||
(
|
||||
"CSO",
|
||||
{
|
||||
"country": "France",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Optical Reconnaissance",
|
||||
"wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"IGS",
|
||||
{
|
||||
"country": "Japan",
|
||||
"mission": "military_recon",
|
||||
"sat_type": "Intelligence Gathering",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite",
|
||||
},
|
||||
),
|
||||
(
|
||||
"CAPELLA",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "sar",
|
||||
"sat_type": "SAR Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Capella_Space",
|
||||
},
|
||||
),
|
||||
(
|
||||
"ICEYE",
|
||||
{
|
||||
"country": "Finland",
|
||||
"mission": "sar",
|
||||
"sat_type": "SAR Microsatellite",
|
||||
"wiki": "https://en.wikipedia.org/wiki/ICEYE",
|
||||
},
|
||||
),
|
||||
(
|
||||
"COSMO-SKYMED",
|
||||
{
|
||||
"country": "Italy",
|
||||
"mission": "sar",
|
||||
"sat_type": "SAR Constellation",
|
||||
"wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed",
|
||||
},
|
||||
),
|
||||
(
|
||||
"TANDEM",
|
||||
{
|
||||
"country": "Germany",
|
||||
"mission": "sar",
|
||||
"sat_type": "SAR Interferometry",
|
||||
"wiki": "https://en.wikipedia.org/wiki/TanDEM-X",
|
||||
},
|
||||
),
|
||||
(
|
||||
"PAZ",
|
||||
{
|
||||
"country": "Spain",
|
||||
"mission": "sar",
|
||||
"sat_type": "SAR Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"WORLDVIEW",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Maxar High-Res",
|
||||
"wiki": "https://en.wikipedia.org/wiki/WorldView-3",
|
||||
},
|
||||
),
|
||||
(
|
||||
"GEOEYE",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Maxar Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/GeoEye-1",
|
||||
},
|
||||
),
|
||||
(
|
||||
"PLEIADES",
|
||||
{
|
||||
"country": "France",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Airbus Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"SPOT",
|
||||
{
|
||||
"country": "France",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Airbus Medium-Res",
|
||||
"wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"PLANET",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "PlanetScope",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Planet_Labs",
|
||||
},
|
||||
),
|
||||
(
|
||||
"SKYSAT",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "Planet Video",
|
||||
"wiki": "https://en.wikipedia.org/wiki/SkySat",
|
||||
},
|
||||
),
|
||||
(
|
||||
"BLACKSKY",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "commercial_imaging",
|
||||
"sat_type": "BlackSky Imaging",
|
||||
"wiki": "https://en.wikipedia.org/wiki/BlackSky",
|
||||
},
|
||||
),
|
||||
(
|
||||
"NROL",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "sigint",
|
||||
"sat_type": "Classified NRO",
|
||||
"wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office",
|
||||
},
|
||||
),
|
||||
(
|
||||
"MENTOR",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "sigint",
|
||||
"sat_type": "SIGINT / ELINT",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"LUCH",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "sigint",
|
||||
"sat_type": "Relay / SIGINT",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"SHIJIAN",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "sigint",
|
||||
"sat_type": "ELINT / Tech Demo",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Shijian",
|
||||
},
|
||||
),
|
||||
(
|
||||
"NAVSTAR",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "navigation",
|
||||
"sat_type": "GPS",
|
||||
"wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks",
|
||||
},
|
||||
),
|
||||
(
|
||||
"GLONASS",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "navigation",
|
||||
"sat_type": "GLONASS",
|
||||
"wiki": "https://en.wikipedia.org/wiki/GLONASS",
|
||||
},
|
||||
),
|
||||
(
|
||||
"BEIDOU",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "navigation",
|
||||
"sat_type": "BeiDou",
|
||||
"wiki": "https://en.wikipedia.org/wiki/BeiDou",
|
||||
},
|
||||
),
|
||||
(
|
||||
"GALILEO",
|
||||
{
|
||||
"country": "EU",
|
||||
"mission": "navigation",
|
||||
"sat_type": "Galileo",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"SBIRS",
|
||||
{
|
||||
"country": "USA",
|
||||
"mission": "early_warning",
|
||||
"sat_type": "Missile Warning",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System",
|
||||
},
|
||||
),
|
||||
(
|
||||
"TUNDRA",
|
||||
{
|
||||
"country": "Russia",
|
||||
"mission": "early_warning",
|
||||
"sat_type": "Missile Warning",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)",
|
||||
},
|
||||
),
|
||||
(
|
||||
"ISS",
|
||||
{
|
||||
"country": "Intl",
|
||||
"mission": "space_station",
|
||||
"sat_type": "Space Station",
|
||||
"wiki": "https://en.wikipedia.org/wiki/International_Space_Station",
|
||||
},
|
||||
),
|
||||
(
|
||||
"TIANGONG",
|
||||
{
|
||||
"country": "China",
|
||||
"mission": "space_station",
|
||||
"sat_type": "Space Station",
|
||||
"wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -154,7 +467,7 @@ def _parse_tle_to_gp(name, norad_id, line1, line2):
|
||||
if bstar_str:
|
||||
mantissa = float(bstar_str[:-2]) / 1e5
|
||||
exponent = int(bstar_str[-2:])
|
||||
bstar = mantissa * (10 ** exponent)
|
||||
bstar = mantissa * (10**exponent)
|
||||
else:
|
||||
bstar = 0.0
|
||||
epoch_yr = int(line1[18:20])
|
||||
@@ -206,17 +519,50 @@ def _fetch_satellites_from_tle_api():
|
||||
seen_ids.add(sat_id)
|
||||
all_results.append(gp)
|
||||
time.sleep(1) # Polite delay between requests
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.debug(f"TLE fallback search '{term}' failed: {e}")
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def fetch_satellites():
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("satellites"):
|
||||
return
|
||||
sats = []
|
||||
try:
|
||||
now_ts = time.time()
|
||||
if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > _CELESTRAK_FETCH_INTERVAL:
|
||||
|
||||
# On first call, try disk cache before hitting CelesTrak
|
||||
if _sat_gp_cache["data"] is None:
|
||||
disk_data = _load_sat_cache()
|
||||
if disk_data:
|
||||
import os
|
||||
|
||||
cache_mtime = (
|
||||
os.path.getmtime(str(_SAT_CACHE_PATH)) if _SAT_CACHE_PATH.exists() else 0
|
||||
)
|
||||
_sat_gp_cache["data"] = disk_data
|
||||
_sat_gp_cache["last_fetch"] = cache_mtime # real fetch time so 24h check works
|
||||
_sat_gp_cache["source"] = "disk_cache"
|
||||
logger.info(
|
||||
f"Satellites: Bootstrapped from disk cache ({len(disk_data)} records, "
|
||||
f"{(now_ts - cache_mtime) / 3600:.1f}h old)"
|
||||
)
|
||||
|
||||
if (
|
||||
_sat_gp_cache["data"] is None
|
||||
or (now_ts - _sat_gp_cache["last_fetch"]) > _CELESTRAK_FETCH_INTERVAL
|
||||
):
|
||||
gp_urls = [
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
|
||||
@@ -232,7 +578,9 @@ def fetch_satellites():
|
||||
if response.status_code == 304:
|
||||
# Data unchanged — reset timer without re-downloading
|
||||
_sat_gp_cache["last_fetch"] = now_ts
|
||||
logger.info(f"Satellites: CelesTrak returned 304 Not Modified (data unchanged)")
|
||||
logger.info(
|
||||
f"Satellites: CelesTrak returned 304 Not Modified (data unchanged)"
|
||||
)
|
||||
break
|
||||
if response.status_code == 200:
|
||||
gp_data = response.json()
|
||||
@@ -241,14 +589,24 @@ def fetch_satellites():
|
||||
_sat_gp_cache["last_fetch"] = now_ts
|
||||
_sat_gp_cache["source"] = "celestrak"
|
||||
# Store Last-Modified header for future conditional requests
|
||||
if hasattr(response, 'headers'):
|
||||
if hasattr(response, "headers"):
|
||||
lm = response.headers.get("Last-Modified")
|
||||
if lm:
|
||||
_sat_gp_cache["last_modified"] = lm
|
||||
_save_sat_cache(gp_data)
|
||||
logger.info(f"Satellites: Downloaded {len(gp_data)} GP records from CelesTrak")
|
||||
logger.info(
|
||||
f"Satellites: Downloaded {len(gp_data)} GP records from CelesTrak"
|
||||
)
|
||||
break
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
|
||||
continue
|
||||
|
||||
@@ -261,8 +619,17 @@ def fetch_satellites():
|
||||
_sat_gp_cache["last_fetch"] = now_ts
|
||||
_sat_gp_cache["source"] = "tle_api"
|
||||
_save_sat_cache(fallback_data)
|
||||
logger.info(f"Satellites: Got {len(fallback_data)} records from TLE fallback API")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
logger.info(
|
||||
f"Satellites: Got {len(fallback_data)} records from TLE fallback API"
|
||||
)
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.error(f"Satellites: TLE fallback also failed: {e}")
|
||||
|
||||
if _sat_gp_cache["data"] is None:
|
||||
@@ -279,9 +646,14 @@ def fetch_satellites():
|
||||
latest_data["satellites"] = sats
|
||||
return
|
||||
|
||||
if _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"] and _sat_classified_cache["data"]:
|
||||
if (
|
||||
_sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"]
|
||||
and _sat_classified_cache["data"]
|
||||
):
|
||||
classified = _sat_classified_cache["data"]
|
||||
logger.info(f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)")
|
||||
logger.info(
|
||||
f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)"
|
||||
)
|
||||
else:
|
||||
classified = []
|
||||
for sat in data:
|
||||
@@ -309,41 +681,57 @@ def fetch_satellites():
|
||||
classified.append(entry)
|
||||
_sat_classified_cache["data"] = classified
|
||||
_sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"]
|
||||
logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog")
|
||||
logger.info(
|
||||
f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog"
|
||||
)
|
||||
|
||||
all_sats = classified
|
||||
|
||||
now = datetime.utcnow()
|
||||
jd, fr = jday(now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6)
|
||||
jd, fr = jday(
|
||||
now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6
|
||||
)
|
||||
|
||||
for s in all_sats:
|
||||
try:
|
||||
mean_motion = s.get('MEAN_MOTION')
|
||||
ecc = s.get('ECCENTRICITY')
|
||||
incl = s.get('INCLINATION')
|
||||
raan = s.get('RA_OF_ASC_NODE')
|
||||
argp = s.get('ARG_OF_PERICENTER')
|
||||
ma = s.get('MEAN_ANOMALY')
|
||||
bstar = s.get('BSTAR', 0)
|
||||
epoch_str = s.get('EPOCH')
|
||||
norad_id = s.get('id', 0)
|
||||
mean_motion = s.get("MEAN_MOTION")
|
||||
ecc = s.get("ECCENTRICITY")
|
||||
incl = s.get("INCLINATION")
|
||||
raan = s.get("RA_OF_ASC_NODE")
|
||||
argp = s.get("ARG_OF_PERICENTER")
|
||||
ma = s.get("MEAN_ANOMALY")
|
||||
bstar = s.get("BSTAR", 0)
|
||||
epoch_str = s.get("EPOCH")
|
||||
norad_id = s.get("id", 0)
|
||||
|
||||
if mean_motion is None or ecc is None or incl is None:
|
||||
continue
|
||||
|
||||
epoch_dt = datetime.strptime(epoch_str[:19], '%Y-%m-%dT%H:%M:%S')
|
||||
epoch_jd, epoch_fr = jday(epoch_dt.year, epoch_dt.month, epoch_dt.day,
|
||||
epoch_dt.hour, epoch_dt.minute, epoch_dt.second)
|
||||
epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
epoch_jd, epoch_fr = jday(
|
||||
epoch_dt.year,
|
||||
epoch_dt.month,
|
||||
epoch_dt.day,
|
||||
epoch_dt.hour,
|
||||
epoch_dt.minute,
|
||||
epoch_dt.second,
|
||||
)
|
||||
|
||||
sat_obj = Satrec()
|
||||
sat_obj.sgp4init(
|
||||
WGS72, 'i', norad_id,
|
||||
WGS72,
|
||||
"i",
|
||||
norad_id,
|
||||
(epoch_jd + epoch_fr) - 2433281.5,
|
||||
bstar, 0.0, 0.0, ecc,
|
||||
math.radians(argp), math.radians(incl),
|
||||
bstar,
|
||||
0.0,
|
||||
0.0,
|
||||
ecc,
|
||||
math.radians(argp),
|
||||
math.radians(incl),
|
||||
math.radians(ma),
|
||||
mean_motion * 2 * math.pi / 1440.0,
|
||||
math.radians(raan)
|
||||
math.radians(raan),
|
||||
)
|
||||
|
||||
e, r, v = sat_obj.sgp4(jd, fr)
|
||||
@@ -353,13 +741,13 @@ def fetch_satellites():
|
||||
x, y, z = r
|
||||
gmst = _gmst(jd + fr)
|
||||
lng_rad = math.atan2(y, x) - gmst
|
||||
lat_rad = math.atan2(z, math.sqrt(x*x + y*y))
|
||||
alt_km = math.sqrt(x*x + y*y + z*z) - 6371.0
|
||||
lat_rad = math.atan2(z, math.sqrt(x * x + y * y))
|
||||
alt_km = math.sqrt(x * x + y * y + z * z) - 6371.0
|
||||
|
||||
s['lat'] = round(math.degrees(lat_rad), 4)
|
||||
s["lat"] = round(math.degrees(lat_rad), 4)
|
||||
lng_deg = math.degrees(lng_rad) % 360
|
||||
s['lng'] = round(lng_deg - 360 if lng_deg > 180 else lng_deg, 4)
|
||||
s['alt_km'] = round(alt_km, 1)
|
||||
s["lng"] = round(lng_deg - 360 if lng_deg > 180 else lng_deg, 4)
|
||||
s["alt_km"] = round(alt_km, 1)
|
||||
|
||||
vx, vy, vz = v
|
||||
omega_e = 7.2921159e-5
|
||||
@@ -373,23 +761,40 @@ def fetch_satellites():
|
||||
v_east = -sin_lng * vx_g + cos_lng * vy_g
|
||||
v_north = -sin_lat * cos_lng * vx_g - sin_lat * sin_lng * vy_g + cos_lat * vz_g
|
||||
ground_speed_kms = math.sqrt(v_east**2 + v_north**2)
|
||||
s['speed_knots'] = round(ground_speed_kms * 1943.84, 1)
|
||||
s["speed_knots"] = round(ground_speed_kms * 1943.84, 1)
|
||||
heading_rad = math.atan2(v_east, v_north)
|
||||
s['heading'] = round(math.degrees(heading_rad) % 360, 1)
|
||||
sat_name = s.get('name', '')
|
||||
usa_match = re.search(r'USA[\s\-]*(\d+)', sat_name)
|
||||
s["heading"] = round(math.degrees(heading_rad) % 360, 1)
|
||||
sat_name = s.get("name", "")
|
||||
usa_match = re.search(r"USA[\s\-]*(\d+)", sat_name)
|
||||
if usa_match:
|
||||
s['wiki'] = f"https://en.wikipedia.org/wiki/USA-{usa_match.group(1)}"
|
||||
for k in ('MEAN_MOTION', 'ECCENTRICITY', 'INCLINATION',
|
||||
'RA_OF_ASC_NODE', 'ARG_OF_PERICENTER', 'MEAN_ANOMALY',
|
||||
'BSTAR', 'EPOCH', 'tle1', 'tle2'):
|
||||
s["wiki"] = f"https://en.wikipedia.org/wiki/USA-{usa_match.group(1)}"
|
||||
for k in (
|
||||
"MEAN_MOTION",
|
||||
"ECCENTRICITY",
|
||||
"INCLINATION",
|
||||
"RA_OF_ASC_NODE",
|
||||
"ARG_OF_PERICENTER",
|
||||
"MEAN_ANOMALY",
|
||||
"BSTAR",
|
||||
"EPOCH",
|
||||
"tle1",
|
||||
"tle2",
|
||||
):
|
||||
s.pop(k, None)
|
||||
sats.append(s)
|
||||
except (ValueError, TypeError, KeyError, AttributeError, ZeroDivisionError):
|
||||
continue
|
||||
|
||||
logger.info(f"Satellites: {len(classified)} classified, {len(sats)} positioned")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, json.JSONDecodeError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching satellites: {e}")
|
||||
if sats:
|
||||
with _data_lock:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data.
|
||||
|
||||
Merges live MQTT signals with cached Meshtastic map API nodes.
|
||||
Live MQTT signals always take priority (fresher) — API nodes fill in the gaps
|
||||
for the thousands of nodes our MQTT listener hasn't heard yet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
|
||||
logger = logging.getLogger("services.data_fetcher")
|
||||
|
||||
|
||||
def _merge_sigint_snapshot(
|
||||
live_signals: list[dict],
|
||||
api_nodes: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Merge live bridge signals with cached Meshtastic map nodes.
|
||||
|
||||
Live Meshtastic observations always win over map/API nodes for the same callsign
|
||||
because they include fresher region/channel metadata.
|
||||
"""
|
||||
|
||||
merged = list(live_signals)
|
||||
live_callsigns = {s["callsign"] for s in merged if s.get("source") == "meshtastic"}
|
||||
for node in api_nodes:
|
||||
if node.get("callsign") in live_callsigns:
|
||||
continue
|
||||
merged.append(node)
|
||||
merged.sort(key=lambda item: str(item.get("timestamp", "") or ""), reverse=True)
|
||||
return merged
|
||||
|
||||
|
||||
def _sigint_totals(signals: list[dict]) -> dict[str, int]:
|
||||
totals = {
|
||||
"total": len(signals),
|
||||
"meshtastic": 0,
|
||||
"meshtastic_live": 0,
|
||||
"meshtastic_map": 0,
|
||||
"aprs": 0,
|
||||
"js8call": 0,
|
||||
}
|
||||
for sig in signals:
|
||||
source = str(sig.get("source", "") or "").lower()
|
||||
if source == "meshtastic":
|
||||
totals["meshtastic"] += 1
|
||||
if bool(sig.get("from_api")):
|
||||
totals["meshtastic_map"] += 1
|
||||
else:
|
||||
totals["meshtastic_live"] += 1
|
||||
elif source == "aprs":
|
||||
totals["aprs"] += 1
|
||||
elif source == "js8call":
|
||||
totals["js8call"] += 1
|
||||
return totals
|
||||
|
||||
|
||||
def build_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str, int]]:
|
||||
"""Build the current merged SIGINT snapshot without hitting the network."""
|
||||
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
live_signals = sigint_grid.get_all_signals()
|
||||
with _data_lock:
|
||||
api_nodes = list(latest_data.get("meshtastic_map_nodes", []))
|
||||
merged = _merge_sigint_snapshot(live_signals, api_nodes)
|
||||
channel_stats = sigint_grid.get_mesh_channel_stats(api_nodes or None)
|
||||
totals = _sigint_totals(merged)
|
||||
return merged, channel_stats, totals
|
||||
|
||||
|
||||
def refresh_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str, int]]:
|
||||
"""Refresh latest_data SIGINT state from current bridge + cache state."""
|
||||
|
||||
signals, channel_stats, totals = build_sigint_snapshot()
|
||||
with _data_lock:
|
||||
latest_data["sigint"] = signals
|
||||
latest_data["mesh_channel_stats"] = channel_stats
|
||||
latest_data["sigint_totals"] = totals
|
||||
_mark_fresh("sigint")
|
||||
return signals, channel_stats, totals
|
||||
|
||||
|
||||
def fetch_sigint():
|
||||
"""Fetch all signals from the SIGINT Grid, merge with Meshtastic map nodes."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("sigint_meshtastic", "sigint_aprs"):
|
||||
return
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
# Start bridges on first call (idempotent)
|
||||
sigint_grid.start()
|
||||
|
||||
signals, channel_stats, totals = refresh_sigint_snapshot()
|
||||
|
||||
status = sigint_grid.status
|
||||
logger.info(
|
||||
f"SIGINT: {len(signals)} signals "
|
||||
f"(APRS:{status['aprs']} MESH:{status['meshtastic']} "
|
||||
f"JS8:{status['js8call']} MAP:{totals['meshtastic_map']})"
|
||||
)
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Train tracking fetchers with normalized metadata and non-redundant merging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from services.fetchers._store import _data_lock, _mark_fresh, latest_data
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EARTH_RADIUS_KM = 6371.0
|
||||
_MERGE_DISTANCE_KM = 5.0
|
||||
_MAX_INFERRED_SPEED_KMH = 350.0
|
||||
_TRACK_CACHE_TTL_S = 6 * 60 * 60
|
||||
|
||||
_SOURCE_METADATA: dict[str, dict[str, object]] = {
|
||||
"amtrak": {
|
||||
"source_label": "Amtraker",
|
||||
"operator": "Amtrak",
|
||||
"country": "US",
|
||||
"telemetry_quality": "aggregated",
|
||||
"priority": 70,
|
||||
},
|
||||
"digitraffic": {
|
||||
"source_label": "Digitraffic Finland",
|
||||
"operator": "Finnish Rail",
|
||||
"country": "FI",
|
||||
"telemetry_quality": "official",
|
||||
"priority": 100,
|
||||
},
|
||||
# Future slots so better official feeds can be merged without changing the
|
||||
# rest of the train pipeline or duplicating map entities.
|
||||
"networkrail": {
|
||||
"source_label": "Network Rail Open Data",
|
||||
"operator": "Network Rail",
|
||||
"country": "GB",
|
||||
"telemetry_quality": "official",
|
||||
"priority": 98,
|
||||
},
|
||||
"dbcargo": {
|
||||
"source_label": "DB Cargo link2rail",
|
||||
"operator": "DB Cargo",
|
||||
"country": "DE",
|
||||
"telemetry_quality": "commercial",
|
||||
"priority": 96,
|
||||
},
|
||||
"railinc": {
|
||||
"source_label": "Railinc RailSight",
|
||||
"operator": "Railinc",
|
||||
"country": "US",
|
||||
"telemetry_quality": "commercial",
|
||||
"priority": 97,
|
||||
},
|
||||
"sncf": {
|
||||
"source_label": "SNCF Open Data",
|
||||
"operator": "SNCF",
|
||||
"country": "FR",
|
||||
"telemetry_quality": "official",
|
||||
"priority": 94,
|
||||
},
|
||||
}
|
||||
|
||||
_TRAIN_TRACK_CACHE: dict[str, dict[str, float]] = {}
|
||||
|
||||
|
||||
def _safe_float(value) -> float | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_observed_at(value) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
raw = float(value)
|
||||
return raw / 1000.0 if raw > 1_000_000_000_000 else raw
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
|
||||
lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
a = (
|
||||
math.sin(dlat / 2.0) ** 2
|
||||
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2.0) ** 2
|
||||
)
|
||||
return 2.0 * _EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _bearing_degrees(lat1: float, lon1: float, lat2: float, lon2: float) -> float | None:
|
||||
if lat1 == lat2 and lon1 == lon2:
|
||||
return None
|
||||
lat1_rad, lat2_rad = math.radians(lat1), math.radians(lat2)
|
||||
dlon_rad = math.radians(lon2 - lon1)
|
||||
y = math.sin(dlon_rad) * math.cos(lat2_rad)
|
||||
x = (
|
||||
math.cos(lat1_rad) * math.sin(lat2_rad)
|
||||
- math.sin(lat1_rad) * math.cos(lat2_rad) * math.cos(dlon_rad)
|
||||
)
|
||||
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||
|
||||
|
||||
def _source_meta(source: str) -> dict[str, object]:
|
||||
return dict(_SOURCE_METADATA.get(source, {}))
|
||||
|
||||
|
||||
def _normalize_train(
|
||||
*,
|
||||
source: str,
|
||||
raw_id: str,
|
||||
number: str,
|
||||
lat,
|
||||
lng,
|
||||
name: str = "",
|
||||
status: str = "Active",
|
||||
route: str = "",
|
||||
speed_kmh=None,
|
||||
heading=None,
|
||||
operator: str | None = None,
|
||||
country: str | None = None,
|
||||
source_label: str | None = None,
|
||||
telemetry_quality: str | None = None,
|
||||
observed_at=None,
|
||||
) -> dict | None:
|
||||
lat_f = _safe_float(lat)
|
||||
lng_f = _safe_float(lng)
|
||||
if lat_f is None or lng_f is None:
|
||||
return None
|
||||
if not (-90.0 <= lat_f <= 90.0 and -180.0 <= lng_f <= 180.0):
|
||||
return None
|
||||
|
||||
number_text = str(number or "").strip()
|
||||
meta = _source_meta(source)
|
||||
observed_ts = _parse_observed_at(observed_at) or datetime.now(timezone.utc).timestamp()
|
||||
speed_f = _safe_float(speed_kmh)
|
||||
heading_f = _safe_float(heading)
|
||||
normalized = {
|
||||
"id": str(raw_id or f"{source}-{number_text or 'unknown'}"),
|
||||
"name": str(name or f"Train {number_text or '?'}").strip(),
|
||||
"number": number_text,
|
||||
"source": source,
|
||||
"source_label": str(source_label or meta.get("source_label") or source.upper()),
|
||||
"operator": str(operator or meta.get("operator") or "").strip(),
|
||||
"country": str(country or meta.get("country") or "").strip(),
|
||||
"telemetry_quality": str(
|
||||
telemetry_quality or meta.get("telemetry_quality") or "unknown"
|
||||
).strip(),
|
||||
"lat": lat_f,
|
||||
"lng": lng_f,
|
||||
"speed_kmh": speed_f,
|
||||
"heading": heading_f,
|
||||
"status": str(status or "Active").strip(),
|
||||
"route": str(route or "").strip(),
|
||||
"_source_priority": int(meta.get("priority") or 0),
|
||||
"_observed_ts": observed_ts,
|
||||
}
|
||||
_apply_motion_estimates(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _prune_track_cache(now_ts: float) -> None:
|
||||
stale_before = now_ts - _TRACK_CACHE_TTL_S
|
||||
stale_ids = [train_id for train_id, entry in _TRAIN_TRACK_CACHE.items() if entry["ts"] < stale_before]
|
||||
for train_id in stale_ids:
|
||||
_TRAIN_TRACK_CACHE.pop(train_id, None)
|
||||
|
||||
|
||||
def _apply_motion_estimates(train: dict) -> None:
|
||||
train_id = str(train.get("id") or "")
|
||||
if not train_id:
|
||||
return
|
||||
now_ts = float(train.get("_observed_ts") or datetime.now(timezone.utc).timestamp())
|
||||
_prune_track_cache(now_ts)
|
||||
previous = _TRAIN_TRACK_CACHE.get(train_id)
|
||||
if previous:
|
||||
dt_s = now_ts - previous["ts"]
|
||||
if 5.0 <= dt_s <= 15.0 * 60.0:
|
||||
distance_km = _haversine_km(
|
||||
float(previous["lat"]),
|
||||
float(previous["lng"]),
|
||||
float(train["lat"]),
|
||||
float(train["lng"]),
|
||||
)
|
||||
if 0.02 <= distance_km <= (_MAX_INFERRED_SPEED_KMH * (dt_s / 3600.0)):
|
||||
if train.get("speed_kmh") is None:
|
||||
inferred_speed = distance_km / (dt_s / 3600.0)
|
||||
train["speed_kmh"] = round(min(inferred_speed, _MAX_INFERRED_SPEED_KMH), 1)
|
||||
if train.get("heading") is None:
|
||||
inferred_heading = _bearing_degrees(
|
||||
float(previous["lat"]),
|
||||
float(previous["lng"]),
|
||||
float(train["lat"]),
|
||||
float(train["lng"]),
|
||||
)
|
||||
if inferred_heading is not None:
|
||||
train["heading"] = round(inferred_heading, 1)
|
||||
|
||||
_TRAIN_TRACK_CACHE[train_id] = {
|
||||
"lat": float(train["lat"]),
|
||||
"lng": float(train["lng"]),
|
||||
"ts": now_ts,
|
||||
}
|
||||
|
||||
|
||||
def _train_merge_key(train: dict) -> str:
|
||||
operator = str(train.get("operator") or "").strip().lower()
|
||||
country = str(train.get("country") or "").strip().lower()
|
||||
number = str(train.get("number") or "").strip().lower()
|
||||
if operator and number:
|
||||
return f"{country}|{operator}|{number}"
|
||||
return f"{str(train.get('source') or '').lower()}|{str(train.get('id') or '').lower()}"
|
||||
|
||||
|
||||
def _train_completeness(train: dict) -> tuple[int, int, int]:
|
||||
return (
|
||||
1 if train.get("speed_kmh") is not None else 0,
|
||||
1 if train.get("heading") is not None else 0,
|
||||
1 if train.get("route") else 0,
|
||||
)
|
||||
|
||||
|
||||
def _should_merge(existing: dict, candidate: dict) -> bool:
|
||||
if _train_merge_key(existing) != _train_merge_key(candidate):
|
||||
return False
|
||||
return _haversine_km(
|
||||
float(existing["lat"]),
|
||||
float(existing["lng"]),
|
||||
float(candidate["lat"]),
|
||||
float(candidate["lng"]),
|
||||
) <= _MERGE_DISTANCE_KM
|
||||
|
||||
|
||||
def _merge_train_pair(existing: dict, candidate: dict) -> dict:
|
||||
existing_priority = int(existing.get("_source_priority") or 0)
|
||||
candidate_priority = int(candidate.get("_source_priority") or 0)
|
||||
existing_score = (existing_priority, _train_completeness(existing))
|
||||
candidate_score = (candidate_priority, _train_completeness(candidate))
|
||||
primary = candidate if candidate_score > existing_score else existing
|
||||
secondary = existing if primary is candidate else candidate
|
||||
merged = dict(primary)
|
||||
|
||||
for field in (
|
||||
"speed_kmh",
|
||||
"heading",
|
||||
"route",
|
||||
"status",
|
||||
"operator",
|
||||
"country",
|
||||
"source_label",
|
||||
"telemetry_quality",
|
||||
):
|
||||
if merged.get(field) in (None, "", "Active"):
|
||||
replacement = secondary.get(field)
|
||||
if replacement not in (None, ""):
|
||||
merged[field] = replacement
|
||||
|
||||
if primary is not candidate and float(candidate.get("_observed_ts") or 0) > float(
|
||||
primary.get("_observed_ts") or 0
|
||||
):
|
||||
merged["lat"] = candidate["lat"]
|
||||
merged["lng"] = candidate["lng"]
|
||||
merged["_observed_ts"] = candidate["_observed_ts"]
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_nonredundant_trains(*sources: list[dict]) -> list[dict]:
|
||||
merged: list[dict] = []
|
||||
for source_trains in sources:
|
||||
for train in source_trains:
|
||||
exact_match = next(
|
||||
(
|
||||
idx
|
||||
for idx, existing in enumerate(merged)
|
||||
if existing.get("source") == train.get("source")
|
||||
and existing.get("id") == train.get("id")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_match is not None:
|
||||
merged[exact_match] = _merge_train_pair(merged[exact_match], train)
|
||||
continue
|
||||
|
||||
merged_idx = next(
|
||||
(idx for idx, existing in enumerate(merged) if _should_merge(existing, train)),
|
||||
None,
|
||||
)
|
||||
if merged_idx is not None:
|
||||
merged[merged_idx] = _merge_train_pair(merged[merged_idx], train)
|
||||
continue
|
||||
merged.append(train)
|
||||
|
||||
merged.sort(
|
||||
key=lambda train: (
|
||||
str(train.get("country") or ""),
|
||||
str(train.get("operator") or ""),
|
||||
str(train.get("number") or ""),
|
||||
str(train.get("id") or ""),
|
||||
)
|
||||
)
|
||||
for train in merged:
|
||||
train.pop("_source_priority", None)
|
||||
train.pop("_observed_ts", None)
|
||||
return merged
|
||||
|
||||
|
||||
def _fetch_amtraker() -> list[dict]:
|
||||
"""Fetch all active Amtrak trains from the Amtraker API."""
|
||||
try:
|
||||
resp = fetch_with_curl(
|
||||
"https://api.amtraker.com/v3/trains",
|
||||
timeout=20,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/136.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json,text/plain,*/*",
|
||||
"Referer": "https://www.amtraker.com/",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Amtraker returned %s", resp.status_code)
|
||||
return []
|
||||
raw = resp.json()
|
||||
trains: list[dict] = []
|
||||
for train_num, variants in raw.items():
|
||||
if not isinstance(variants, list):
|
||||
continue
|
||||
for item in variants:
|
||||
normalized = _normalize_train(
|
||||
source="amtrak",
|
||||
raw_id=f"AMTK-{item.get('trainID', train_num)}",
|
||||
name=item.get("routeName", f"Train {train_num}"),
|
||||
number=str(item.get("trainNum", train_num) or train_num),
|
||||
lat=item.get("lat"),
|
||||
lng=item.get("lon"),
|
||||
speed_kmh=item.get("velocity") or item.get("speed"),
|
||||
heading=item.get("heading") or item.get("bearing"),
|
||||
status=item.get("trainTimely") or "On Time",
|
||||
route=item.get("routeName", ""),
|
||||
observed_at=item.get("updatedAt")
|
||||
or item.get("lastValTS")
|
||||
or item.get("eventDT"),
|
||||
)
|
||||
if normalized:
|
||||
trains.append(normalized)
|
||||
return trains
|
||||
except Exception as exc:
|
||||
logger.warning("Amtraker fetch error: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_digitraffic() -> list[dict]:
|
||||
"""Fetch live train positions from Finnish DigiTraffic API."""
|
||||
try:
|
||||
resp = fetch_with_curl(
|
||||
"https://rata.digitraffic.fi/api/v1/train-locations/latest",
|
||||
timeout=15,
|
||||
headers={
|
||||
"Accept-Encoding": "gzip",
|
||||
"User-Agent": "ShadowBroker-OSINT/1.0",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("DigiTraffic returned %s", resp.status_code)
|
||||
return []
|
||||
raw = resp.json()
|
||||
trains: list[dict] = []
|
||||
for item in raw:
|
||||
location = item.get("location", {})
|
||||
coords = location.get("coordinates")
|
||||
if not coords or len(coords) < 2:
|
||||
continue
|
||||
lon, lat = coords[0], coords[1]
|
||||
train_number = str(item.get("trainNumber", "") or "").strip()
|
||||
route_bits = [
|
||||
str(item.get("departureStationShortCode") or "").strip(),
|
||||
str(item.get("stationShortCode") or "").strip(),
|
||||
]
|
||||
route = " -> ".join([bit for bit in route_bits if bit])
|
||||
train_type = str(item.get("trainType") or "").strip()
|
||||
normalized = _normalize_train(
|
||||
source="digitraffic",
|
||||
raw_id=f"FIN-{train_number or len(trains)}",
|
||||
name=f"{train_type} {train_number}".strip() or f"Train {train_number or '?'}",
|
||||
number=train_number,
|
||||
lat=lat,
|
||||
lng=lon,
|
||||
speed_kmh=item.get("speed"),
|
||||
heading=item.get("heading"),
|
||||
status="Active",
|
||||
route=route,
|
||||
observed_at=item.get("timestamp"),
|
||||
)
|
||||
if normalized:
|
||||
trains.append(normalized)
|
||||
return trains
|
||||
except Exception as exc:
|
||||
logger.warning("DigiTraffic fetch error: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
_TRAIN_FETCHERS: tuple[tuple[str, Callable[[], list[dict]]], ...] = (
|
||||
("amtrak", _fetch_amtraker),
|
||||
("digitraffic", _fetch_digitraffic),
|
||||
)
|
||||
|
||||
|
||||
def fetch_trains():
|
||||
"""Fetch trains from all configured sources and merge without duplicates."""
|
||||
with _data_lock:
|
||||
existing_trains = list(latest_data.get("trains") or [])
|
||||
source_batches: list[list[dict]] = []
|
||||
source_counts: list[str] = []
|
||||
for source_name, fetcher in _TRAIN_FETCHERS:
|
||||
batch = fetcher()
|
||||
source_batches.append(batch)
|
||||
if batch:
|
||||
source_counts.append(f"{source_name}:{len(batch)}")
|
||||
|
||||
trains = _merge_nonredundant_trains(*source_batches)
|
||||
if not trains and existing_trains:
|
||||
logger.warning(
|
||||
"Train refresh returned 0 records — preserving %s cached trains until the next successful poll",
|
||||
len(existing_trains),
|
||||
)
|
||||
trains = existing_trains
|
||||
|
||||
with _data_lock:
|
||||
latest_data["trains"] = trains
|
||||
_mark_fresh("trains")
|
||||
logger.info(
|
||||
"Trains: %s total%s",
|
||||
len(trains),
|
||||
f" ({', '.join(source_counts)})" if source_counts else "",
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Ukraine air raid alerts via alerts.in.ua API.
|
||||
|
||||
Polls active alerts every 2 minutes, matches to oblast boundary polygons,
|
||||
and produces GeoJSON-style records for map rendering.
|
||||
|
||||
Requires ALERTS_IN_UA_TOKEN env var (free registration at alerts.in.ua).
|
||||
Gracefully skips if token is not set.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── Alert type → color mapping ──────────────────────────────────────────────
|
||||
ALERT_COLORS = {
|
||||
"air_raid": "#ef4444", # red
|
||||
"artillery_shelling": "#f97316", # orange
|
||||
"urban_fights": "#eab308", # yellow
|
||||
"chemical": "#a855f7", # purple
|
||||
"nuclear": "#dc2626", # dark red
|
||||
}
|
||||
|
||||
# ─── Load oblast boundary polygons (once) ────────────────────────────────────
|
||||
_oblast_geojson = None
|
||||
|
||||
|
||||
def _load_oblasts():
|
||||
global _oblast_geojson
|
||||
if _oblast_geojson is not None:
|
||||
return _oblast_geojson
|
||||
|
||||
data_path = Path(__file__).resolve().parent.parent.parent / "data" / "ukraine_oblasts.geojson"
|
||||
if not data_path.exists():
|
||||
logger.error(f"Ukraine oblasts GeoJSON not found at {data_path}")
|
||||
_oblast_geojson = {}
|
||||
return _oblast_geojson
|
||||
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
_oblast_geojson = json.load(f)
|
||||
|
||||
logger.info(f"Loaded {len(_oblast_geojson.get('features', []))} Ukraine oblast boundaries")
|
||||
return _oblast_geojson
|
||||
|
||||
|
||||
def _find_oblast_geometry(location_title: str):
|
||||
"""Find the polygon geometry for an oblast by matching Ukrainian name."""
|
||||
oblasts = _load_oblasts()
|
||||
features = oblasts.get("features", [])
|
||||
for feat in features:
|
||||
props = feat.get("properties", {})
|
||||
name = props.get("name", "")
|
||||
# Exact match on Ukrainian name (e.g. "Луганська область")
|
||||
if name == location_title:
|
||||
return feat.get("geometry"), props.get("name_en", "")
|
||||
# Fuzzy: try partial match (alert may say "Київська область" but GeoJSON says "Київ")
|
||||
for feat in features:
|
||||
props = feat.get("properties", {})
|
||||
name = props.get("name", "")
|
||||
if location_title in name or name in location_title:
|
||||
return feat.get("geometry"), props.get("name_en", "")
|
||||
return None, ""
|
||||
|
||||
|
||||
# ─── Fetcher ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_ukraine_air_raid_alerts():
|
||||
"""Fetch active Ukraine air raid alerts from alerts.in.ua."""
|
||||
from services.fetchers._store import is_any_active
|
||||
|
||||
if not is_any_active("ukraine_alerts"):
|
||||
return
|
||||
|
||||
token = os.environ.get("ALERTS_IN_UA_TOKEN", "")
|
||||
if not token:
|
||||
logger.debug("ALERTS_IN_UA_TOKEN not set, skipping Ukraine air raid alerts")
|
||||
return
|
||||
|
||||
alerts_out = []
|
||||
try:
|
||||
url = f"https://api.alerts.in.ua/v1/alerts/active.json?token={token}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
response = fetch_with_curl(url, timeout=10, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
raw_alerts = data.get("alerts", [])
|
||||
|
||||
for alert in raw_alerts:
|
||||
loc_type = alert.get("location_type", "")
|
||||
# Only render oblast-level alerts (not raion/city/hromada)
|
||||
if loc_type != "oblast":
|
||||
continue
|
||||
|
||||
location_title = alert.get("location_title", "")
|
||||
alert_type = alert.get("alert_type", "air_raid")
|
||||
geometry, name_en = _find_oblast_geometry(location_title)
|
||||
|
||||
if not geometry:
|
||||
logger.debug(f"No geometry for oblast: {location_title}")
|
||||
continue
|
||||
|
||||
alerts_out.append({
|
||||
"id": alert.get("id", 0),
|
||||
"alert_type": alert_type,
|
||||
"location_title": location_title,
|
||||
"location_uid": alert.get("location_uid", ""),
|
||||
"name_en": name_en,
|
||||
"started_at": alert.get("started_at", ""),
|
||||
"color": ALERT_COLORS.get(alert_type, "#ef4444"),
|
||||
"geometry": geometry,
|
||||
})
|
||||
|
||||
logger.info(f"Ukraine alerts: {len(alerts_out)} active oblast-level alerts "
|
||||
f"(from {len(raw_alerts)} total)")
|
||||
elif response.status_code == 401:
|
||||
logger.warning("alerts.in.ua returned 401 — check ALERTS_IN_UA_TOKEN")
|
||||
elif response.status_code == 429:
|
||||
logger.warning("alerts.in.ua rate-limited (429)")
|
||||
else:
|
||||
logger.warning(f"alerts.in.ua returned HTTP {response.status_code}")
|
||||
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
|
||||
logger.error(f"Error fetching Ukraine alerts: {e}")
|
||||
|
||||
with _data_lock:
|
||||
latest_data["ukraine_alerts"] = alerts_out
|
||||
if alerts_out:
|
||||
_mark_fresh("ukraine_alerts")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Finnhub scheduled fetcher — congress trades, insider transactions, defense quotes.
|
||||
|
||||
Runs on a 15-minute schedule and stores results in latest_data["unusual_whales"].
|
||||
Also updates latest_data["stocks"] with Finnhub quotes (replaces yfinance for defense tickers).
|
||||
Falls back gracefully if no API key is configured.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
from services.fetchers.retry import with_retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@with_retry(max_retries=1, base_delay=2)
|
||||
def fetch_unusual_whales():
|
||||
"""Fetch congress trades, insider txns, and defense quotes from Finnhub."""
|
||||
import os
|
||||
|
||||
if not os.environ.get("FINNHUB_API_KEY", "").strip():
|
||||
logger.debug("FINNHUB_API_KEY not set — skipping scheduled fetch.")
|
||||
return
|
||||
|
||||
from services.unusual_whales_connector import (
|
||||
fetch_congress_trades,
|
||||
fetch_insider_transactions,
|
||||
fetch_defense_quotes,
|
||||
FinnhubConnectorError,
|
||||
)
|
||||
|
||||
result: dict = {}
|
||||
|
||||
# Defense stock quotes (also populates latest_data["stocks"])
|
||||
try:
|
||||
quotes = fetch_defense_quotes()
|
||||
if quotes:
|
||||
result["quotes"] = quotes
|
||||
# Mirror into stocks for backward compat with existing MarketsPanel fallback
|
||||
with _data_lock:
|
||||
latest_data["stocks"] = quotes
|
||||
_mark_fresh("stocks")
|
||||
except FinnhubConnectorError as e:
|
||||
logger.warning(f"Finnhub quotes fetch failed: {e.detail}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub quotes fetch error: {e}")
|
||||
|
||||
# Congress trades
|
||||
try:
|
||||
congress = fetch_congress_trades()
|
||||
result["congress_trades"] = congress.get("trades", [])
|
||||
except FinnhubConnectorError as e:
|
||||
logger.warning(f"Finnhub congress trades fetch failed: {e.detail}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub congress trades fetch error: {e}")
|
||||
|
||||
# Insider transactions
|
||||
try:
|
||||
insiders = fetch_insider_transactions()
|
||||
result["insider_transactions"] = insiders.get("transactions", [])
|
||||
except FinnhubConnectorError as e:
|
||||
logger.warning(f"Finnhub insider fetch failed: {e.detail}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub insider fetch error: {e}")
|
||||
|
||||
if not result:
|
||||
logger.warning("Finnhub update produced no data; keeping previous cache.")
|
||||
return
|
||||
|
||||
with _data_lock:
|
||||
latest_data["unusual_whales"] = result
|
||||
_mark_fresh("unusual_whales")
|
||||
logger.info(
|
||||
f"Finnhub updated: {len(result.get('congress_trades', []))} congress, "
|
||||
f"{len(result.get('insider_transactions', []))} insider, "
|
||||
f"{len(result.get('quotes', {}))} quotes"
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Yacht-Alert DB — load and enrich AIS vessels with tracked yacht metadata."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
@@ -26,7 +27,8 @@ def _load_yacht_alert_db():
|
||||
global _YACHT_ALERT_DB
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data", "yacht_alert_db.json"
|
||||
"data",
|
||||
"yacht_alert_db.json",
|
||||
)
|
||||
if not os.path.exists(json_path):
|
||||
logger.warning(f"Yacht-Alert DB not found at {json_path}")
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Geocoding proxy for Nominatim with caching and proper headers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Dict, List
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.fetchers.geo import cached_airports
|
||||
|
||||
_CACHE_TTL_S = 900
|
||||
_CACHE_MAX = 1000
|
||||
_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_local_search_cache: List[Dict[str, Any]] | None = None
|
||||
_local_search_lock = threading.Lock()
|
||||
|
||||
_USER_AGENT = os.environ.get(
|
||||
"NOMINATIM_USER_AGENT", "ShadowBroker/1.0 (https://github.com/BigBodyCobain/Shadowbroker)"
|
||||
)
|
||||
|
||||
|
||||
def _get_cache(key: str):
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
entry = _cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if now - entry["ts"] > _CACHE_TTL_S:
|
||||
_cache.pop(key, None)
|
||||
return None
|
||||
return entry["value"]
|
||||
|
||||
|
||||
def _set_cache(key: str, value):
|
||||
with _cache_lock:
|
||||
if len(_cache) >= _CACHE_MAX:
|
||||
# Simple eviction: drop ~10% oldest keys
|
||||
keys = list(_cache.keys())[: max(1, _CACHE_MAX // 10)]
|
||||
for k in keys:
|
||||
_cache.pop(k, None)
|
||||
_cache[key] = {"ts": time.time(), "value": value}
|
||||
|
||||
|
||||
def _load_local_search_cache() -> List[Dict[str, Any]]:
|
||||
global _local_search_cache
|
||||
with _local_search_lock:
|
||||
if _local_search_cache is not None:
|
||||
return _local_search_cache
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
cache_path = Path(__file__).resolve().parents[1] / "data" / "geocode_cache.json"
|
||||
try:
|
||||
if cache_path.exists():
|
||||
raw = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
for label, coords in raw.items():
|
||||
if (
|
||||
isinstance(label, str)
|
||||
and isinstance(coords, list)
|
||||
and len(coords) == 2
|
||||
and all(isinstance(v, (int, float)) for v in coords)
|
||||
):
|
||||
results.append(
|
||||
{
|
||||
"label": label,
|
||||
"lat": float(coords[0]),
|
||||
"lng": float(coords[1]),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
results = []
|
||||
|
||||
_local_search_cache = results
|
||||
return _local_search_cache
|
||||
|
||||
|
||||
def _search_local_fallback(query: str, limit: int) -> List[Dict[str, Any]]:
|
||||
q = query.strip().lower()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
matches: List[Dict[str, Any]] = []
|
||||
seen: set[tuple[float, float, str]] = set()
|
||||
|
||||
for item in cached_airports:
|
||||
haystacks = [
|
||||
str(item.get("name", "")).lower(),
|
||||
str(item.get("iata", "")).lower(),
|
||||
str(item.get("id", "")).lower(),
|
||||
]
|
||||
if any(q in h for h in haystacks):
|
||||
label = f'{item.get("name", "Airport")} ({item.get("iata", "")})'
|
||||
key = (float(item["lat"]), float(item["lng"]), label)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
matches.append(
|
||||
{
|
||||
"label": label,
|
||||
"lat": float(item["lat"]),
|
||||
"lng": float(item["lng"]),
|
||||
}
|
||||
)
|
||||
if len(matches) >= limit:
|
||||
return matches
|
||||
|
||||
for item in _load_local_search_cache():
|
||||
label = str(item.get("label", ""))
|
||||
if q in label.lower():
|
||||
key = (float(item["lat"]), float(item["lng"]), label)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def _reverse_geocode_offline(lat: float, lng: float) -> Dict[str, Any]:
|
||||
try:
|
||||
import reverse_geocoder as rg
|
||||
|
||||
hit = rg.search((lat, lng), mode=1)[0]
|
||||
city = hit.get("name") or ""
|
||||
state = hit.get("admin1") or ""
|
||||
country = hit.get("cc") or ""
|
||||
parts = [city, state, country]
|
||||
label = ", ".join([p for p in parts if p]) or "Unknown"
|
||||
return {"label": label}
|
||||
except Exception:
|
||||
return {"label": "Unknown"}
|
||||
|
||||
|
||||
def search_geocode(query: str, limit: int = 5, local_only: bool = False) -> List[Dict[str, Any]]:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return []
|
||||
limit = max(1, min(int(limit or 5), 10))
|
||||
key = f"search:{q.lower()}:{limit}:{int(local_only)}"
|
||||
cached = _get_cache(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if local_only:
|
||||
results = _search_local_fallback(q, limit)
|
||||
_set_cache(key, results)
|
||||
return results
|
||||
|
||||
params = urlencode({"q": q, "format": "json", "limit": str(limit)})
|
||||
url = f"https://nominatim.openstreetmap.org/search?{params}"
|
||||
try:
|
||||
res = fetch_with_curl(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept-Language": "en",
|
||||
},
|
||||
timeout=6,
|
||||
)
|
||||
except Exception:
|
||||
results = _search_local_fallback(q, limit)
|
||||
_set_cache(key, results)
|
||||
return results
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
if res and res.status_code == 200:
|
||||
try:
|
||||
data = res.json() or []
|
||||
for item in data:
|
||||
try:
|
||||
results.append(
|
||||
{
|
||||
"label": item.get("display_name"),
|
||||
"lat": float(item.get("lat")),
|
||||
"lng": float(item.get("lon")),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
results = []
|
||||
if not results:
|
||||
results = _search_local_fallback(q, limit)
|
||||
|
||||
_set_cache(key, results)
|
||||
return results
|
||||
|
||||
|
||||
def reverse_geocode(lat: float, lng: float, local_only: bool = False) -> Dict[str, Any]:
|
||||
key = f"reverse:{lat:.4f},{lng:.4f}:{int(local_only)}"
|
||||
cached = _get_cache(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if local_only:
|
||||
payload = _reverse_geocode_offline(lat, lng)
|
||||
_set_cache(key, payload)
|
||||
return payload
|
||||
|
||||
params = urlencode(
|
||||
{
|
||||
"lat": f"{lat}",
|
||||
"lon": f"{lng}",
|
||||
"format": "json",
|
||||
"zoom": "10",
|
||||
"addressdetails": "1",
|
||||
}
|
||||
)
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?{params}"
|
||||
try:
|
||||
res = fetch_with_curl(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept-Language": "en",
|
||||
},
|
||||
timeout=6,
|
||||
)
|
||||
except Exception:
|
||||
payload = _reverse_geocode_offline(lat, lng)
|
||||
_set_cache(key, payload)
|
||||
return payload
|
||||
|
||||
label = "Unknown"
|
||||
if res and res.status_code == 200:
|
||||
try:
|
||||
data = res.json() or {}
|
||||
addr = data.get("address") or {}
|
||||
city = (
|
||||
addr.get("city")
|
||||
or addr.get("town")
|
||||
or addr.get("village")
|
||||
or addr.get("county")
|
||||
or ""
|
||||
)
|
||||
state = addr.get("state") or addr.get("region") or ""
|
||||
country = addr.get("country") or ""
|
||||
parts = [city, state, country]
|
||||
label = ", ".join([p for p in parts if p]) or (
|
||||
data.get("display_name", "") or "Unknown"
|
||||
)
|
||||
except Exception:
|
||||
label = "Unknown"
|
||||
if label == "Unknown":
|
||||
payload = _reverse_geocode_offline(lat, lng)
|
||||
_set_cache(key, payload)
|
||||
return payload
|
||||
|
||||
payload = {"label": label}
|
||||
_set_cache(key, payload)
|
||||
return payload
|
||||
+248
-92
@@ -1,8 +1,11 @@
|
||||
import requests
|
||||
import logging
|
||||
import zipfile
|
||||
import socket
|
||||
import ipaddress
|
||||
from cachetools import cached, TTLCache
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,6 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
# Cache Frontline data for 30 minutes, it doesn't move that fast
|
||||
frontline_cache = TTLCache(maxsize=1, ttl=1800)
|
||||
|
||||
|
||||
@cached(frontline_cache)
|
||||
def fetch_ukraine_frontlines():
|
||||
"""
|
||||
@@ -18,27 +22,34 @@ def fetch_ukraine_frontlines():
|
||||
"""
|
||||
try:
|
||||
logger.info("Fetching DeepStateMap from GitHub mirror...")
|
||||
|
||||
|
||||
# First, query the repo tree to find the latest file name
|
||||
tree_url = "https://api.github.com/repos/cyterat/deepstate-map-data/git/trees/main?recursive=1"
|
||||
tree_url = (
|
||||
"https://api.github.com/repos/cyterat/deepstate-map-data/git/trees/main?recursive=1"
|
||||
)
|
||||
res_tree = requests.get(tree_url, timeout=10)
|
||||
|
||||
|
||||
if res_tree.status_code == 200:
|
||||
tree_data = res_tree.json().get("tree", [])
|
||||
# Filter for geojson files in data folder
|
||||
geo_files = [item["path"] for item in tree_data if item["path"].startswith("data/deepstatemap_data_") and item["path"].endswith(".geojson")]
|
||||
|
||||
geo_files = [
|
||||
item["path"]
|
||||
for item in tree_data
|
||||
if item["path"].startswith("data/deepstatemap_data_")
|
||||
and item["path"].endswith(".geojson")
|
||||
]
|
||||
|
||||
if geo_files:
|
||||
# Get the alphabetically latest file (since it's named with YYYYMMDD)
|
||||
latest_file = sorted(geo_files)[-1]
|
||||
|
||||
|
||||
raw_url = f"https://raw.githubusercontent.com/cyterat/deepstate-map-data/main/{latest_file}"
|
||||
logger.info(f"Downloading latest DeepStateMap: {raw_url}")
|
||||
|
||||
|
||||
res_geo = requests.get(raw_url, timeout=20)
|
||||
if res_geo.status_code == 200:
|
||||
data = res_geo.json()
|
||||
|
||||
|
||||
# The Cyterat GitHub mirror strips all properties and just provides a raw array of Feature polygons.
|
||||
# Based on DeepStateMap's frontend mapping, the array index corresponds to the zone type:
|
||||
# 0: Russian-occupied areas
|
||||
@@ -49,68 +60,78 @@ def fetch_ukraine_frontlines():
|
||||
0: "Russian-occupied areas",
|
||||
1: "Russian advance",
|
||||
2: "Liberated area",
|
||||
3: "Russian-occupied areas", # Crimea / LPR / DPR
|
||||
4: "Directions of UA attacks"
|
||||
3: "Russian-occupied areas", # Crimea / LPR / DPR
|
||||
4: "Directions of UA attacks",
|
||||
}
|
||||
|
||||
|
||||
if "features" in data:
|
||||
for idx, feature in enumerate(data["features"]):
|
||||
if "properties" not in feature or feature["properties"] is None:
|
||||
feature["properties"] = {}
|
||||
|
||||
feature["properties"]["name"] = name_map.get(idx, "Russian-occupied areas")
|
||||
|
||||
feature["properties"]["name"] = name_map.get(
|
||||
idx, "Russian-occupied areas"
|
||||
)
|
||||
feature["properties"]["zone_id"] = idx
|
||||
|
||||
|
||||
return data
|
||||
else:
|
||||
logger.error(f"Failed to fetch parsed Github Raw GeoJSON: {res_geo.status_code}")
|
||||
logger.error(
|
||||
f"Failed to fetch parsed Github Raw GeoJSON: {res_geo.status_code}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to fetch Github Tree for Deepstatemap: {res_tree.status_code}")
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"Error fetching DeepStateMap: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Cache GDELT data for 6 hours - heavy aggregation, data doesn't change rapidly
|
||||
gdelt_cache = TTLCache(maxsize=1, ttl=21600)
|
||||
|
||||
|
||||
def _extract_domain(url):
|
||||
"""Extract a clean source name from a URL, e.g. 'nytimes.com' from 'https://www.nytimes.com/...'"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).hostname or ''
|
||||
|
||||
host = urlparse(url).hostname or ""
|
||||
# Strip www. prefix
|
||||
if host.startswith('www.'):
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
except (ValueError, AttributeError, KeyError): # non-critical
|
||||
return url[:40]
|
||||
|
||||
|
||||
def _url_to_headline(url):
|
||||
"""Extract a human-readable headline from a URL path.
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites'
|
||||
Falls back to domain name if the URL slug is gibberish (hex IDs, UUIDs, etc.).
|
||||
"""
|
||||
import re
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.hostname or ''
|
||||
if domain.startswith('www.'):
|
||||
domain = parsed.hostname or ""
|
||||
if domain.startswith("www."):
|
||||
domain = domain[4:]
|
||||
|
||||
# Get last meaningful path segment
|
||||
path = unquote(parsed.path).strip('/')
|
||||
path = unquote(parsed.path).strip("/")
|
||||
if not path:
|
||||
return domain
|
||||
|
||||
# Try the last path segment first, then walk backwards
|
||||
segments = [s for s in path.split('/') if s]
|
||||
slug = ''
|
||||
segments = [s for s in path.split("/") if s]
|
||||
slug = ""
|
||||
for seg in reversed(segments):
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
for ext in [".html", ".htm", ".php", ".asp", ".aspx", ".shtml"]:
|
||||
if seg.lower().endswith(ext):
|
||||
seg = seg[:-len(ext)]
|
||||
seg = seg[: -len(ext)]
|
||||
# Skip segments that are clearly not headlines
|
||||
if _is_gibberish(seg):
|
||||
continue
|
||||
@@ -121,22 +142,22 @@ def _url_to_headline(url):
|
||||
return domain
|
||||
|
||||
# Remove common ID patterns at start/end
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
slug = re.sub(r"^[\d]+-", "", slug) # leading "13847569-"
|
||||
slug = re.sub(r"-[\da-f]{6,}$", "", slug) # trailing hex IDs
|
||||
slug = re.sub(r"[-_]c-\d+$", "", slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r"^p=\d+$", "", slug) # WordPress ?p=1234
|
||||
# Convert slug separators to spaces
|
||||
slug = slug.replace('-', ' ').replace('_', ' ')
|
||||
slug = re.sub(r'\s+', ' ', slug).strip()
|
||||
slug = slug.replace("-", " ").replace("_", " ")
|
||||
slug = re.sub(r"\s+", " ", slug).strip()
|
||||
|
||||
# Final gibberish check after cleanup
|
||||
if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
|
||||
if len(slug) < 8 or _is_gibberish(slug.replace(" ", "-")):
|
||||
return domain
|
||||
|
||||
# Title case and truncate
|
||||
headline = slug.title()
|
||||
if len(headline) > 90:
|
||||
headline = headline[:87] + '...'
|
||||
headline = headline[:87] + "..."
|
||||
return headline
|
||||
except (ValueError, AttributeError, KeyError): # non-critical
|
||||
return url[:60]
|
||||
@@ -146,19 +167,22 @@ def _is_gibberish(text):
|
||||
"""Detect if a URL segment is gibberish (hex IDs, UUIDs, numeric IDs, etc.)
|
||||
rather than a real human-readable slug like 'us-strikes-iran'."""
|
||||
import re
|
||||
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
# Pure numbers
|
||||
if re.match(r'^\d+$', t):
|
||||
if re.match(r"^\d+$", t):
|
||||
return True
|
||||
# UUID pattern (with or without dashes)
|
||||
if re.match(r'^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$', t, re.I):
|
||||
if re.match(
|
||||
r"^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$", t, re.I
|
||||
):
|
||||
return True
|
||||
# Hex-heavy string: more than 40% hex digits among alphanumeric chars
|
||||
alnum = re.sub(r'[^a-zA-Z0-9]', '', t)
|
||||
alnum = re.sub(r"[^a-zA-Z0-9]", "", t)
|
||||
if alnum:
|
||||
hex_chars = sum(1 for c in alnum if c in '0123456789abcdefABCDEF')
|
||||
hex_chars = sum(1 for c in alnum if c in "0123456789abcdefABCDEF")
|
||||
if hex_chars / len(alnum) > 0.4 and len(alnum) > 6:
|
||||
return True
|
||||
# Mostly digits with a few alpha (like "article8efa6c53")
|
||||
@@ -169,13 +193,81 @@ def _is_gibberish(text):
|
||||
if len(t) < 5:
|
||||
return True
|
||||
# Query-param style segments
|
||||
if '=' in t:
|
||||
if "=" in t:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Persistent cache for article titles — survives across GDELT cache refreshes
|
||||
_article_title_cache = {}
|
||||
# Bounded to 5000 entries with 24hr TTL to prevent unbounded memory growth
|
||||
_article_title_cache = TTLCache(maxsize=5000, ttl=86400)
|
||||
_article_url_safety_cache = TTLCache(maxsize=5000, ttl=3600)
|
||||
_TITLE_FETCH_MAX_REDIRECTS = 3
|
||||
_TITLE_FETCH_READ_BYTES = 32768
|
||||
_ALLOWED_ARTICLE_PORTS = {80, 443, 8080, 8443}
|
||||
|
||||
|
||||
def _hostname_resolves_public(hostname: str, port: int) -> bool:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
||||
except (socket.gaierror, OSError):
|
||||
return False
|
||||
|
||||
addresses = set()
|
||||
for info in infos:
|
||||
sockaddr = info[4] if len(info) > 4 else None
|
||||
if not sockaddr:
|
||||
continue
|
||||
raw_addr = str(sockaddr[0] or "").split("%", 1)[0]
|
||||
if not raw_addr:
|
||||
continue
|
||||
try:
|
||||
addresses.add(ipaddress.ip_address(raw_addr))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return bool(addresses) and all(addr.is_global for addr in addresses)
|
||||
|
||||
|
||||
def _is_safe_public_article_url(url: str) -> tuple[bool, str]:
|
||||
cached = _article_url_safety_cache.get(url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
parsed = urlparse(str(url or "").strip())
|
||||
except ValueError:
|
||||
result = (False, "parse_error")
|
||||
_article_url_safety_cache[url] = result
|
||||
return result
|
||||
|
||||
scheme = str(parsed.scheme or "").lower()
|
||||
host = str(parsed.hostname or "").strip().lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
result = (False, "scheme")
|
||||
elif not host:
|
||||
result = (False, "host")
|
||||
elif parsed.username or parsed.password:
|
||||
result = (False, "userinfo")
|
||||
elif host in {"localhost", "localhost.localdomain"}:
|
||||
result = (False, "localhost")
|
||||
else:
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
if port not in _ALLOWED_ARTICLE_PORTS:
|
||||
result = (False, "port")
|
||||
else:
|
||||
try:
|
||||
target_ip = ipaddress.ip_address(host.split("%", 1)[0])
|
||||
except ValueError:
|
||||
target_ip = None
|
||||
if target_ip is not None:
|
||||
result = (True, "") if target_ip.is_global else (False, "private_ip")
|
||||
else:
|
||||
result = (True, "") if _hostname_resolves_public(host, port) else (False, "private_dns")
|
||||
|
||||
_article_url_safety_cache[url] = result
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_article_title(url):
|
||||
"""Fetch the real headline from an article's HTML <title> or og:title tag.
|
||||
@@ -183,51 +275,85 @@ def _fetch_article_title(url):
|
||||
Uses a persistent cache to avoid refetching."""
|
||||
if url in _article_title_cache:
|
||||
return _article_title_cache[url]
|
||||
|
||||
|
||||
import re
|
||||
|
||||
try:
|
||||
# Only read the first 32KB — the <title> is always in <head>
|
||||
resp = requests.get(url, timeout=4, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; OSINT Dashboard/1.0)'
|
||||
}, stream=True)
|
||||
if resp.status_code != 200:
|
||||
current_url = str(url or "").strip()
|
||||
chunk = ""
|
||||
for _ in range(_TITLE_FETCH_MAX_REDIRECTS + 1):
|
||||
allowed, _reason = _is_safe_public_article_url(current_url)
|
||||
if not allowed:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
resp = requests.get(
|
||||
current_url,
|
||||
timeout=4,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; OSINT Dashboard/1.0)"},
|
||||
stream=True,
|
||||
allow_redirects=False,
|
||||
)
|
||||
try:
|
||||
location = str(resp.headers.get("Location") or "").strip()
|
||||
if 300 <= resp.status_code < 400 and location:
|
||||
current_url = urljoin(current_url, location)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
chunk = resp.raw.read(_TITLE_FETCH_READ_BYTES).decode("utf-8", errors="replace")
|
||||
break
|
||||
finally:
|
||||
resp.close()
|
||||
else:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
chunk = resp.raw.read(32768).decode('utf-8', errors='replace')
|
||||
resp.close()
|
||||
|
||||
|
||||
title = None
|
||||
|
||||
|
||||
# Try og:title first (usually the cleanest)
|
||||
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I)
|
||||
og_match = re.search(
|
||||
r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I
|
||||
)
|
||||
if not og_match:
|
||||
og_match = re.search(r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']', chunk, re.I)
|
||||
og_match = re.search(
|
||||
r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']',
|
||||
chunk,
|
||||
re.I,
|
||||
)
|
||||
if og_match:
|
||||
title = og_match.group(1).strip()
|
||||
|
||||
|
||||
# Fall back to <title> tag
|
||||
if not title:
|
||||
title_match = re.search(r'<title[^>]*>([^<]+)</title>', chunk, re.I)
|
||||
title_match = re.search(r"<title[^>]*>([^<]+)</title>", chunk, re.I)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
|
||||
|
||||
if title:
|
||||
# Clean up HTML entities
|
||||
import html as html_mod
|
||||
|
||||
title = html_mod.unescape(title)
|
||||
# Remove site name suffixes like " | CNN" or " - BBC News"
|
||||
title = re.sub(r'\s*[|\-–—]\s*[^|\-–—]{2,30}$', '', title).strip()
|
||||
title = re.sub(r"\s*[|\-–—]\s*[^|\-–—]{2,30}$", "", title).strip()
|
||||
# Truncate very long titles
|
||||
if len(title) > 120:
|
||||
title = title[:117] + '...'
|
||||
title = title[:117] + "..."
|
||||
if len(title) > 10:
|
||||
_article_title_cache[url] = title
|
||||
return title
|
||||
|
||||
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, AttributeError): # non-critical
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
AttributeError,
|
||||
): # non-critical
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
@@ -236,6 +362,7 @@ def _batch_fetch_titles(urls):
|
||||
"""Fetch real article titles for a list of URLs in parallel.
|
||||
Returns a dict of url -> title (or None if fetch failed)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
||||
futures = {executor.submit(_fetch_article_title, u): u for u in urls}
|
||||
@@ -253,16 +380,19 @@ def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_
|
||||
loc_index maps loc_key -> index in features list for fast duplicate merging.
|
||||
"""
|
||||
import csv, io, zipfile
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
||||
csv_name = zf.namelist()[0]
|
||||
with zf.open(csv_name) as cf:
|
||||
reader = csv.reader(io.TextIOWrapper(cf, encoding='utf-8', errors='replace'), delimiter='\t')
|
||||
reader = csv.reader(
|
||||
io.TextIOWrapper(cf, encoding="utf-8", errors="replace"), delimiter="\t"
|
||||
)
|
||||
for row in reader:
|
||||
try:
|
||||
if len(row) < 61:
|
||||
continue
|
||||
event_code = row[26][:2] if len(row[26]) >= 2 else ''
|
||||
event_code = row[26][:2] if len(row[26]) >= 2 else ""
|
||||
if event_code not in conflict_codes:
|
||||
continue
|
||||
lat = float(row[56]) if row[56] else None
|
||||
@@ -270,10 +400,10 @@ def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_
|
||||
if lat is None or lng is None or (lat == 0 and lng == 0):
|
||||
continue
|
||||
|
||||
source_url = row[60].strip() if len(row) > 60 else ''
|
||||
location = row[52].strip() if len(row) > 52 else 'Unknown'
|
||||
actor1 = row[6].strip() if len(row) > 6 else ''
|
||||
actor2 = row[16].strip() if len(row) > 16 else ''
|
||||
source_url = row[60].strip() if len(row) > 60 else ""
|
||||
location = row[52].strip() if len(row) > 52 else "Unknown"
|
||||
actor1 = row[6].strip() if len(row) > 6 else ""
|
||||
actor2 = row[16].strip() if len(row) > 16 else ""
|
||||
|
||||
loc_key = f"{round(lat, 1)}_{round(lng, 1)}"
|
||||
if loc_key in seen_locs:
|
||||
@@ -293,25 +423,32 @@ def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_
|
||||
continue
|
||||
seen_locs.add(loc_key)
|
||||
|
||||
name = location or (f"{actor1} vs {actor2}" if actor1 and actor2 else actor1) or "Unknown Incident"
|
||||
domain = _extract_domain(source_url) if source_url else ''
|
||||
name = (
|
||||
location
|
||||
or (f"{actor1} vs {actor2}" if actor1 and actor2 else actor1)
|
||||
or "Unknown Incident"
|
||||
)
|
||||
domain = _extract_domain(source_url) if source_url else ""
|
||||
loc_index[loc_key] = len(features)
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": name,
|
||||
"count": 1,
|
||||
"_urls": [source_url] if source_url else [],
|
||||
"_domains": {domain} if domain else set(),
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [lng, lat]},
|
||||
"_loc_key": loc_key
|
||||
})
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": name,
|
||||
"count": 1,
|
||||
"_urls": [source_url] if source_url else [],
|
||||
"_domains": {domain} if domain else set(),
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [lng, lat]},
|
||||
"_loc_key": loc_key,
|
||||
}
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
except (IOError, OSError, ValueError, KeyError, zipfile.BadZipFile) as e:
|
||||
logger.warning(f"Failed to parse GDELT export zip: {e}")
|
||||
|
||||
|
||||
def _download_gdelt_export(url):
|
||||
"""Download a single GDELT export file, return bytes or None."""
|
||||
try:
|
||||
@@ -322,10 +459,12 @@ def _download_gdelt_export(url):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_feature_html(features, fetched_titles=None):
|
||||
"""Build URL + headline arrays for frontend rendering.
|
||||
Uses fetched_titles (real article titles) when available, falls back to URL slug parsing."""
|
||||
import html as html_mod
|
||||
|
||||
for f in features:
|
||||
urls = f["properties"].pop("_urls", [])
|
||||
f["properties"].pop("_domains", None)
|
||||
@@ -338,10 +477,12 @@ def _build_feature_html(features, fetched_titles=None):
|
||||
if urls:
|
||||
links = []
|
||||
for u, h in zip(urls, headlines):
|
||||
safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank'
|
||||
safe_url = u if u.startswith(("http://", "https://")) else "about:blank"
|
||||
safe_h = html_mod.escape(h)
|
||||
links.append(f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>')
|
||||
f["properties"]["html"] = ''.join(links)
|
||||
links.append(
|
||||
f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>'
|
||||
)
|
||||
f["properties"]["html"] = "".join(links)
|
||||
else:
|
||||
f["properties"]["html"] = html_mod.escape(f["properties"]["name"])
|
||||
f.pop("_loc_key", None)
|
||||
@@ -350,6 +491,7 @@ def _build_feature_html(features, fetched_titles=None):
|
||||
def _enrich_gdelt_titles_background(features, all_article_urls):
|
||||
"""Background thread: fetch real article titles then update features in-place."""
|
||||
import html as html_mod
|
||||
|
||||
try:
|
||||
logger.info(f"[BG] Fetching real article titles for {len(all_article_urls)} URLs...")
|
||||
fetched_titles = _batch_fetch_titles(all_article_urls)
|
||||
@@ -368,10 +510,12 @@ def _enrich_gdelt_titles_background(features, all_article_urls):
|
||||
f["properties"]["_headlines_list"] = headlines
|
||||
links = []
|
||||
for u, h in zip(urls, headlines):
|
||||
safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank'
|
||||
safe_url = u if u.startswith(("http://", "https://")) else "about:blank"
|
||||
safe_h = html_mod.escape(h)
|
||||
links.append(f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>')
|
||||
f["properties"]["html"] = ''.join(links)
|
||||
links.append(
|
||||
f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>'
|
||||
)
|
||||
f["properties"]["html"] = "".join(links)
|
||||
logger.info(f"[BG] GDELT title enrichment complete")
|
||||
except Exception as e:
|
||||
logger.error(f"[BG] GDELT title enrichment failed: {e}")
|
||||
@@ -391,16 +535,18 @@ def fetch_global_military_incidents():
|
||||
logger.info("Fetching GDELT events via export CDN (multi-file)...")
|
||||
|
||||
# Get the latest export URL to determine current timestamp
|
||||
index_res = fetch_with_curl("http://data.gdeltproject.org/gdeltv2/lastupdate.txt", timeout=10)
|
||||
index_res = fetch_with_curl(
|
||||
"http://data.gdeltproject.org/gdeltv2/lastupdate.txt", timeout=10
|
||||
)
|
||||
if index_res.status_code != 200:
|
||||
logger.error(f"GDELT lastupdate failed: {index_res.status_code}")
|
||||
return []
|
||||
|
||||
# Extract latest export URL and its timestamp
|
||||
latest_url = None
|
||||
for line in index_res.text.strip().split('\n'):
|
||||
for line in index_res.text.strip().split("\n"):
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 3 and parts[2].endswith('.export.CSV.zip'):
|
||||
if len(parts) >= 3 and parts[2].endswith(".export.CSV.zip"):
|
||||
latest_url = parts[2]
|
||||
break
|
||||
|
||||
@@ -410,19 +556,20 @@ def fetch_global_military_incidents():
|
||||
|
||||
# Extract timestamp from URL like: http://data.gdeltproject.org/gdeltv2/20260301120000.export.CSV.zip
|
||||
import re
|
||||
ts_match = re.search(r'(\d{14})\.export\.CSV\.zip', latest_url)
|
||||
|
||||
ts_match = re.search(r"(\d{14})\.export\.CSV\.zip", latest_url)
|
||||
if not ts_match:
|
||||
logger.error("Could not parse GDELT export timestamp")
|
||||
return []
|
||||
|
||||
latest_ts = datetime.strptime(ts_match.group(1), '%Y%m%d%H%M%S')
|
||||
latest_ts = datetime.strptime(ts_match.group(1), "%Y%m%d%H%M%S")
|
||||
|
||||
# Generate URLs for the last 8 hours (32 files at 15-min intervals)
|
||||
NUM_FILES = 32
|
||||
urls = []
|
||||
for i in range(NUM_FILES):
|
||||
ts = latest_ts - timedelta(minutes=15 * i)
|
||||
fname = ts.strftime('%Y%m%d%H%M%S') + '.export.CSV.zip'
|
||||
fname = ts.strftime("%Y%m%d%H%M%S") + ".export.CSV.zip"
|
||||
url = f"http://data.gdeltproject.org/gdeltv2/{fname}"
|
||||
urls.append(url)
|
||||
|
||||
@@ -436,7 +583,7 @@ def fetch_global_military_incidents():
|
||||
logger.info(f"Downloaded {successful}/{len(urls)} GDELT exports")
|
||||
|
||||
# Parse all downloaded files
|
||||
CONFLICT_CODES = {'14', '17', '18', '19', '20'}
|
||||
CONFLICT_CODES = {"14", "17", "18", "19", "20"}
|
||||
features = []
|
||||
seen_locs = set()
|
||||
loc_index = {} # loc_key -> index in features
|
||||
@@ -455,7 +602,9 @@ def fetch_global_military_incidents():
|
||||
# Build HTML immediately with URL-slug headlines (instant, no network)
|
||||
_build_feature_html(features)
|
||||
|
||||
logger.info(f"GDELT parsed: {len(features)} conflict locations from {successful} files (titles enriching in background)")
|
||||
logger.info(
|
||||
f"GDELT parsed: {len(features)} conflict locations from {successful} files (titles enriching in background)"
|
||||
)
|
||||
|
||||
# Kick off background thread to enrich with real article titles
|
||||
# Features list is shared — background thread updates in-place
|
||||
@@ -468,6 +617,13 @@ def fetch_global_military_incidents():
|
||||
|
||||
return features
|
||||
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
except (
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.error(f"Error fetching GDELT data: {e}")
|
||||
return []
|
||||
|
||||
@@ -16,13 +16,13 @@ kiwisdr_cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache
|
||||
|
||||
def _parse_comment(html: str, field: str) -> str:
|
||||
"""Extract a field value from HTML comment like <!-- field=value -->"""
|
||||
m = re.search(rf'<!--\s*{field}=(.*?)\s*-->', html)
|
||||
m = re.search(rf"<!--\s*{field}=(.*?)\s*-->", html)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _parse_gps(html: str):
|
||||
"""Extract lat/lon from <!-- gps=(lat, lon) --> comment."""
|
||||
m = re.search(r'<!--\s*gps=\(([^,]+),\s*([^)]+)\)\s*-->', html)
|
||||
m = re.search(r"<!--\s*gps=\(([^,]+),\s*([^)]+)\)\s*-->", html)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
@@ -78,17 +78,19 @@ def fetch_kiwisdr_nodes() -> list[dict]:
|
||||
except ValueError:
|
||||
users_max = 0
|
||||
|
||||
nodes.append({
|
||||
"name": name[:120], # Truncate long names
|
||||
"lat": round(lat, 5),
|
||||
"lon": round(lon, 5),
|
||||
"url": url,
|
||||
"users": users,
|
||||
"users_max": users_max,
|
||||
"bands": bands,
|
||||
"antenna": antenna[:200] if antenna else "",
|
||||
"location": location[:100] if location else "",
|
||||
})
|
||||
nodes.append(
|
||||
{
|
||||
"name": name[:120], # Truncate long names
|
||||
"lat": round(lat, 5),
|
||||
"lon": round(lon, 5),
|
||||
"url": url,
|
||||
"users": users,
|
||||
"users_max": users_max,
|
||||
"bands": bands,
|
||||
"antenna": antenna[:200] if antenna else "",
|
||||
"location": location[:100] if location else "",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"KiwiSDR: parsed {len(nodes)} online receivers")
|
||||
return nodes
|
||||
|
||||
@@ -8,90 +8,101 @@ from playwright_stealth import stealth_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_liveuamap():
|
||||
logger.info("Starting Liveuamap scraper with Playwright Stealth...")
|
||||
|
||||
|
||||
regions = [
|
||||
{"name": "Ukraine", "url": "https://liveuamap.com"},
|
||||
{"name": "Middle East", "url": "https://mideast.liveuamap.com"},
|
||||
{"name": "Israel-Palestine", "url": "https://israelpalestine.liveuamap.com"},
|
||||
{"name": "Syria", "url": "https://syria.liveuamap.com"}
|
||||
{"name": "Syria", "url": "https://syria.liveuamap.com"},
|
||||
]
|
||||
|
||||
|
||||
all_markers = []
|
||||
seen_ids = set()
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Launching with a real user agent to bypass Turnstile
|
||||
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
|
||||
browser = p.chromium.launch(
|
||||
headless=True, args=["--disable-blink-features=AutomationControlled"]
|
||||
)
|
||||
context = browser.new_context(
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
color_scheme="dark"
|
||||
color_scheme="dark",
|
||||
)
|
||||
page = context.new_page()
|
||||
stealth_sync(page)
|
||||
|
||||
|
||||
for region in regions:
|
||||
try:
|
||||
logger.info(f"Scraping Liveuamap region: {region['name']}")
|
||||
page.goto(region["url"], timeout=60000, wait_until="domcontentloaded")
|
||||
|
||||
|
||||
# Wait for the map canvas or markers script to load, max 10s wait
|
||||
try:
|
||||
page.wait_for_timeout(5000)
|
||||
except (TimeoutError, OSError): # non-critical: page load delay
|
||||
pass
|
||||
|
||||
|
||||
html = page.content()
|
||||
|
||||
|
||||
m = re.search(r"var\s+ovens\s*=\s*(.*?);(?!function)", html, re.DOTALL)
|
||||
if not m:
|
||||
logger.warning(f"Could not find 'ovens' data for {region['name']} in raw HTML")
|
||||
# Let's try grabbing the evaluated JavaScript variable if it's there
|
||||
try:
|
||||
ovens_json = page.evaluate("() => typeof ovens !== 'undefined' ? JSON.stringify(ovens) : null")
|
||||
ovens_json = page.evaluate(
|
||||
"() => typeof ovens !== 'undefined' ? JSON.stringify(ovens) : null"
|
||||
)
|
||||
if ovens_json:
|
||||
markers = json.loads(ovens_json)
|
||||
# process below
|
||||
html = f"var ovens={ovens_json};"
|
||||
m = re.search(r"var\s+ovens=(.*?);", html, re.DOTALL)
|
||||
except (ValueError, KeyError, OSError) as e: # non-critical: JS eval fallback
|
||||
logger.debug(f"Could not evaluate ovens JS variable for {region['name']}: {e}")
|
||||
|
||||
logger.debug(
|
||||
f"Could not evaluate ovens JS variable for {region['name']}: {e}"
|
||||
)
|
||||
|
||||
if m:
|
||||
json_str = m.group(1).strip()
|
||||
if json_str.startswith("'") or json_str.startswith('"'):
|
||||
json_str = json_str.strip('"\'')
|
||||
json_str = base64.b64decode(urllib.parse.unquote(json_str)).decode('utf-8')
|
||||
|
||||
json_str = json_str.strip("\"'")
|
||||
json_str = base64.b64decode(urllib.parse.unquote(json_str)).decode("utf-8")
|
||||
|
||||
try:
|
||||
markers = json.loads(json_str)
|
||||
for marker in markers:
|
||||
mid = marker.get("id")
|
||||
if mid and mid not in seen_ids:
|
||||
seen_ids.add(mid)
|
||||
all_markers.append({
|
||||
"id": mid,
|
||||
"type": "liveuamap",
|
||||
"title": marker.get("s", "Unknown Event") or marker.get("title", ""),
|
||||
"lat": marker.get("lat"),
|
||||
"lng": marker.get("lng"),
|
||||
"timestamp": marker.get("time", ""),
|
||||
"link": marker.get("link", region["url"]),
|
||||
"region": region["name"]
|
||||
})
|
||||
all_markers.append(
|
||||
{
|
||||
"id": mid,
|
||||
"type": "liveuamap",
|
||||
"title": marker.get("s", "Unknown Event")
|
||||
or marker.get("title", ""),
|
||||
"lat": marker.get("lat"),
|
||||
"lng": marker.get("lng"),
|
||||
"timestamp": marker.get("time", ""),
|
||||
"link": marker.get("link", region["url"]),
|
||||
"region": region["name"],
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
||||
logger.error(f"Error parsing JSON for {region['name']}: {e}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scraping Liveuamap {region['name']}: {e}")
|
||||
|
||||
|
||||
browser.close()
|
||||
|
||||
|
||||
logger.info(f"Liveuamap scraper finished, extracted {len(all_markers)} unique markers.")
|
||||
return all_markers
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
res = fetch_liveuamap()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Structured logging setup for backend services."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: Dict[str, Any] = {
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO"):
|
||||
"""Configure root logger with JSON formatting."""
|
||||
root = logging.getLogger()
|
||||
if root.handlers:
|
||||
return # Respect existing config
|
||||
root.setLevel(level.upper())
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root.addHandler(handler)
|
||||
@@ -0,0 +1 @@
|
||||
# Mesh protocol services package
|
||||
@@ -0,0 +1,340 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_crypto import canonical_json, normalize_peer_url
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_BOOTSTRAP_MANIFEST_PATH = DATA_DIR / "bootstrap_peers.json"
|
||||
BOOTSTRAP_MANIFEST_VERSION = 1
|
||||
ALLOWED_BOOTSTRAP_TRANSPORTS = {"clearnet", "onion"}
|
||||
ALLOWED_BOOTSTRAP_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
class BootstrapManifestError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapPeer:
|
||||
peer_url: str
|
||||
transport: str
|
||||
role: str
|
||||
label: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapManifest:
|
||||
version: int
|
||||
issued_at: int
|
||||
valid_until: int
|
||||
signer_id: str
|
||||
peers: tuple[BootstrapPeer, ...]
|
||||
signature: str
|
||||
|
||||
def payload_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": int(self.version),
|
||||
"issued_at": int(self.issued_at),
|
||||
"valid_until": int(self.valid_until),
|
||||
"signer_id": str(self.signer_id or ""),
|
||||
"peers": [peer.to_dict() for peer in self.peers],
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payload = self.payload_dict()
|
||||
payload["signature"] = str(self.signature or "")
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_manifest_path(raw_path: str) -> Path:
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_BOOTSTRAP_MANIFEST_PATH
|
||||
candidate = Path(raw)
|
||||
if candidate.is_absolute():
|
||||
return candidate
|
||||
return BACKEND_DIR / candidate
|
||||
|
||||
|
||||
def _canonical_manifest_payload(payload: dict[str, Any]) -> str:
|
||||
return canonical_json(payload)
|
||||
|
||||
|
||||
def _load_signer_private_key(private_key_b64: str) -> ed25519.Ed25519PrivateKey:
|
||||
try:
|
||||
signer_private_key = base64.b64decode(
|
||||
str(private_key_b64 or "").encode("utf-8"),
|
||||
validate=True,
|
||||
)
|
||||
return ed25519.Ed25519PrivateKey.from_private_bytes(signer_private_key)
|
||||
except Exception as exc:
|
||||
raise BootstrapManifestError("bootstrap signer private key must be raw Ed25519 base64") from exc
|
||||
|
||||
|
||||
def bootstrap_signer_public_key_b64(private_key_b64: str) -> str:
|
||||
signer = _load_signer_private_key(private_key_b64)
|
||||
public_key = signer.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
return base64.b64encode(public_key).decode("utf-8")
|
||||
|
||||
|
||||
def generate_bootstrap_signer() -> dict[str, str]:
|
||||
signer = ed25519.Ed25519PrivateKey.generate()
|
||||
private_key = signer.private_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PrivateFormat.Raw,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
public_key = signer.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
return {
|
||||
"private_key_b64": base64.b64encode(private_key).decode("utf-8"),
|
||||
"public_key_b64": base64.b64encode(public_key).decode("utf-8"),
|
||||
}
|
||||
|
||||
|
||||
def _verify_manifest_signature(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
signature_b64: str,
|
||||
signer_public_key_b64: str,
|
||||
) -> None:
|
||||
try:
|
||||
signature = base64.b64decode(str(signature_b64 or "").encode("utf-8"), validate=True)
|
||||
except Exception as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest signature must be base64") from exc
|
||||
|
||||
try:
|
||||
signer_public_key = base64.b64decode(
|
||||
str(signer_public_key_b64 or "").encode("utf-8"),
|
||||
validate=True,
|
||||
)
|
||||
verifier = ed25519.Ed25519PublicKey.from_public_bytes(signer_public_key)
|
||||
except Exception as exc:
|
||||
raise BootstrapManifestError("bootstrap signer public key must be raw Ed25519 base64") from exc
|
||||
|
||||
serialized = _canonical_manifest_payload(payload).encode("utf-8")
|
||||
try:
|
||||
verifier.verify(signature, serialized)
|
||||
except InvalidSignature as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest signature invalid") from exc
|
||||
|
||||
|
||||
def _validate_bootstrap_peer(peer_data: dict[str, Any]) -> BootstrapPeer:
|
||||
peer_url = str(peer_data.get("peer_url", "") or "").strip()
|
||||
transport = str(peer_data.get("transport", "") or "").strip().lower()
|
||||
role = str(peer_data.get("role", "") or "").strip().lower()
|
||||
label = str(peer_data.get("label", "") or "").strip()
|
||||
|
||||
if transport not in ALLOWED_BOOTSTRAP_TRANSPORTS:
|
||||
raise BootstrapManifestError(f"unsupported bootstrap transport: {transport or 'missing'}")
|
||||
if role not in ALLOWED_BOOTSTRAP_ROLES:
|
||||
raise BootstrapManifestError(f"unsupported bootstrap role: {role or 'missing'}")
|
||||
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized or normalized != peer_url:
|
||||
raise BootstrapManifestError("bootstrap peer_url must be normalized")
|
||||
|
||||
parsed = urlparse(normalized)
|
||||
hostname = str(parsed.hostname or "").strip().lower()
|
||||
if transport == "clearnet":
|
||||
if parsed.scheme != "https" or hostname.endswith(".onion"):
|
||||
raise BootstrapManifestError("clearnet bootstrap peers must use https://")
|
||||
elif transport == "onion":
|
||||
if parsed.scheme != "http" or not hostname.endswith(".onion"):
|
||||
raise BootstrapManifestError("onion bootstrap peers must use http://*.onion")
|
||||
|
||||
return BootstrapPeer(
|
||||
peer_url=normalized,
|
||||
transport=transport,
|
||||
role=role,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
def _validate_bootstrap_manifest_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
version = int(payload.get("version", 0) or 0)
|
||||
issued_at = int(payload.get("issued_at", 0) or 0)
|
||||
valid_until = int(payload.get("valid_until", 0) or 0)
|
||||
signer_id = str(payload.get("signer_id", "") or "").strip()
|
||||
peers_raw = payload.get("peers", [])
|
||||
current_time = int(now if now is not None else time.time())
|
||||
|
||||
if version != BOOTSTRAP_MANIFEST_VERSION:
|
||||
raise BootstrapManifestError(f"unsupported bootstrap manifest version: {version}")
|
||||
if not signer_id:
|
||||
raise BootstrapManifestError("bootstrap manifest signer_id is required")
|
||||
if issued_at <= 0 or valid_until <= 0 or valid_until <= issued_at:
|
||||
raise BootstrapManifestError("bootstrap manifest validity window is invalid")
|
||||
if current_time > valid_until:
|
||||
raise BootstrapManifestError("bootstrap manifest expired")
|
||||
if not isinstance(peers_raw, list):
|
||||
raise BootstrapManifestError("bootstrap manifest peers must be a list")
|
||||
|
||||
peers: list[BootstrapPeer] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for entry in peers_raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest peers must be objects")
|
||||
peer = _validate_bootstrap_peer(entry)
|
||||
key = (peer.transport, peer.peer_url)
|
||||
if key in seen:
|
||||
raise BootstrapManifestError("bootstrap manifest peers must be unique")
|
||||
seen.add(key)
|
||||
peers.append(peer)
|
||||
|
||||
if not peers:
|
||||
raise BootstrapManifestError("bootstrap manifest must contain at least one peer")
|
||||
|
||||
return BootstrapManifest(
|
||||
version=version,
|
||||
issued_at=issued_at,
|
||||
valid_until=valid_until,
|
||||
signer_id=signer_id,
|
||||
peers=tuple(peers),
|
||||
signature="",
|
||||
)
|
||||
|
||||
|
||||
def build_bootstrap_manifest_payload(
|
||||
*,
|
||||
signer_id: str,
|
||||
peers: list[dict[str, Any]] | tuple[dict[str, Any], ...],
|
||||
issued_at: int | None = None,
|
||||
valid_until: int | None = None,
|
||||
valid_for_hours: int = 168,
|
||||
) -> dict[str, Any]:
|
||||
timestamp = int(issued_at if issued_at is not None else time.time())
|
||||
expiry = int(valid_until if valid_until is not None else timestamp + max(1, int(valid_for_hours or 0)) * 3600)
|
||||
payload = {
|
||||
"version": BOOTSTRAP_MANIFEST_VERSION,
|
||||
"issued_at": timestamp,
|
||||
"valid_until": expiry,
|
||||
"signer_id": str(signer_id or "").strip(),
|
||||
"peers": list(peers),
|
||||
}
|
||||
manifest = _validate_bootstrap_manifest_payload(payload, now=timestamp)
|
||||
return manifest.payload_dict()
|
||||
|
||||
|
||||
def sign_bootstrap_manifest_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
signer_private_key_b64: str,
|
||||
) -> str:
|
||||
signer = _load_signer_private_key(signer_private_key_b64)
|
||||
serialized = _canonical_manifest_payload(payload).encode("utf-8")
|
||||
signature = signer.sign(serialized)
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
|
||||
|
||||
def write_signed_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
*,
|
||||
signer_id: str,
|
||||
signer_private_key_b64: str,
|
||||
peers: list[dict[str, Any]] | tuple[dict[str, Any], ...],
|
||||
issued_at: int | None = None,
|
||||
valid_until: int | None = None,
|
||||
valid_for_hours: int = 168,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
payload = build_bootstrap_manifest_payload(
|
||||
signer_id=signer_id,
|
||||
peers=list(peers),
|
||||
issued_at=issued_at,
|
||||
valid_until=valid_until,
|
||||
valid_for_hours=valid_for_hours,
|
||||
)
|
||||
signature = sign_bootstrap_manifest_payload(
|
||||
payload,
|
||||
signer_private_key_b64=signer_private_key_b64,
|
||||
)
|
||||
manifest = BootstrapManifest(
|
||||
version=int(payload["version"]),
|
||||
issued_at=int(payload["issued_at"]),
|
||||
valid_until=int(payload["valid_until"]),
|
||||
signer_id=str(payload["signer_id"]),
|
||||
peers=tuple(_validate_bootstrap_peer(dict(peer)) for peer in payload["peers"]),
|
||||
signature=signature,
|
||||
)
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps(manifest.to_dict(), indent=2) + "\n", encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def load_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
*,
|
||||
signer_public_key_b64: str,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest root must be an object")
|
||||
|
||||
signature = str(raw.get("signature", "") or "").strip()
|
||||
payload = {key: value for key, value in raw.items() if key != "signature"}
|
||||
if not signature:
|
||||
raise BootstrapManifestError("bootstrap manifest signature is required")
|
||||
|
||||
_verify_manifest_signature(
|
||||
payload,
|
||||
signature_b64=signature,
|
||||
signer_public_key_b64=signer_public_key_b64,
|
||||
)
|
||||
manifest = _validate_bootstrap_manifest_payload(payload, now=now)
|
||||
return BootstrapManifest(
|
||||
version=manifest.version,
|
||||
issued_at=manifest.issued_at,
|
||||
valid_until=manifest.valid_until,
|
||||
signer_id=manifest.signer_id,
|
||||
peers=manifest.peers,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_manifest_from_settings(*, now: float | None = None) -> BootstrapManifest | None:
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_BOOTSTRAP_DISABLED", False)):
|
||||
return None
|
||||
signer_public_key_b64 = str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip()
|
||||
if not signer_public_key_b64:
|
||||
return None
|
||||
manifest_path = _resolve_manifest_path(str(getattr(settings, "MESH_BOOTSTRAP_MANIFEST_PATH", "") or ""))
|
||||
return load_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_public_key_b64=signer_public_key_b64,
|
||||
now=now,
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Cryptographic helpers for Mesh protocol verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION, NETWORK_ID, normalize_payload
|
||||
|
||||
NODE_ID_PREFIX = "!sb_"
|
||||
NODE_ID_HEX_LEN = 16
|
||||
|
||||
|
||||
def canonical_json(obj: dict[str, Any]) -> str:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def normalize_peer_url(peer_url: str) -> str:
|
||||
raw = str(peer_url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urlparse(raw)
|
||||
scheme = str(parsed.scheme or "").strip().lower()
|
||||
hostname = str(parsed.hostname or "").strip().lower()
|
||||
if not scheme or not hostname:
|
||||
return ""
|
||||
port = parsed.port
|
||||
default_port = 443 if scheme == "https" else 80 if scheme == "http" else None
|
||||
netloc = hostname
|
||||
if port and port != default_port:
|
||||
netloc = f"{hostname}:{port}"
|
||||
path = str(parsed.path or "").rstrip("/")
|
||||
return f"{scheme}://{netloc}{path}"
|
||||
|
||||
|
||||
def _derive_peer_key(shared_secret: str, peer_url: str) -> bytes:
|
||||
normalized_url = normalize_peer_url(peer_url)
|
||||
if not shared_secret or not normalized_url:
|
||||
return b""
|
||||
# HKDF-Extract per RFC 5869 §2.2: PRK = HMAC-Hash(salt, IKM).
|
||||
# Python's hmac.new(key=salt, msg=IKM) maps directly to that definition.
|
||||
prk = hmac.new(
|
||||
b"sb-peer-auth-v1",
|
||||
shared_secret.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return hmac.new(
|
||||
prk,
|
||||
normalized_url.encode("utf-8") + b"\x01",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _node_digest(public_key_b64: str) -> str:
|
||||
raw = base64.b64decode(public_key_b64)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def derive_node_id(public_key_b64: str, *, legacy: bool = False) -> str:
|
||||
digest = _node_digest(public_key_b64)
|
||||
length = NODE_ID_HEX_LEN
|
||||
return NODE_ID_PREFIX + digest[:length]
|
||||
|
||||
|
||||
def derive_node_id_candidates(public_key_b64: str) -> tuple[str, ...]:
|
||||
current = derive_node_id(public_key_b64, legacy=False)
|
||||
return (current,)
|
||||
|
||||
|
||||
def build_signature_payload(
|
||||
*,
|
||||
event_type: str,
|
||||
node_id: str,
|
||||
sequence: int,
|
||||
payload: dict[str, Any],
|
||||
) -> str:
|
||||
normalized = normalize_payload(event_type, payload)
|
||||
payload_json = canonical_json(normalized)
|
||||
return "|".join(
|
||||
[PROTOCOL_VERSION, NETWORK_ID, event_type, node_id, str(sequence), payload_json]
|
||||
)
|
||||
|
||||
|
||||
def parse_public_key_algo(value: str) -> str:
|
||||
val = (value or "").strip().upper()
|
||||
if val in ("ED25519", "EDDSA"):
|
||||
return "Ed25519"
|
||||
if val in ("ECDSA", "ECDSA_P256", "P-256", "P256"):
|
||||
return "ECDSA_P256"
|
||||
return ""
|
||||
|
||||
|
||||
def verify_signature(
|
||||
*,
|
||||
public_key_b64: str,
|
||||
public_key_algo: str,
|
||||
signature_hex: str,
|
||||
payload: str,
|
||||
) -> bool:
|
||||
try:
|
||||
sig_bytes = bytes.fromhex(signature_hex)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
pub_raw = base64.b64decode(public_key_b64)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
algo = parse_public_key_algo(public_key_algo)
|
||||
data = payload.encode("utf-8")
|
||||
|
||||
try:
|
||||
if algo == "Ed25519":
|
||||
pub = ed25519.Ed25519PublicKey.from_public_bytes(pub_raw)
|
||||
pub.verify(sig_bytes, data)
|
||||
return True
|
||||
if algo == "ECDSA_P256":
|
||||
pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), pub_raw)
|
||||
pub.verify(sig_bytes, data, ec.ECDSA(hashes.SHA256()))
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def verify_node_binding(node_id: str, public_key_b64: str) -> bool:
|
||||
try:
|
||||
return str(node_id or "") in derive_node_id_candidates(public_key_b64)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,669 @@
|
||||
"""MLS-backed DM session manager.
|
||||
|
||||
This module keeps DM session orchestration in Python while privacy-core owns
|
||||
the MLS session state. Python-side metadata survives via domain storage, but
|
||||
Rust session state remains in-memory only. Process restart still requires
|
||||
session re-establishment until Rust FFI state export is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from services.mesh.mesh_secure_storage import (
|
||||
read_domain_json,
|
||||
read_secure_json,
|
||||
write_domain_json,
|
||||
)
|
||||
from services.mesh.mesh_privacy_logging import privacy_log_label
|
||||
from services.mesh.mesh_wormhole_persona import sign_dm_alias_blob, verify_dm_alias_blob
|
||||
from services.privacy_core_client import PrivacyCoreClient, PrivacyCoreError
|
||||
from services.wormhole_supervisor import get_wormhole_state, transport_tier_from_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
STATE_FILE = DATA_DIR / "wormhole_dm_mls.json"
|
||||
STATE_FILENAME = "wormhole_dm_mls.json"
|
||||
STATE_DOMAIN = "dm_alias"
|
||||
_STATE_LOCK = threading.RLock()
|
||||
_PRIVACY_CLIENT: PrivacyCoreClient | None = None
|
||||
_STATE_LOADED = False
|
||||
_TRANSPORT_TIER_ORDER = {
|
||||
"public_degraded": 0,
|
||||
"private_transitional": 1,
|
||||
"private_strong": 2,
|
||||
}
|
||||
MLS_DM_FORMAT = "mls1"
|
||||
MAX_DM_PLAINTEXT_SIZE = 65_536
|
||||
|
||||
try:
|
||||
from nacl.public import PrivateKey as _NaclPrivateKey
|
||||
from nacl.public import PublicKey as _NaclPublicKey
|
||||
from nacl.public import SealedBox as _NaclSealedBox
|
||||
except ImportError:
|
||||
_NaclPrivateKey = None
|
||||
_NaclPublicKey = None
|
||||
_NaclSealedBox = None
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def _decode_key_text(data: str | bytes | None) -> bytes:
|
||||
raw = str(data or "").strip()
|
||||
if not raw:
|
||||
return b""
|
||||
try:
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError:
|
||||
return _unb64(raw)
|
||||
|
||||
|
||||
def _normalize_alias(alias: str) -> str:
|
||||
return str(alias or "").strip().lower()
|
||||
|
||||
|
||||
def _session_id(local_alias: str, remote_alias: str) -> str:
|
||||
return f"{_normalize_alias(local_alias)}::{_normalize_alias(remote_alias)}"
|
||||
|
||||
|
||||
def _seal_keypair() -> dict[str, str]:
|
||||
private_key = x25519.X25519PrivateKey.generate()
|
||||
return {
|
||||
"public_key": private_key.public_key().public_bytes_raw().hex(),
|
||||
"private_key": private_key.private_bytes_raw().hex(),
|
||||
}
|
||||
|
||||
|
||||
def _seal_welcome_for_public_key(payload: bytes, public_key_text: str) -> bytes:
|
||||
public_key_bytes = _decode_key_text(public_key_text)
|
||||
if not public_key_bytes:
|
||||
raise PrivacyCoreError("responder_dh_pub is required for sealed welcome")
|
||||
if _NaclPublicKey is not None and _NaclSealedBox is not None:
|
||||
return _NaclSealedBox(_NaclPublicKey(public_key_bytes)).encrypt(payload)
|
||||
|
||||
ephemeral_private = x25519.X25519PrivateKey.generate()
|
||||
ephemeral_public = ephemeral_private.public_key().public_bytes_raw()
|
||||
recipient_public = x25519.X25519PublicKey.from_public_bytes(public_key_bytes)
|
||||
shared_secret = ephemeral_private.exchange(recipient_public)
|
||||
key = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=None,
|
||||
info=b"shadowbroker|dm-mls-welcome|v1",
|
||||
).derive(shared_secret)
|
||||
nonce = secrets.token_bytes(12)
|
||||
ciphertext = AESGCM(key).encrypt(
|
||||
nonce,
|
||||
payload,
|
||||
b"shadowbroker|dm-mls-welcome|v1",
|
||||
)
|
||||
return ephemeral_public + nonce + ciphertext
|
||||
|
||||
|
||||
def _unseal_welcome_for_private_key(payload: bytes, private_key_text: str) -> bytes:
|
||||
private_key_bytes = _decode_key_text(private_key_text)
|
||||
if not private_key_bytes:
|
||||
raise PrivacyCoreError("local DH secret unavailable for DM session acceptance")
|
||||
if _NaclPrivateKey is not None and _NaclSealedBox is not None:
|
||||
return _NaclSealedBox(_NaclPrivateKey(private_key_bytes)).decrypt(payload)
|
||||
if len(payload) < 44:
|
||||
raise PrivacyCoreError("sealed DM welcome is truncated")
|
||||
ephemeral_public = x25519.X25519PublicKey.from_public_bytes(payload[:32])
|
||||
nonce = payload[32:44]
|
||||
ciphertext = payload[44:]
|
||||
private_key = x25519.X25519PrivateKey.from_private_bytes(private_key_bytes)
|
||||
shared_secret = private_key.exchange(ephemeral_public)
|
||||
key = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=None,
|
||||
info=b"shadowbroker|dm-mls-welcome|v1",
|
||||
).derive(shared_secret)
|
||||
try:
|
||||
return AESGCM(key).decrypt(
|
||||
nonce,
|
||||
ciphertext,
|
||||
b"shadowbroker|dm-mls-welcome|v1",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PrivacyCoreError("sealed DM welcome decrypt failed") from exc
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionBinding:
|
||||
session_id: str
|
||||
local_alias: str
|
||||
remote_alias: str
|
||||
role: str
|
||||
session_handle: int
|
||||
created_at: int
|
||||
|
||||
|
||||
_ALIAS_IDENTITIES: dict[str, int] = {}
|
||||
_ALIAS_BINDINGS: dict[str, dict[str, str]] = {}
|
||||
_ALIAS_SEAL_KEYS: dict[str, dict[str, str]] = {}
|
||||
_SESSIONS: dict[str, _SessionBinding] = {}
|
||||
_DM_FORMAT_LOCKS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _default_state() -> dict[str, Any]:
|
||||
return {
|
||||
"version": 2,
|
||||
"updated_at": 0,
|
||||
"aliases": {},
|
||||
"alias_seal_keys": {},
|
||||
"sessions": {},
|
||||
"dm_format_locks": {},
|
||||
}
|
||||
|
||||
|
||||
def _privacy_client() -> PrivacyCoreClient:
|
||||
global _PRIVACY_CLIENT
|
||||
if _PRIVACY_CLIENT is None:
|
||||
_PRIVACY_CLIENT = PrivacyCoreClient.load()
|
||||
return _PRIVACY_CLIENT
|
||||
|
||||
|
||||
def _current_transport_tier() -> str:
|
||||
return transport_tier_from_state(get_wormhole_state())
|
||||
|
||||
|
||||
def _require_private_transport() -> tuple[bool, str]:
|
||||
current = _current_transport_tier()
|
||||
if _TRANSPORT_TIER_ORDER.get(current, 0) < _TRANSPORT_TIER_ORDER["private_transitional"]:
|
||||
return False, "DM MLS requires PRIVATE transport tier"
|
||||
return True, current
|
||||
|
||||
|
||||
def _serialize_session(binding: _SessionBinding) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": binding.session_id,
|
||||
"local_alias": binding.local_alias,
|
||||
"remote_alias": binding.remote_alias,
|
||||
"role": binding.role,
|
||||
"session_handle": int(binding.session_handle),
|
||||
"created_at": int(binding.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _binding_record(handle: int, public_bundle: bytes, binding_proof: str) -> dict[str, Any]:
|
||||
return {
|
||||
"handle": int(handle),
|
||||
"public_bundle": _b64(public_bundle),
|
||||
"binding_proof": str(binding_proof or ""),
|
||||
}
|
||||
|
||||
|
||||
def _load_state() -> None:
|
||||
global _STATE_LOADED
|
||||
with _STATE_LOCK:
|
||||
if _STATE_LOADED:
|
||||
return
|
||||
# KNOWN LIMITATION: Persisted handles only survive when the privacy-core
|
||||
# library instance is still alive in the same process. Full Rust-state
|
||||
# export/import is deferred to a later sprint.
|
||||
domain_path = DATA_DIR / STATE_DOMAIN / STATE_FILENAME
|
||||
if not domain_path.exists() and STATE_FILE.exists():
|
||||
try:
|
||||
legacy = read_secure_json(STATE_FILE, _default_state)
|
||||
write_domain_json(STATE_DOMAIN, STATE_FILENAME, legacy)
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Legacy DM MLS state could not be decrypted — "
|
||||
"discarding stale file and starting fresh"
|
||||
)
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
raw = read_domain_json(STATE_DOMAIN, STATE_FILENAME, _default_state)
|
||||
state = _default_state()
|
||||
if isinstance(raw, dict):
|
||||
state.update(raw)
|
||||
|
||||
_ALIAS_IDENTITIES.clear()
|
||||
_ALIAS_BINDINGS.clear()
|
||||
for alias, payload in dict(state.get("aliases") or {}).items():
|
||||
alias_key = _normalize_alias(alias)
|
||||
if not alias_key:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
handle = int(payload.get("handle", 0) or 0)
|
||||
public_bundle_b64 = str(payload.get("public_bundle", "") or "")
|
||||
binding_proof = str(payload.get("binding_proof", "") or "")
|
||||
else:
|
||||
handle = int(payload or 0)
|
||||
public_bundle_b64 = ""
|
||||
binding_proof = ""
|
||||
if handle <= 0 or not public_bundle_b64 or not binding_proof:
|
||||
logger.warning("DM MLS alias binding missing proof; identity will be re-created")
|
||||
continue
|
||||
try:
|
||||
public_bundle = _unb64(public_bundle_b64)
|
||||
except Exception as exc:
|
||||
logger.warning("DM MLS alias binding decode failed: %s", type(exc).__name__)
|
||||
continue
|
||||
ok, reason = verify_dm_alias_blob(alias_key, public_bundle, binding_proof)
|
||||
if not ok:
|
||||
logger.warning("DM MLS alias binding invalid: %s", reason)
|
||||
continue
|
||||
_ALIAS_IDENTITIES[alias_key] = handle
|
||||
_ALIAS_BINDINGS[alias_key] = _binding_record(handle, public_bundle, binding_proof)
|
||||
|
||||
_ALIAS_SEAL_KEYS.clear()
|
||||
for alias, keypair in dict(state.get("alias_seal_keys") or {}).items():
|
||||
alias_key = _normalize_alias(alias)
|
||||
pair = dict(keypair or {})
|
||||
public_key = str(pair.get("public_key", "") or "").strip().lower()
|
||||
private_key = str(pair.get("private_key", "") or "").strip().lower()
|
||||
if alias_key and public_key and private_key:
|
||||
_ALIAS_SEAL_KEYS[alias_key] = {
|
||||
"public_key": public_key,
|
||||
"private_key": private_key,
|
||||
}
|
||||
|
||||
_SESSIONS.clear()
|
||||
for session_id, payload in dict(state.get("sessions") or {}).items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
binding = _SessionBinding(
|
||||
session_id=str(payload.get("session_id", session_id) or session_id),
|
||||
local_alias=_normalize_alias(str(payload.get("local_alias", "") or "")),
|
||||
remote_alias=_normalize_alias(str(payload.get("remote_alias", "") or "")),
|
||||
role=str(payload.get("role", "initiator") or "initiator"),
|
||||
session_handle=int(payload.get("session_handle", 0) or 0),
|
||||
created_at=int(payload.get("created_at", 0) or 0),
|
||||
)
|
||||
if (
|
||||
binding.session_id
|
||||
and binding.session_handle > 0
|
||||
and binding.local_alias in _ALIAS_IDENTITIES
|
||||
):
|
||||
_SESSIONS[binding.session_id] = binding
|
||||
|
||||
_DM_FORMAT_LOCKS.clear()
|
||||
for session_id, payload_format in dict(state.get("dm_format_locks") or {}).items():
|
||||
normalized = str(payload_format or "").strip().lower()
|
||||
if normalized:
|
||||
_DM_FORMAT_LOCKS[str(session_id or "")] = normalized
|
||||
_STATE_LOADED = True
|
||||
|
||||
|
||||
def _save_state() -> None:
|
||||
with _STATE_LOCK:
|
||||
write_domain_json(
|
||||
STATE_DOMAIN,
|
||||
STATE_FILENAME,
|
||||
{
|
||||
"version": 2,
|
||||
"updated_at": int(time.time()),
|
||||
"aliases": {
|
||||
alias: dict(_ALIAS_BINDINGS.get(alias) or {})
|
||||
for alias, handle in _ALIAS_IDENTITIES.items()
|
||||
if _ALIAS_BINDINGS.get(alias)
|
||||
},
|
||||
"alias_seal_keys": {
|
||||
alias: dict(keypair or {})
|
||||
for alias, keypair in _ALIAS_SEAL_KEYS.items()
|
||||
},
|
||||
"sessions": {
|
||||
session_id: _serialize_session(binding)
|
||||
for session_id, binding in _SESSIONS.items()
|
||||
},
|
||||
"dm_format_locks": dict(_DM_FORMAT_LOCKS),
|
||||
},
|
||||
)
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def reset_dm_mls_state(*, clear_privacy_core: bool = False, clear_persistence: bool = True) -> None:
|
||||
global _PRIVACY_CLIENT, _STATE_LOADED
|
||||
with _STATE_LOCK:
|
||||
if clear_privacy_core and _PRIVACY_CLIENT is not None:
|
||||
try:
|
||||
_PRIVACY_CLIENT.reset_all_state()
|
||||
except Exception:
|
||||
logger.exception("privacy-core reset failed while clearing DM MLS state")
|
||||
_ALIAS_IDENTITIES.clear()
|
||||
_ALIAS_BINDINGS.clear()
|
||||
_ALIAS_SEAL_KEYS.clear()
|
||||
_SESSIONS.clear()
|
||||
_DM_FORMAT_LOCKS.clear()
|
||||
_STATE_LOADED = False
|
||||
if clear_persistence and STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
|
||||
|
||||
def _identity_handle_for_alias(alias: str) -> int:
|
||||
alias_key = _normalize_alias(alias)
|
||||
if not alias_key:
|
||||
raise PrivacyCoreError("dm alias is required")
|
||||
_load_state()
|
||||
with _STATE_LOCK:
|
||||
handle = _ALIAS_IDENTITIES.get(alias_key)
|
||||
if handle:
|
||||
return handle
|
||||
handle = _privacy_client().create_identity()
|
||||
public_bundle = _privacy_client().export_public_bundle(handle)
|
||||
signed = sign_dm_alias_blob(alias_key, public_bundle)
|
||||
if not signed.get("ok"):
|
||||
try:
|
||||
_privacy_client().release_identity(handle)
|
||||
except Exception:
|
||||
pass
|
||||
raise PrivacyCoreError(str(signed.get("detail") or "dm_mls_identity_binding_failed"))
|
||||
_ALIAS_IDENTITIES[alias_key] = handle
|
||||
_ALIAS_BINDINGS[alias_key] = _binding_record(
|
||||
handle,
|
||||
public_bundle,
|
||||
str(signed.get("signature", "") or ""),
|
||||
)
|
||||
_save_state()
|
||||
return handle
|
||||
|
||||
|
||||
def _seal_keypair_for_alias(alias: str) -> dict[str, str]:
|
||||
alias_key = _normalize_alias(alias)
|
||||
if not alias_key:
|
||||
raise PrivacyCoreError("dm alias is required")
|
||||
_load_state()
|
||||
with _STATE_LOCK:
|
||||
existing = _ALIAS_SEAL_KEYS.get(alias_key)
|
||||
if existing and existing.get("public_key") and existing.get("private_key"):
|
||||
return dict(existing)
|
||||
created = _seal_keypair()
|
||||
_ALIAS_SEAL_KEYS[alias_key] = created
|
||||
_save_state()
|
||||
return dict(created)
|
||||
|
||||
|
||||
def export_dm_key_package_for_alias(alias: str) -> dict[str, Any]:
|
||||
alias_key = _normalize_alias(alias)
|
||||
if not alias_key:
|
||||
return {"ok": False, "detail": "alias is required"}
|
||||
try:
|
||||
identity_handle = _identity_handle_for_alias(alias_key)
|
||||
key_package = _privacy_client().export_key_package(identity_handle)
|
||||
seal_keypair = _seal_keypair_for_alias(alias_key)
|
||||
return {
|
||||
"ok": True,
|
||||
"alias": alias_key,
|
||||
"mls_key_package": _b64(key_package),
|
||||
"welcome_dh_pub": str(seal_keypair.get("public_key", "") or ""),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"dm mls key package export failed for %s",
|
||||
privacy_log_label(alias_key, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_key_package_failed"}
|
||||
|
||||
|
||||
def _remember_session(local_alias: str, remote_alias: str, *, role: str, session_handle: int) -> _SessionBinding:
|
||||
binding = _SessionBinding(
|
||||
session_id=_session_id(local_alias, remote_alias),
|
||||
local_alias=_normalize_alias(local_alias),
|
||||
remote_alias=_normalize_alias(remote_alias),
|
||||
role=str(role or "initiator"),
|
||||
session_handle=int(session_handle),
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
with _STATE_LOCK:
|
||||
existing = _SESSIONS.get(binding.session_id)
|
||||
if existing is not None:
|
||||
try:
|
||||
_privacy_client().release_dm_session(session_handle)
|
||||
except Exception:
|
||||
pass
|
||||
return existing
|
||||
_SESSIONS[binding.session_id] = binding
|
||||
_save_state()
|
||||
return binding
|
||||
|
||||
|
||||
def _forget_session(local_alias: str, remote_alias: str) -> _SessionBinding | None:
|
||||
_load_state()
|
||||
with _STATE_LOCK:
|
||||
binding = _SESSIONS.pop(_session_id(local_alias, remote_alias), None)
|
||||
_save_state()
|
||||
return binding
|
||||
|
||||
|
||||
def _lock_dm_format(local_alias: str, remote_alias: str, format_str: str) -> None:
|
||||
_load_state()
|
||||
with _STATE_LOCK:
|
||||
_DM_FORMAT_LOCKS[_session_id(local_alias, remote_alias)] = str(format_str or "").strip().lower()
|
||||
_save_state()
|
||||
|
||||
|
||||
def is_dm_locked_to_mls(local_alias: str, remote_alias: str) -> bool:
|
||||
_load_state()
|
||||
return (
|
||||
str(_DM_FORMAT_LOCKS.get(_session_id(local_alias, remote_alias), "") or "").strip().lower()
|
||||
== MLS_DM_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _session_binding(local_alias: str, remote_alias: str) -> _SessionBinding:
|
||||
_load_state()
|
||||
session_id = _session_id(local_alias, remote_alias)
|
||||
binding = _SESSIONS.get(session_id)
|
||||
if binding is None:
|
||||
raise PrivacyCoreError(f"dm session not found for {session_id}")
|
||||
return binding
|
||||
|
||||
|
||||
def initiate_dm_session(
|
||||
local_alias: str,
|
||||
remote_alias: str,
|
||||
remote_prekey_bundle: dict,
|
||||
responder_dh_pub: str = "",
|
||||
) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
local_key = _normalize_alias(local_alias)
|
||||
remote_key = _normalize_alias(remote_alias)
|
||||
remote_key_package_b64 = str(
|
||||
(remote_prekey_bundle or {}).get("mls_key_package")
|
||||
or (remote_prekey_bundle or {}).get("key_package")
|
||||
or ""
|
||||
).strip()
|
||||
if not local_key or not remote_key or not remote_key_package_b64:
|
||||
return {"ok": False, "detail": "local_alias, remote_alias, and mls_key_package are required"}
|
||||
resolved_responder_dh_pub = str(
|
||||
responder_dh_pub
|
||||
or (remote_prekey_bundle or {}).get("welcome_dh_pub")
|
||||
or (remote_prekey_bundle or {}).get("identity_dh_pub_key")
|
||||
or ""
|
||||
).strip()
|
||||
key_package_handle = 0
|
||||
session_handle = 0
|
||||
remembered = False
|
||||
try:
|
||||
identity_handle = _identity_handle_for_alias(local_key)
|
||||
key_package_handle = _privacy_client().import_key_package(_unb64(remote_key_package_b64))
|
||||
session_handle = _privacy_client().create_dm_session(identity_handle, key_package_handle)
|
||||
welcome = _privacy_client().dm_session_welcome(session_handle)
|
||||
sealed_welcome = _seal_welcome_for_public_key(welcome, resolved_responder_dh_pub)
|
||||
binding = _remember_session(local_key, remote_key, role="initiator", session_handle=session_handle)
|
||||
remembered = True
|
||||
return {"ok": True, "welcome": _b64(sealed_welcome), "session_id": binding.session_id}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"dm mls initiate failed for %s -> %s",
|
||||
privacy_log_label(local_key, label="alias"),
|
||||
privacy_log_label(remote_key, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_initiate_failed"}
|
||||
finally:
|
||||
if key_package_handle:
|
||||
try:
|
||||
_privacy_client().release_key_package(key_package_handle)
|
||||
except Exception:
|
||||
pass
|
||||
if session_handle and not remembered:
|
||||
try:
|
||||
_privacy_client().release_dm_session(session_handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def accept_dm_session(
|
||||
local_alias: str,
|
||||
remote_alias: str,
|
||||
welcome_b64: str,
|
||||
local_dh_secret: str = "",
|
||||
) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
local_key = _normalize_alias(local_alias)
|
||||
remote_key = _normalize_alias(remote_alias)
|
||||
if not local_key or not remote_key or not str(welcome_b64 or "").strip():
|
||||
return {"ok": False, "detail": "local_alias, remote_alias, and welcome are required"}
|
||||
session_handle = 0
|
||||
remembered = False
|
||||
try:
|
||||
identity_handle = _identity_handle_for_alias(local_key)
|
||||
seal_keypair = _seal_keypair_for_alias(local_key)
|
||||
welcome = _unseal_welcome_for_private_key(
|
||||
_unb64(welcome_b64),
|
||||
str(local_dh_secret or seal_keypair.get("private_key") or ""),
|
||||
)
|
||||
session_handle = _privacy_client().join_dm_session(identity_handle, welcome)
|
||||
binding = _remember_session(local_key, remote_key, role="responder", session_handle=session_handle)
|
||||
remembered = True
|
||||
return {"ok": True, "session_id": binding.session_id}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"dm mls accept failed for %s <- %s",
|
||||
privacy_log_label(local_key, label="alias"),
|
||||
privacy_log_label(remote_key, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_accept_failed"}
|
||||
finally:
|
||||
if session_handle and not remembered:
|
||||
try:
|
||||
_privacy_client().release_dm_session(session_handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def has_dm_session(local_alias: str, remote_alias: str) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
try:
|
||||
binding = _session_binding(local_alias, remote_alias)
|
||||
return {"ok": True, "exists": True, "session_id": binding.session_id}
|
||||
except Exception:
|
||||
return {"ok": True, "exists": False, "session_id": _session_id(local_alias, remote_alias)}
|
||||
|
||||
|
||||
def ensure_dm_session(local_alias: str, remote_alias: str, welcome_b64: str) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
has_session = has_dm_session(local_alias, remote_alias)
|
||||
if not has_session.get("ok"):
|
||||
return has_session
|
||||
if has_session.get("exists"):
|
||||
return {"ok": True, "session_id": _session_id(local_alias, remote_alias)}
|
||||
return accept_dm_session(local_alias, remote_alias, welcome_b64)
|
||||
|
||||
|
||||
def _session_expired_result(local_alias: str, remote_alias: str) -> dict[str, Any]:
|
||||
binding = _forget_session(local_alias, remote_alias)
|
||||
session_id = binding.session_id if binding is not None else _session_id(local_alias, remote_alias)
|
||||
return {"ok": False, "detail": "session_expired", "session_id": session_id}
|
||||
|
||||
|
||||
def encrypt_dm(local_alias: str, remote_alias: str, plaintext: str) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
plaintext_bytes = str(plaintext or "").encode("utf-8")
|
||||
if len(plaintext_bytes) > MAX_DM_PLAINTEXT_SIZE:
|
||||
return {"ok": False, "detail": "plaintext exceeds maximum size"}
|
||||
try:
|
||||
binding = _session_binding(local_alias, remote_alias)
|
||||
ciphertext = _privacy_client().dm_encrypt(binding.session_handle, plaintext_bytes)
|
||||
_lock_dm_format(local_alias, remote_alias, MLS_DM_FORMAT)
|
||||
return {
|
||||
"ok": True,
|
||||
"ciphertext": _b64(ciphertext),
|
||||
# NOTE: nonce is generated for DM envelope compatibility with dm1 format.
|
||||
# MLS handles its own nonce/IV internally — this field is not consumed by MLS.
|
||||
"nonce": _b64(secrets.token_bytes(12)),
|
||||
"session_id": binding.session_id,
|
||||
}
|
||||
except PrivacyCoreError as exc:
|
||||
if "unknown dm session handle" in str(exc).lower():
|
||||
return _session_expired_result(local_alias, remote_alias)
|
||||
logger.exception(
|
||||
"dm mls encrypt failed for %s -> %s",
|
||||
privacy_log_label(local_alias, label="alias"),
|
||||
privacy_log_label(remote_alias, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_encrypt_failed"}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"dm mls encrypt failed for %s -> %s",
|
||||
privacy_log_label(local_alias, label="alias"),
|
||||
privacy_log_label(remote_alias, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_encrypt_failed"}
|
||||
|
||||
|
||||
def decrypt_dm(local_alias: str, remote_alias: str, ciphertext_b64: str, nonce_b64: str) -> dict[str, Any]:
|
||||
ok, detail = _require_private_transport()
|
||||
if not ok:
|
||||
return {"ok": False, "detail": detail}
|
||||
try:
|
||||
binding = _session_binding(local_alias, remote_alias)
|
||||
plaintext = _privacy_client().dm_decrypt(binding.session_handle, _unb64(ciphertext_b64))
|
||||
_lock_dm_format(local_alias, remote_alias, MLS_DM_FORMAT)
|
||||
return {
|
||||
"ok": True,
|
||||
"plaintext": plaintext.decode("utf-8"),
|
||||
"session_id": binding.session_id,
|
||||
"nonce": str(nonce_b64 or ""),
|
||||
}
|
||||
except PrivacyCoreError as exc:
|
||||
if "unknown dm session handle" in str(exc).lower():
|
||||
return _session_expired_result(local_alias, remote_alias)
|
||||
logger.exception(
|
||||
"dm mls decrypt failed for %s <- %s",
|
||||
privacy_log_label(local_alias, label="alias"),
|
||||
privacy_log_label(remote_alias, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_decrypt_failed"}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"dm mls decrypt failed for %s <- %s",
|
||||
privacy_log_label(local_alias, label="alias"),
|
||||
privacy_log_label(remote_alias, label="alias"),
|
||||
)
|
||||
return {"ok": False, "detail": "dm_mls_decrypt_failed"}
|
||||
@@ -0,0 +1,824 @@
|
||||
"""Metadata-minimized DM relay for request and shared mailboxes.
|
||||
|
||||
This relay never decrypts application payloads. In secure mode it keeps
|
||||
pending ciphertext in memory only and persists just the minimum metadata
|
||||
needed for continuity: accepted DH bundles, block lists, witness data,
|
||||
and nonce replay windows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_metrics import increment as metrics_inc
|
||||
from services.mesh.mesh_wormhole_prekey import _validate_bundle_record
|
||||
from services.mesh.mesh_secure_storage import read_secure_json, write_secure_json
|
||||
|
||||
TTL_SECONDS = 3600
|
||||
EPOCH_SECONDS = 6 * 60 * 60
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
RELAY_FILE = DATA_DIR / "dm_relay.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_token_pepper() -> str:
|
||||
"""Read token pepper lazily so auto-generated values from startup audit take effect."""
|
||||
pepper = os.environ.get("MESH_DM_TOKEN_PEPPER", "").strip()
|
||||
if not pepper:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.env_check import _ensure_dm_token_pepper
|
||||
|
||||
pepper = _ensure_dm_token_pepper(get_settings())
|
||||
except Exception:
|
||||
pepper = os.environ.get("MESH_DM_TOKEN_PEPPER", "").strip()
|
||||
if not pepper:
|
||||
raise RuntimeError("MESH_DM_TOKEN_PEPPER is unavailable at runtime")
|
||||
return pepper
|
||||
|
||||
|
||||
@dataclass
|
||||
class DMMessage:
|
||||
sender_id: str
|
||||
ciphertext: str
|
||||
timestamp: float
|
||||
msg_id: str
|
||||
delivery_class: str
|
||||
sender_seal: str = ""
|
||||
relay_salt: str = ""
|
||||
sender_block_ref: str = ""
|
||||
payload_format: str = "dm1"
|
||||
session_welcome: str = ""
|
||||
|
||||
|
||||
class DMRelay:
|
||||
"""Relay for encrypted request/shared mailboxes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._mailboxes: dict[str, list[DMMessage]] = defaultdict(list)
|
||||
self._dh_keys: dict[str, dict[str, Any]] = {}
|
||||
self._prekey_bundles: dict[str, dict[str, Any]] = {}
|
||||
self._mailbox_bindings: dict[str, dict[str, Any]] = defaultdict(dict)
|
||||
self._witnesses: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
self._blocks: dict[str, set[str]] = defaultdict(set)
|
||||
self._nonce_cache: OrderedDict[str, float] = OrderedDict()
|
||||
self._stats: dict[str, int] = {"messages_in_memory": 0}
|
||||
self._dirty = False
|
||||
self._save_timer: threading.Timer | None = None
|
||||
self._SAVE_INTERVAL = 5.0
|
||||
atexit.register(self._flush)
|
||||
self._load()
|
||||
|
||||
def _settings(self):
|
||||
return get_settings()
|
||||
|
||||
def _persist_spool_enabled(self) -> bool:
|
||||
return bool(self._settings().MESH_DM_PERSIST_SPOOL)
|
||||
|
||||
def _request_mailbox_limit(self) -> int:
|
||||
return max(1, int(self._settings().MESH_DM_REQUEST_MAILBOX_LIMIT))
|
||||
|
||||
def _shared_mailbox_limit(self) -> int:
|
||||
return max(1, int(self._settings().MESH_DM_SHARED_MAILBOX_LIMIT))
|
||||
|
||||
def _self_mailbox_limit(self) -> int:
|
||||
return max(1, int(self._settings().MESH_DM_SELF_MAILBOX_LIMIT))
|
||||
|
||||
def _nonce_ttl_seconds(self) -> int:
|
||||
return max(30, int(self._settings().MESH_DM_NONCE_TTL_S))
|
||||
|
||||
def _nonce_cache_max_entries(self) -> int:
|
||||
return max(1, int(getattr(self._settings(), "MESH_DM_NONCE_CACHE_MAX", 4096)))
|
||||
|
||||
def _pepper_token(self, token: str) -> str:
|
||||
material = token
|
||||
pepper = _get_token_pepper()
|
||||
if pepper:
|
||||
material = f"{pepper}|{token}"
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
||||
|
||||
def _sender_block_ref(self, sender_id: str) -> str:
|
||||
sender = str(sender_id or "").strip()
|
||||
if not sender:
|
||||
return ""
|
||||
return "ref:" + self._pepper_token(f"block|{sender}")
|
||||
|
||||
def _canonical_blocked_id(self, blocked_id: str) -> str:
|
||||
blocked = str(blocked_id or "").strip()
|
||||
if not blocked:
|
||||
return ""
|
||||
if blocked.startswith("ref:"):
|
||||
return blocked
|
||||
return self._sender_block_ref(blocked)
|
||||
|
||||
def _message_block_ref(self, message: DMMessage) -> str:
|
||||
block_ref = str(getattr(message, "sender_block_ref", "") or "").strip()
|
||||
if block_ref:
|
||||
return block_ref
|
||||
sender_id = str(message.sender_id or "").strip()
|
||||
if not sender_id or sender_id.startswith("sealed:") or sender_id.startswith("sender_token:"):
|
||||
return ""
|
||||
return self._sender_block_ref(sender_id)
|
||||
|
||||
def _mailbox_key(self, mailbox_type: str, mailbox_value: str, epoch: int | None = None) -> str:
|
||||
if mailbox_type in {"self", "requests"}:
|
||||
bucket = self._epoch_bucket() if epoch is None else int(epoch)
|
||||
identifier = f"{mailbox_type}|{bucket}|{mailbox_value}"
|
||||
else:
|
||||
identifier = f"{mailbox_type}|{mailbox_value}"
|
||||
return self._pepper_token(identifier)
|
||||
|
||||
def _hashed_mailbox_token(self, token: str) -> str:
|
||||
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
||||
|
||||
def _remember_mailbox_binding(self, agent_id: str, mailbox_type: str, token: str) -> str:
|
||||
token_hash = self._hashed_mailbox_token(token)
|
||||
self._mailbox_bindings[str(agent_id or "").strip()][str(mailbox_type or "").strip().lower()] = {
|
||||
"token_hash": token_hash,
|
||||
"last_used": time.time(),
|
||||
}
|
||||
self._save()
|
||||
return token_hash
|
||||
|
||||
def _bound_mailbox_key(self, agent_id: str, mailbox_type: str) -> str:
|
||||
entry = self._mailbox_bindings.get(str(agent_id or "").strip(), {}).get(
|
||||
str(mailbox_type or "").strip().lower(),
|
||||
"",
|
||||
)
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("token_hash", "") or "")
|
||||
return str(entry or "")
|
||||
|
||||
def _mailbox_keys_for_claim(self, agent_id: str, claim: dict[str, Any]) -> list[str]:
|
||||
claim_type = str(claim.get("type", "")).strip().lower()
|
||||
if claim_type == "shared":
|
||||
token = str(claim.get("token", "")).strip()
|
||||
if not token:
|
||||
metrics_inc("dm_claim_invalid")
|
||||
return []
|
||||
return [self._hashed_mailbox_token(token)]
|
||||
if claim_type == "requests":
|
||||
token = str(claim.get("token", "")).strip()
|
||||
if token:
|
||||
bound_key = self._remember_mailbox_binding(agent_id, "requests", token)
|
||||
epoch = self._epoch_bucket()
|
||||
return [
|
||||
bound_key,
|
||||
self._mailbox_key("requests", agent_id, epoch),
|
||||
self._mailbox_key("requests", agent_id, epoch - 1),
|
||||
]
|
||||
metrics_inc("dm_claim_invalid")
|
||||
return []
|
||||
if claim_type == "self":
|
||||
token = str(claim.get("token", "")).strip()
|
||||
if token:
|
||||
bound_key = self._remember_mailbox_binding(agent_id, "self", token)
|
||||
epoch = self._epoch_bucket()
|
||||
return [
|
||||
bound_key,
|
||||
self._mailbox_key("self", agent_id, epoch),
|
||||
self._mailbox_key("self", agent_id, epoch - 1),
|
||||
]
|
||||
metrics_inc("dm_claim_invalid")
|
||||
return []
|
||||
metrics_inc("dm_claim_invalid")
|
||||
return []
|
||||
|
||||
def mailbox_key_for_delivery(
|
||||
self,
|
||||
*,
|
||||
recipient_id: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str | None = None,
|
||||
) -> str:
|
||||
delivery_class = str(delivery_class or "").strip().lower()
|
||||
if delivery_class == "request":
|
||||
bound_key = self._bound_mailbox_key(recipient_id, "requests")
|
||||
if bound_key:
|
||||
return bound_key
|
||||
return self._mailbox_key("requests", str(recipient_id or "").strip())
|
||||
if delivery_class == "shared":
|
||||
token = str(recipient_token or "").strip()
|
||||
if not token:
|
||||
raise ValueError("recipient_token required for shared delivery")
|
||||
return self._hashed_mailbox_token(token)
|
||||
raise ValueError("Unsupported delivery_class")
|
||||
|
||||
def claim_mailbox_keys(self, agent_id: str, claims: list[dict[str, Any]]) -> list[str]:
|
||||
keys: list[str] = []
|
||||
for claim in claims[:32]:
|
||||
keys.extend(self._mailbox_keys_for_claim(agent_id, claim))
|
||||
return list(dict.fromkeys(keys))
|
||||
|
||||
def _legacy_mailbox_token(self, agent_id: str, epoch: int) -> str:
|
||||
raw = f"sb_dm|{epoch}|{agent_id}".encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def _legacy_token_candidates(self, agent_id: str) -> list[str]:
|
||||
epoch = self._epoch_bucket()
|
||||
raw = [self._legacy_mailbox_token(agent_id, epoch), self._legacy_mailbox_token(agent_id, epoch - 1)]
|
||||
peppered = [self._pepper_token(token) for token in raw]
|
||||
return list(dict.fromkeys(peppered + raw))
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Mark dirty and schedule a coalesced disk write."""
|
||||
self._dirty = True
|
||||
if not RELAY_FILE.exists():
|
||||
self._flush()
|
||||
return
|
||||
with self._lock:
|
||||
if self._save_timer is None or not self._save_timer.is_alive():
|
||||
self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush)
|
||||
self._save_timer.daemon = True
|
||||
self._save_timer.start()
|
||||
|
||||
def _prune_stale_metadata(self) -> None:
|
||||
"""Remove expired DH keys, prekey bundles, and mailbox bindings."""
|
||||
now = time.time()
|
||||
settings = self._settings()
|
||||
key_ttl = max(1, int(getattr(settings, "MESH_DM_KEY_TTL_DAYS", 30) or 30)) * 86400
|
||||
binding_ttl = max(1, int(getattr(settings, "MESH_DM_BINDING_TTL_DAYS", 7) or 7)) * 86400
|
||||
|
||||
stale_keys = [
|
||||
aid for aid, entry in self._dh_keys.items()
|
||||
if (now - float(entry.get("timestamp", 0) or 0)) > key_ttl
|
||||
]
|
||||
for aid in stale_keys:
|
||||
del self._dh_keys[aid]
|
||||
|
||||
stale_bundles = [
|
||||
aid for aid, entry in self._prekey_bundles.items()
|
||||
if (now - float(entry.get("updated_at", entry.get("timestamp", 0)) or 0)) > key_ttl
|
||||
]
|
||||
for aid in stale_bundles:
|
||||
del self._prekey_bundles[aid]
|
||||
|
||||
stale_agents: list[str] = []
|
||||
for agent_id, kinds in self._mailbox_bindings.items():
|
||||
expired_kinds = [
|
||||
k for k, v in kinds.items()
|
||||
if isinstance(v, dict) and (now - float(v.get("last_used", 0) or 0)) > binding_ttl
|
||||
]
|
||||
for k in expired_kinds:
|
||||
del kinds[k]
|
||||
if not kinds:
|
||||
stale_agents.append(agent_id)
|
||||
for agent_id in stale_agents:
|
||||
del self._mailbox_bindings[agent_id]
|
||||
|
||||
def _metadata_persist_enabled(self) -> bool:
|
||||
return bool(getattr(self._settings(), "MESH_DM_METADATA_PERSIST", True))
|
||||
|
||||
def _flush(self) -> None:
|
||||
"""Actually write to disk (called by timer or atexit)."""
|
||||
if not self._dirty:
|
||||
return
|
||||
try:
|
||||
self._prune_stale_metadata()
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, Any] = {
|
||||
"saved_at": int(time.time()),
|
||||
"dh_keys": self._dh_keys,
|
||||
"prekey_bundles": self._prekey_bundles,
|
||||
"witnesses": self._witnesses,
|
||||
"blocks": {k: sorted(v) for k, v in self._blocks.items()},
|
||||
"nonce_cache": dict(self._nonce_cache),
|
||||
"stats": self._stats,
|
||||
}
|
||||
if self._metadata_persist_enabled():
|
||||
payload["mailbox_bindings"] = self._mailbox_bindings
|
||||
if self._persist_spool_enabled():
|
||||
payload["mailboxes"] = {
|
||||
key: [m.__dict__ for m in msgs] for key, msgs in self._mailboxes.items()
|
||||
}
|
||||
write_secure_json(RELAY_FILE, payload)
|
||||
self._dirty = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load(self) -> None:
|
||||
if not RELAY_FILE.exists():
|
||||
return
|
||||
try:
|
||||
data = read_secure_json(RELAY_FILE, lambda: {})
|
||||
except Exception:
|
||||
return
|
||||
if self._persist_spool_enabled():
|
||||
mailboxes = data.get("mailboxes", {})
|
||||
if isinstance(mailboxes, dict):
|
||||
for key, items in mailboxes.items():
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
restored: list[DMMessage] = []
|
||||
for item in items:
|
||||
try:
|
||||
restored.append(
|
||||
DMMessage(
|
||||
sender_id=str(item.get("sender_id", "")),
|
||||
ciphertext=str(item.get("ciphertext", "")),
|
||||
timestamp=float(item.get("timestamp", 0)),
|
||||
msg_id=str(item.get("msg_id", "")),
|
||||
delivery_class=str(item.get("delivery_class", "shared")),
|
||||
sender_seal=str(item.get("sender_seal", "")),
|
||||
relay_salt=str(item.get("relay_salt", "") or ""),
|
||||
sender_block_ref=str(item.get("sender_block_ref", "") or ""),
|
||||
payload_format=str(item.get("payload_format", item.get("format", "dm1")) or "dm1"),
|
||||
session_welcome=str(item.get("session_welcome", "") or ""),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
for message in restored:
|
||||
if not message.sender_block_ref:
|
||||
message.sender_block_ref = self._message_block_ref(message)
|
||||
if restored:
|
||||
self._mailboxes[key] = restored
|
||||
dh_keys = data.get("dh_keys", {})
|
||||
if isinstance(dh_keys, dict):
|
||||
self._dh_keys = {str(k): dict(v) for k, v in dh_keys.items() if isinstance(v, dict)}
|
||||
prekey_bundles = data.get("prekey_bundles", {})
|
||||
if isinstance(prekey_bundles, dict):
|
||||
self._prekey_bundles = {
|
||||
str(k): dict(v) for k, v in prekey_bundles.items() if isinstance(v, dict)
|
||||
}
|
||||
mailbox_bindings = data.get("mailbox_bindings", {})
|
||||
if isinstance(mailbox_bindings, dict):
|
||||
self._mailbox_bindings = defaultdict(
|
||||
dict,
|
||||
{
|
||||
str(agent_id): {
|
||||
str(kind): str(token_hash)
|
||||
for kind, token_hash in dict(bindings or {}).items()
|
||||
if str(token_hash or "").strip()
|
||||
}
|
||||
for agent_id, bindings in mailbox_bindings.items()
|
||||
if isinstance(bindings, dict)
|
||||
},
|
||||
)
|
||||
witnesses = data.get("witnesses", {})
|
||||
if isinstance(witnesses, dict):
|
||||
self._witnesses = defaultdict(
|
||||
list,
|
||||
{
|
||||
str(k): list(v)
|
||||
for k, v in witnesses.items()
|
||||
if isinstance(v, list)
|
||||
},
|
||||
)
|
||||
blocks = data.get("blocks", {})
|
||||
if isinstance(blocks, dict):
|
||||
for key, values in blocks.items():
|
||||
if isinstance(values, list):
|
||||
self._blocks[str(key)] = {
|
||||
self._canonical_blocked_id(str(v))
|
||||
for v in values
|
||||
if str(v or "").strip()
|
||||
}
|
||||
nonce_cache = data.get("nonce_cache", {})
|
||||
if isinstance(nonce_cache, dict):
|
||||
now = time.time()
|
||||
restored = sorted(
|
||||
(
|
||||
(str(k), float(v))
|
||||
for k, v in nonce_cache.items()
|
||||
if float(v) > now
|
||||
),
|
||||
key=lambda item: item[1],
|
||||
)
|
||||
self._nonce_cache = OrderedDict(restored)
|
||||
stats = data.get("stats", {})
|
||||
if isinstance(stats, dict):
|
||||
self._stats = {str(k): int(v) for k, v in stats.items() if isinstance(v, (int, float))}
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
|
||||
def _bundle_fingerprint(
|
||||
self,
|
||||
*,
|
||||
dh_pub_key: str,
|
||||
dh_algo: str,
|
||||
public_key: str,
|
||||
public_key_algo: str,
|
||||
protocol_version: str,
|
||||
) -> str:
|
||||
material = "|".join(
|
||||
[
|
||||
dh_pub_key,
|
||||
dh_algo,
|
||||
public_key,
|
||||
public_key_algo,
|
||||
protocol_version,
|
||||
]
|
||||
)
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
||||
|
||||
def register_dh_key(
|
||||
self,
|
||||
agent_id: str,
|
||||
dh_pub_key: str,
|
||||
dh_algo: str,
|
||||
timestamp: int,
|
||||
signature: str,
|
||||
public_key: str,
|
||||
public_key_algo: str,
|
||||
protocol_version: str,
|
||||
sequence: int,
|
||||
) -> tuple[bool, str, dict[str, Any] | None]:
|
||||
"""Register/update an agent's DH public key bundle with replay protection."""
|
||||
fingerprint = self._bundle_fingerprint(
|
||||
dh_pub_key=dh_pub_key,
|
||||
dh_algo=dh_algo,
|
||||
public_key=public_key,
|
||||
public_key_algo=public_key_algo,
|
||||
protocol_version=protocol_version,
|
||||
)
|
||||
with self._lock:
|
||||
existing = self._dh_keys.get(agent_id)
|
||||
if existing:
|
||||
existing_seq = int(existing.get("sequence", 0) or 0)
|
||||
existing_ts = int(existing.get("timestamp", 0) or 0)
|
||||
if sequence <= existing_seq:
|
||||
metrics_inc("dm_key_replay")
|
||||
return False, "DM key replay or rollback rejected", None
|
||||
if timestamp < existing_ts:
|
||||
metrics_inc("dm_key_stale")
|
||||
return False, "DM key timestamp is older than the current bundle", None
|
||||
self._dh_keys[agent_id] = {
|
||||
"dh_pub_key": dh_pub_key,
|
||||
"dh_algo": dh_algo,
|
||||
"timestamp": timestamp,
|
||||
"signature": signature,
|
||||
"public_key": public_key,
|
||||
"public_key_algo": public_key_algo,
|
||||
"protocol_version": protocol_version,
|
||||
"sequence": sequence,
|
||||
"bundle_fingerprint": fingerprint,
|
||||
}
|
||||
self._save()
|
||||
return True, "ok", {
|
||||
"accepted_sequence": sequence,
|
||||
"bundle_fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
def get_dh_key(self, agent_id: str) -> dict[str, Any] | None:
|
||||
return self._dh_keys.get(agent_id)
|
||||
|
||||
def register_prekey_bundle(
|
||||
self,
|
||||
agent_id: str,
|
||||
bundle: dict[str, Any],
|
||||
signature: str,
|
||||
public_key: str,
|
||||
public_key_algo: str,
|
||||
protocol_version: str,
|
||||
sequence: int,
|
||||
) -> tuple[bool, str, dict[str, Any] | None]:
|
||||
ok, reason = _validate_bundle_record(
|
||||
{
|
||||
"bundle": bundle,
|
||||
"public_key": public_key,
|
||||
"agent_id": agent_id,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
return False, reason, None
|
||||
with self._lock:
|
||||
existing = self._prekey_bundles.get(agent_id)
|
||||
if existing:
|
||||
existing_seq = int(existing.get("sequence", 0) or 0)
|
||||
if sequence <= existing_seq:
|
||||
return False, "Prekey bundle replay or rollback rejected", None
|
||||
stored = {
|
||||
"bundle": dict(bundle or {}),
|
||||
"signature": signature,
|
||||
"public_key": public_key,
|
||||
"public_key_algo": public_key_algo,
|
||||
"protocol_version": protocol_version,
|
||||
"sequence": int(sequence),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
self._prekey_bundles[agent_id] = stored
|
||||
self._save()
|
||||
return True, "ok", {"accepted_sequence": int(sequence)}
|
||||
|
||||
def get_prekey_bundle(self, agent_id: str) -> dict[str, Any] | None:
|
||||
stored = self._prekey_bundles.get(agent_id)
|
||||
if not stored:
|
||||
return None
|
||||
return dict(stored)
|
||||
|
||||
def consume_one_time_prekey(self, agent_id: str) -> dict[str, Any] | None:
|
||||
"""Atomically claim the next published one-time prekey for a peer bundle."""
|
||||
claimed: dict[str, Any] | None = None
|
||||
with self._lock:
|
||||
stored = self._prekey_bundles.get(agent_id)
|
||||
if not stored:
|
||||
return None
|
||||
bundle = dict(stored.get("bundle") or {})
|
||||
otks = list(bundle.get("one_time_prekeys") or [])
|
||||
if not otks:
|
||||
return dict(stored)
|
||||
claimed = dict(otks.pop(0) or {})
|
||||
bundle["one_time_prekeys"] = otks
|
||||
bundle["one_time_prekey_count"] = len(otks)
|
||||
stored = dict(stored)
|
||||
stored["bundle"] = bundle
|
||||
stored["updated_at"] = int(time.time())
|
||||
self._prekey_bundles[agent_id] = stored
|
||||
self._save()
|
||||
result = dict(stored)
|
||||
result["claimed_one_time_prekey"] = claimed
|
||||
return result
|
||||
|
||||
def _prune_witnesses(self, target_id: str, ttl_days: int = 30) -> None:
|
||||
cutoff = time.time() - (ttl_days * 86400)
|
||||
self._witnesses[target_id] = [
|
||||
w for w in self._witnesses.get(target_id, []) if float(w.get("timestamp", 0)) >= cutoff
|
||||
]
|
||||
if not self._witnesses[target_id]:
|
||||
del self._witnesses[target_id]
|
||||
|
||||
def record_witness(
|
||||
self,
|
||||
witness_id: str,
|
||||
target_id: str,
|
||||
dh_pub_key: str,
|
||||
timestamp: int,
|
||||
) -> tuple[bool, str]:
|
||||
if not witness_id or not target_id or not dh_pub_key:
|
||||
return False, "Missing witness_id, target_id, or dh_pub_key"
|
||||
if witness_id == target_id:
|
||||
return False, "Cannot witness yourself"
|
||||
with self._lock:
|
||||
self._prune_witnesses(target_id)
|
||||
entries = self._witnesses.get(target_id, [])
|
||||
for entry in entries:
|
||||
if entry.get("witness_id") == witness_id and entry.get("dh_pub_key") == dh_pub_key:
|
||||
return False, "Duplicate witness"
|
||||
entries.append(
|
||||
{
|
||||
"witness_id": witness_id,
|
||||
"dh_pub_key": dh_pub_key,
|
||||
"timestamp": int(timestamp),
|
||||
}
|
||||
)
|
||||
self._witnesses[target_id] = entries[-50:]
|
||||
self._save()
|
||||
return True, "ok"
|
||||
|
||||
def get_witnesses(self, target_id: str, dh_pub_key: str | None = None, limit: int = 5) -> list[dict]:
|
||||
with self._lock:
|
||||
self._prune_witnesses(target_id)
|
||||
entries = list(self._witnesses.get(target_id, []))
|
||||
if dh_pub_key:
|
||||
entries = [e for e in entries if e.get("dh_pub_key") == dh_pub_key]
|
||||
entries = sorted(entries, key=lambda e: e.get("timestamp", 0), reverse=True)
|
||||
return entries[: max(1, limit)]
|
||||
|
||||
def _epoch_bucket(self, ts: float | None = None) -> int:
|
||||
now = ts if ts is not None else time.time()
|
||||
return int(now // EPOCH_SECONDS)
|
||||
|
||||
def _mailbox_limit_for_class(self, delivery_class: str) -> int:
|
||||
if delivery_class == "request":
|
||||
return self._request_mailbox_limit()
|
||||
if delivery_class == "shared":
|
||||
return self._shared_mailbox_limit()
|
||||
return self._self_mailbox_limit()
|
||||
|
||||
def _cleanup_expired(self) -> bool:
|
||||
now = time.time()
|
||||
changed = False
|
||||
for mailbox_id in list(self._mailboxes):
|
||||
fresh = [m for m in self._mailboxes[mailbox_id] if now - m.timestamp < TTL_SECONDS]
|
||||
if len(fresh) != len(self._mailboxes[mailbox_id]):
|
||||
changed = True
|
||||
self._mailboxes[mailbox_id] = fresh
|
||||
if not self._mailboxes[mailbox_id]:
|
||||
del self._mailboxes[mailbox_id]
|
||||
changed = True
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
return changed
|
||||
|
||||
def consume_nonce(self, agent_id: str, nonce: str, timestamp: int) -> tuple[bool, str]:
|
||||
nonce = str(nonce or "").strip()
|
||||
if not nonce:
|
||||
return False, "Missing nonce"
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._nonce_cache = OrderedDict(
|
||||
(key, expiry)
|
||||
for key, expiry in self._nonce_cache.items()
|
||||
if float(expiry) > now
|
||||
)
|
||||
key = f"{agent_id}:{nonce}"
|
||||
if key in self._nonce_cache:
|
||||
metrics_inc("dm_nonce_replay")
|
||||
return False, "nonce replay detected"
|
||||
if len(self._nonce_cache) >= self._nonce_cache_max_entries():
|
||||
metrics_inc("dm_nonce_cache_full")
|
||||
return False, "nonce cache at capacity"
|
||||
expiry = max(now + self._nonce_ttl_seconds(), float(timestamp) + self._nonce_ttl_seconds())
|
||||
self._nonce_cache[key] = expiry
|
||||
self._nonce_cache.move_to_end(key)
|
||||
self._save()
|
||||
return True, "ok"
|
||||
|
||||
def deposit(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
raw_sender_id: str = "",
|
||||
recipient_id: str = "",
|
||||
ciphertext: str,
|
||||
msg_id: str = "",
|
||||
delivery_class: str,
|
||||
recipient_token: str | None = None,
|
||||
sender_seal: str = "",
|
||||
relay_salt: str = "",
|
||||
sender_token_hash: str = "",
|
||||
payload_format: str = "dm1",
|
||||
session_welcome: str = "",
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
authority_sender = str(raw_sender_id or sender_id or "").strip()
|
||||
sender_block_ref = self._sender_block_ref(authority_sender)
|
||||
if recipient_id and sender_block_ref in self._blocks.get(recipient_id, set()):
|
||||
metrics_inc("dm_drop_blocked")
|
||||
return {"ok": False, "detail": "Recipient is not accepting your messages"}
|
||||
if len(ciphertext) > int(self._settings().MESH_DM_MAX_MSG_BYTES):
|
||||
metrics_inc("dm_drop_oversize")
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": f"Message too large ({len(ciphertext)} > {int(self._settings().MESH_DM_MAX_MSG_BYTES)})",
|
||||
}
|
||||
self._cleanup_expired()
|
||||
if delivery_class == "request":
|
||||
mailbox_key = self._mailbox_key("requests", recipient_id)
|
||||
elif delivery_class == "shared":
|
||||
if not recipient_token:
|
||||
metrics_inc("dm_claim_invalid")
|
||||
return {"ok": False, "detail": "recipient_token required for shared delivery"}
|
||||
mailbox_key = self._hashed_mailbox_token(recipient_token)
|
||||
else:
|
||||
return {"ok": False, "detail": "Unsupported delivery_class"}
|
||||
if len(self._mailboxes[mailbox_key]) >= self._mailbox_limit_for_class(delivery_class):
|
||||
metrics_inc("dm_drop_full")
|
||||
return {"ok": False, "detail": "Recipient mailbox full"}
|
||||
if not msg_id:
|
||||
msg_id = f"dm_{int(time.time() * 1000)}_{secrets.token_hex(6)}"
|
||||
elif any(m.msg_id == msg_id for m in self._mailboxes[mailbox_key]):
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
relay_sender_id = (
|
||||
f"sender_token:{sender_token_hash}"
|
||||
if sender_token_hash and delivery_class == "shared"
|
||||
else sender_id
|
||||
)
|
||||
self._mailboxes[mailbox_key].append(
|
||||
DMMessage(
|
||||
sender_id=relay_sender_id,
|
||||
ciphertext=ciphertext,
|
||||
timestamp=time.time(),
|
||||
msg_id=msg_id,
|
||||
delivery_class=delivery_class,
|
||||
sender_seal=sender_seal,
|
||||
sender_block_ref=sender_block_ref,
|
||||
payload_format=str(payload_format or "dm1"),
|
||||
session_welcome=str(session_welcome or ""),
|
||||
)
|
||||
)
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
|
||||
def is_blocked(self, recipient_id: str, sender_id: str) -> bool:
|
||||
with self._lock:
|
||||
blocked_ref = self._sender_block_ref(sender_id)
|
||||
if not recipient_id or not blocked_ref:
|
||||
return False
|
||||
return blocked_ref in self._blocks.get(recipient_id, set())
|
||||
|
||||
def _collect_from_keys(self, keys: list[str], *, destructive: bool) -> list[dict[str, Any]]:
|
||||
messages: list[DMMessage] = []
|
||||
seen: set[str] = set()
|
||||
for key in keys:
|
||||
mailbox = self._mailboxes.pop(key, []) if destructive else list(self._mailboxes.get(key, []))
|
||||
for message in mailbox:
|
||||
if message.msg_id in seen:
|
||||
continue
|
||||
seen.add(message.msg_id)
|
||||
messages.append(message)
|
||||
if destructive:
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
return [
|
||||
{
|
||||
"sender_id": message.sender_id,
|
||||
"ciphertext": message.ciphertext,
|
||||
"timestamp": message.timestamp,
|
||||
"msg_id": message.msg_id,
|
||||
"delivery_class": message.delivery_class,
|
||||
"sender_seal": message.sender_seal,
|
||||
"format": message.payload_format,
|
||||
"session_welcome": message.session_welcome,
|
||||
}
|
||||
for message in sorted(messages, key=lambda item: item.timestamp)
|
||||
]
|
||||
|
||||
def collect_claims(self, agent_id: str, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
self._cleanup_expired()
|
||||
keys: list[str] = []
|
||||
for claim in claims[:32]:
|
||||
keys.extend(self._mailbox_keys_for_claim(agent_id, claim))
|
||||
return self._collect_from_keys(list(dict.fromkeys(keys)), destructive=True)
|
||||
|
||||
def count_claims(self, agent_id: str, claims: list[dict[str, Any]]) -> int:
|
||||
with self._lock:
|
||||
self._cleanup_expired()
|
||||
keys: list[str] = []
|
||||
for claim in claims[:32]:
|
||||
keys.extend(self._mailbox_keys_for_claim(agent_id, claim))
|
||||
messages = self._collect_from_keys(list(dict.fromkeys(keys)), destructive=False)
|
||||
return len(messages)
|
||||
|
||||
def claim_message_ids(self, agent_id: str, claims: list[dict[str, Any]]) -> set[str]:
|
||||
with self._lock:
|
||||
self._cleanup_expired()
|
||||
keys: list[str] = []
|
||||
for claim in claims[:32]:
|
||||
keys.extend(self._mailbox_keys_for_claim(agent_id, claim))
|
||||
messages = self._collect_from_keys(list(dict.fromkeys(keys)), destructive=False)
|
||||
return {
|
||||
str(message.get("msg_id", "") or "")
|
||||
for message in messages
|
||||
if str(message.get("msg_id", "") or "")
|
||||
}
|
||||
|
||||
def collect_legacy(self, agent_id: str | None = None, agent_token: str | None = None) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
self._cleanup_expired()
|
||||
if not agent_token:
|
||||
return []
|
||||
keys = [self._pepper_token(agent_token), agent_token]
|
||||
return self._collect_from_keys(list(dict.fromkeys(keys)), destructive=True)
|
||||
|
||||
def count_legacy(self, agent_id: str | None = None, agent_token: str | None = None) -> int:
|
||||
with self._lock:
|
||||
self._cleanup_expired()
|
||||
if not agent_token:
|
||||
return 0
|
||||
keys = [self._pepper_token(agent_token), agent_token]
|
||||
return len(self._collect_from_keys(list(dict.fromkeys(keys)), destructive=False))
|
||||
|
||||
def block(self, agent_id: str, blocked_id: str) -> None:
|
||||
with self._lock:
|
||||
blocked_ref = self._canonical_blocked_id(blocked_id)
|
||||
if not blocked_ref:
|
||||
return
|
||||
self._blocks[agent_id].add(blocked_ref)
|
||||
purge_keys = self._legacy_token_candidates(agent_id)
|
||||
bound_request = self._bound_mailbox_key(agent_id, "requests")
|
||||
bound_self = self._bound_mailbox_key(agent_id, "self")
|
||||
if bound_request:
|
||||
purge_keys.append(bound_request)
|
||||
if bound_self:
|
||||
purge_keys.append(bound_self)
|
||||
purge_keys.extend(
|
||||
[
|
||||
self._mailbox_key("self", agent_id),
|
||||
self._mailbox_key("requests", agent_id),
|
||||
self._mailbox_key("self", agent_id, self._epoch_bucket() - 1),
|
||||
self._mailbox_key("requests", agent_id, self._epoch_bucket() - 1),
|
||||
]
|
||||
)
|
||||
for key in set(purge_keys):
|
||||
if key in self._mailboxes:
|
||||
self._mailboxes[key] = [
|
||||
m for m in self._mailboxes[key] if self._message_block_ref(m) != blocked_ref
|
||||
]
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
|
||||
def unblock(self, agent_id: str, blocked_id: str) -> None:
|
||||
with self._lock:
|
||||
blocked_ref = self._canonical_blocked_id(blocked_id)
|
||||
if not blocked_ref:
|
||||
return
|
||||
self._blocks[agent_id].discard(blocked_ref)
|
||||
self._save()
|
||||
|
||||
|
||||
dm_relay = DMRelay()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
KEY_SIZE = 32
|
||||
DEFAULT_SEEDS = [0x243F6A8885A308D3, 0x13198A2E03707344, 0xA4093822299F31D0]
|
||||
FINGERPRINT_SEED = 0xC0FFEE1234567890
|
||||
|
||||
|
||||
def _safe_int(val, default=0) -> int:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _hash64(data: bytes, seed: int) -> int:
|
||||
key = seed.to_bytes(8, "little", signed=False)
|
||||
digest = hashlib.blake2b(data, digest_size=8, key=key).digest()
|
||||
return int.from_bytes(digest, "little", signed=False)
|
||||
|
||||
|
||||
def _fingerprint(data: bytes) -> int:
|
||||
key = FINGERPRINT_SEED.to_bytes(8, "little", signed=False)
|
||||
digest = hashlib.blake2b(data, digest_size=8, key=key).digest()
|
||||
return int.from_bytes(digest, "little", signed=False)
|
||||
|
||||
|
||||
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||
return bytes(x ^ y for x, y in zip(a, b))
|
||||
|
||||
|
||||
def _ensure_key(key: bytes) -> bytes:
|
||||
if len(key) != KEY_SIZE:
|
||||
raise ValueError(f"IBF key must be {KEY_SIZE} bytes")
|
||||
return key
|
||||
|
||||
|
||||
def _b64_encode(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _b64_decode(data: str) -> bytes:
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class IBLTCell:
|
||||
count: int = 0
|
||||
key_xor: bytes = b"\x00" * KEY_SIZE
|
||||
hash_xor: int = 0
|
||||
|
||||
def add(self, key: bytes, sign: int) -> None:
|
||||
self.count += sign
|
||||
self.key_xor = _xor_bytes(self.key_xor, key)
|
||||
self.hash_xor ^= _fingerprint(key)
|
||||
|
||||
|
||||
class IBLT:
|
||||
def __init__(self, size: int, seeds: List[int] | None = None) -> None:
|
||||
if size <= 0:
|
||||
raise ValueError("IBLT size must be positive")
|
||||
self.size = size
|
||||
self.seeds = seeds or list(DEFAULT_SEEDS)
|
||||
self.cells: List[IBLTCell] = [IBLTCell() for _ in range(size)]
|
||||
|
||||
def _indexes(self, key: bytes) -> List[int]:
|
||||
key = _ensure_key(key)
|
||||
return [(_hash64(key, seed) % self.size) for seed in self.seeds]
|
||||
|
||||
def insert(self, key: bytes) -> None:
|
||||
key = _ensure_key(key)
|
||||
for idx in self._indexes(key):
|
||||
self.cells[idx].add(key, 1)
|
||||
|
||||
def delete(self, key: bytes) -> None:
|
||||
key = _ensure_key(key)
|
||||
for idx in self._indexes(key):
|
||||
self.cells[idx].add(key, -1)
|
||||
|
||||
def subtract(self, other: "IBLT") -> "IBLT":
|
||||
if self.size != other.size or self.seeds != other.seeds:
|
||||
raise ValueError("IBLT mismatch; size or seeds differ")
|
||||
out = IBLT(self.size, self.seeds)
|
||||
for i, cell in enumerate(self.cells):
|
||||
other_cell = other.cells[i]
|
||||
out.cells[i] = IBLTCell(
|
||||
count=cell.count - other_cell.count,
|
||||
key_xor=_xor_bytes(cell.key_xor, other_cell.key_xor),
|
||||
hash_xor=cell.hash_xor ^ other_cell.hash_xor,
|
||||
)
|
||||
return out
|
||||
|
||||
def decode(self) -> Tuple[bool, List[bytes], List[bytes]]:
|
||||
plus: List[bytes] = []
|
||||
minus: List[bytes] = []
|
||||
stack = [i for i, c in enumerate(self.cells) if abs(c.count) == 1]
|
||||
|
||||
while stack:
|
||||
idx = stack.pop()
|
||||
cell = self.cells[idx]
|
||||
if abs(cell.count) != 1:
|
||||
continue
|
||||
key = cell.key_xor
|
||||
if _fingerprint(key) != cell.hash_xor:
|
||||
continue
|
||||
sign = 1 if cell.count == 1 else -1
|
||||
if sign == 1:
|
||||
plus.append(key)
|
||||
else:
|
||||
minus.append(key)
|
||||
for j in self._indexes(key):
|
||||
if j == idx:
|
||||
continue
|
||||
self.cells[j].add(key, -sign)
|
||||
if abs(self.cells[j].count) == 1:
|
||||
stack.append(j)
|
||||
self.cells[idx] = IBLTCell()
|
||||
|
||||
success = all(
|
||||
c.count == 0 and c.hash_xor == 0 and c.key_xor == b"\x00" * KEY_SIZE
|
||||
for c in self.cells
|
||||
)
|
||||
return success, plus, minus
|
||||
|
||||
def to_compact_dict(self) -> dict:
|
||||
return {
|
||||
"m": self.size,
|
||||
"s": self.seeds,
|
||||
"c": [[cell.count, _b64_encode(cell.key_xor), cell.hash_xor] for cell in self.cells],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_compact_dict(cls, data: dict) -> "IBLT":
|
||||
size = _safe_int(data.get("m", 0) or 0)
|
||||
seeds = data.get("s") or list(DEFAULT_SEEDS)
|
||||
cells = data.get("c") or []
|
||||
iblt = cls(size, list(seeds))
|
||||
if len(cells) != size:
|
||||
raise ValueError("IBLT cell count mismatch")
|
||||
for i, raw in enumerate(cells):
|
||||
count, key_b64, hash_xor = raw
|
||||
iblt.cells[i] = IBLTCell(
|
||||
count=_safe_int(count, 0),
|
||||
key_xor=_b64_decode(str(key_b64)),
|
||||
hash_xor=_safe_int(hash_xor, 0),
|
||||
)
|
||||
return iblt
|
||||
|
||||
|
||||
def build_iblt(keys: Iterable[bytes], size: int) -> IBLT:
|
||||
iblt = IBLT(size)
|
||||
for key in keys:
|
||||
iblt.insert(key)
|
||||
return iblt
|
||||
|
||||
|
||||
def minhash_sketch(keys: Iterable[bytes], k: int) -> List[int]:
|
||||
if k <= 0:
|
||||
return []
|
||||
mins: List[int] = []
|
||||
for key in keys:
|
||||
h = _hash64(key, 0x9E3779B97F4A7C15)
|
||||
if len(mins) < k:
|
||||
mins.append(h)
|
||||
mins.sort()
|
||||
elif h < mins[-1]:
|
||||
mins[-1] = h
|
||||
mins.sort()
|
||||
return mins
|
||||
|
||||
|
||||
def minhash_similarity(a: Iterable[int], b: Iterable[int]) -> float:
|
||||
a_list = list(a)
|
||||
b_list = list(b)
|
||||
if not a_list or not b_list:
|
||||
return 0.0
|
||||
k = min(len(a_list), len(b_list))
|
||||
if k <= 0:
|
||||
return 0.0
|
||||
a_set = set(a_list[:k])
|
||||
b_set = set(b_list[:k])
|
||||
return len(a_set & b_set) / float(k)
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from services.mesh.mesh_peer_store import PeerRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncWorkerState:
|
||||
last_sync_started_at: int = 0
|
||||
last_sync_finished_at: int = 0
|
||||
last_sync_ok_at: int = 0
|
||||
next_sync_due_at: int = 0
|
||||
last_peer_url: str = ""
|
||||
last_error: str = ""
|
||||
last_outcome: str = "idle"
|
||||
current_head: str = ""
|
||||
fork_detected: bool = False
|
||||
consecutive_failures: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def eligible_sync_peers(records: list[PeerRecord], *, now: float | None = None) -> list[PeerRecord]:
|
||||
current_time = int(now if now is not None else time.time())
|
||||
candidates = [
|
||||
record
|
||||
for record in records
|
||||
if record.bucket == "sync" and record.enabled and int(record.cooldown_until or 0) <= current_time
|
||||
]
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda record: (
|
||||
-int(record.last_sync_ok_at or 0),
|
||||
int(record.failure_count or 0),
|
||||
int(record.added_at or 0),
|
||||
record.peer_url,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def begin_sync(
|
||||
state: SyncWorkerState,
|
||||
*,
|
||||
peer_url: str = "",
|
||||
current_head: str = "",
|
||||
now: float | None = None,
|
||||
) -> SyncWorkerState:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
return SyncWorkerState(
|
||||
last_sync_started_at=timestamp,
|
||||
last_sync_finished_at=state.last_sync_finished_at,
|
||||
last_sync_ok_at=state.last_sync_ok_at,
|
||||
next_sync_due_at=state.next_sync_due_at,
|
||||
last_peer_url=peer_url or state.last_peer_url,
|
||||
last_error="",
|
||||
last_outcome="running",
|
||||
current_head=current_head or state.current_head,
|
||||
fork_detected=False,
|
||||
consecutive_failures=state.consecutive_failures,
|
||||
)
|
||||
|
||||
|
||||
def finish_sync(
|
||||
state: SyncWorkerState,
|
||||
*,
|
||||
ok: bool,
|
||||
peer_url: str = "",
|
||||
current_head: str = "",
|
||||
error: str = "",
|
||||
fork_detected: bool = False,
|
||||
now: float | None = None,
|
||||
interval_s: int = 300,
|
||||
failure_backoff_s: int = 60,
|
||||
) -> SyncWorkerState:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
if ok:
|
||||
return SyncWorkerState(
|
||||
last_sync_started_at=state.last_sync_started_at,
|
||||
last_sync_finished_at=timestamp,
|
||||
last_sync_ok_at=timestamp,
|
||||
next_sync_due_at=timestamp + max(0, int(interval_s or 0)),
|
||||
last_peer_url=peer_url or state.last_peer_url,
|
||||
last_error="",
|
||||
last_outcome="ok",
|
||||
current_head=current_head or state.current_head,
|
||||
fork_detected=bool(fork_detected),
|
||||
consecutive_failures=0,
|
||||
)
|
||||
|
||||
return SyncWorkerState(
|
||||
last_sync_started_at=state.last_sync_started_at,
|
||||
last_sync_finished_at=timestamp,
|
||||
last_sync_ok_at=state.last_sync_ok_at,
|
||||
next_sync_due_at=timestamp + max(0, int(failure_backoff_s or 0)),
|
||||
last_peer_url=peer_url or state.last_peer_url,
|
||||
last_error=str(error or "").strip(),
|
||||
last_outcome="fork" if fork_detected else "error",
|
||||
current_head=current_head or state.current_head,
|
||||
fork_detected=bool(fork_detected),
|
||||
consecutive_failures=state.consecutive_failures + 1,
|
||||
)
|
||||
|
||||
|
||||
def should_run_sync(
|
||||
state: SyncWorkerState,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
current_time = int(now if now is not None else time.time())
|
||||
if state.last_outcome == "running":
|
||||
return False
|
||||
return int(state.next_sync_due_at or 0) <= current_time
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _hash_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def hash_leaf(value: str) -> str:
|
||||
return _hash_bytes(value.encode("utf-8"))
|
||||
|
||||
|
||||
def hash_pair(left: str, right: str) -> str:
|
||||
return _hash_bytes(f"{left}{right}".encode("utf-8"))
|
||||
|
||||
|
||||
def build_merkle_levels(leaves: list[str]) -> list[list[str]]:
|
||||
if not leaves:
|
||||
return []
|
||||
level = [hash_leaf(leaf) for leaf in leaves]
|
||||
levels = [level]
|
||||
while len(level) > 1:
|
||||
next_level: list[str] = []
|
||||
for idx in range(0, len(level), 2):
|
||||
left = level[idx]
|
||||
right = level[idx + 1] if idx + 1 < len(level) else left
|
||||
next_level.append(hash_pair(left, right))
|
||||
level = next_level
|
||||
levels.append(level)
|
||||
return levels
|
||||
|
||||
|
||||
def merkle_root(leaves: list[str]) -> str:
|
||||
levels = build_merkle_levels(leaves)
|
||||
if not levels:
|
||||
return ""
|
||||
return levels[-1][0]
|
||||
|
||||
|
||||
def merkle_proof_from_levels(levels: list[list[str]], index: int) -> list[dict[str, Any]]:
|
||||
if not levels:
|
||||
return []
|
||||
if index < 0 or index >= len(levels[0]):
|
||||
return []
|
||||
proof: list[dict[str, Any]] = []
|
||||
idx = index
|
||||
for level in levels[:-1]:
|
||||
is_right = idx % 2 == 1
|
||||
sibling_idx = idx - 1 if is_right else idx + 1
|
||||
if sibling_idx >= len(level):
|
||||
sibling_hash = level[idx]
|
||||
else:
|
||||
sibling_hash = level[sibling_idx]
|
||||
proof.append({"hash": sibling_hash, "side": "left" if is_right else "right"})
|
||||
idx //= 2
|
||||
return proof
|
||||
|
||||
|
||||
def verify_merkle_proof(
|
||||
leaf_value: str, index: int, proof: list[dict[str, Any]], root: str
|
||||
) -> bool:
|
||||
current = hash_leaf(leaf_value)
|
||||
idx = index
|
||||
for step in proof:
|
||||
sibling = str(step.get("hash", ""))
|
||||
side = str(step.get("side", "right")).lower()
|
||||
if side == "left":
|
||||
current = hash_pair(sibling, current)
|
||||
else:
|
||||
current = hash_pair(current, sibling)
|
||||
idx //= 2
|
||||
return current == root
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Lightweight metrics for mesh protocol health signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
_lock = threading.Lock()
|
||||
_metrics: dict[str, int] = {}
|
||||
_last_updated: float = 0.0
|
||||
|
||||
|
||||
def increment(name: str, count: int = 1) -> None:
|
||||
global _last_updated
|
||||
with _lock:
|
||||
_metrics[name] = _metrics.get(name, 0) + count
|
||||
_last_updated = time.time()
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
with _lock:
|
||||
return {
|
||||
"updated_at": _last_updated,
|
||||
"counters": dict(_metrics),
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
"""Oracle System — prediction-backed truth arbitration for the mesh.
|
||||
|
||||
Oracle Rep is a separate reputation tier earned ONLY by:
|
||||
1. Correctly predicting outcomes on Kalshi/Polymarket-sourced markets
|
||||
2. Winning truth stakes on posts/comments
|
||||
|
||||
Oracle Rep can be staked on posts to protect them from mob downvoting.
|
||||
Other oracles can counter-stake. After the stake period (1-7 days),
|
||||
whichever side has more oracle rep staked wins. Losers' rep is divided
|
||||
proportionally among winners.
|
||||
|
||||
Scoring formula for predictions:
|
||||
oracle_rep_earned = 1.0 - probability_of_chosen_outcome / 100
|
||||
- Bet YES at 99% → earn 0.01 (trivial, everyone knew)
|
||||
- Bet YES at 50% → earn 0.50 (genuine uncertainty, real insight)
|
||||
- Bet YES at 10% → earn 0.90 (contrarian genius if correct)
|
||||
|
||||
Designed for AI game theory: this mechanism works identically
|
||||
whether participants are humans, AI agents, or a mix.
|
||||
|
||||
Persistence: JSON files in backend/data/ (auto-saved on change).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import atexit
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("services.mesh_oracle")
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
ORACLE_FILE = DATA_DIR / "oracle_ledger.json"
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
MIN_STAKE_DAYS = 1 # Minimum stake duration
|
||||
MAX_STAKE_DAYS = 7 # Maximum stake duration
|
||||
GRACE_PERIOD_HOURS = 24 # Counter-stakers get 24h after any new stake
|
||||
ORACLE_DECAY_DAYS = 90 # Oracle rep decays over 90 days like regular rep
|
||||
|
||||
|
||||
class OracleLedger:
|
||||
"""Oracle reputation ledger — predictions, stakes, and truth arbitration.
|
||||
|
||||
Storage:
|
||||
oracle_rep: {node_id: float} — current oracle rep balances
|
||||
predictions: [{node_id, market_title, side, probability_at_bet, timestamp, resolved, correct, rep_earned}]
|
||||
stakes: [{stake_id, message_id, poster_id, staker_id, side ("truth"|"false"),
|
||||
amount, duration_days, created_at, expires_at, resolved}]
|
||||
prediction_log: [{node_id, market_title, side, probability_at_bet, rep_earned, timestamp}]
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.oracle_rep: dict[str, float] = {}
|
||||
self.predictions: list[dict] = []
|
||||
self.market_stakes: list[dict] = [] # Rep staked on prediction markets
|
||||
self.stakes: list[dict] = [] # Truth stakes on posts (separate system)
|
||||
self.prediction_log: list[dict] = [] # Public log of all predictions
|
||||
self._dirty = False
|
||||
self._save_lock = threading.Lock()
|
||||
self._save_timer: threading.Timer | None = None
|
||||
self._SAVE_INTERVAL = 5.0
|
||||
atexit.register(self._flush)
|
||||
self._load()
|
||||
|
||||
# ─── Persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
if ORACLE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(ORACLE_FILE.read_text(encoding="utf-8"))
|
||||
self.oracle_rep = data.get("oracle_rep", {})
|
||||
self.predictions = data.get("predictions", [])
|
||||
self.market_stakes = data.get("market_stakes", [])
|
||||
self.stakes = data.get("stakes", [])
|
||||
self.prediction_log = data.get("prediction_log", [])
|
||||
logger.info(
|
||||
f"Loaded oracle ledger: {len(self.oracle_rep)} oracles, "
|
||||
f"{len(self.predictions)} predictions, "
|
||||
f"{len(self.market_stakes)} market stakes, {len(self.stakes)} truth stakes"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load oracle ledger: {e}")
|
||||
|
||||
def _save(self):
|
||||
"""Mark dirty and schedule a coalesced disk write."""
|
||||
self._dirty = True
|
||||
with self._save_lock:
|
||||
if self._save_timer is None or not self._save_timer.is_alive():
|
||||
self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush)
|
||||
self._save_timer.daemon = True
|
||||
self._save_timer.start()
|
||||
|
||||
def _flush(self):
|
||||
"""Actually write to disk (called by timer or atexit)."""
|
||||
if not self._dirty:
|
||||
return
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"oracle_rep": self.oracle_rep,
|
||||
"predictions": self.predictions,
|
||||
"market_stakes": self.market_stakes,
|
||||
"stakes": self.stakes,
|
||||
"prediction_log": self.prediction_log,
|
||||
}
|
||||
ORACLE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
self._dirty = False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save oracle ledger: {e}")
|
||||
|
||||
# ─── Oracle Rep ───────────────────────────────────────────────────
|
||||
|
||||
def get_oracle_rep(self, node_id: str) -> float:
|
||||
"""Get current oracle rep for a node (excludes locked/staked amount)."""
|
||||
total = self.oracle_rep.get(node_id, 0.0)
|
||||
# Subtract locked truth stakes on posts
|
||||
locked = sum(
|
||||
s["amount"]
|
||||
for s in self.stakes
|
||||
if s["staker_id"] == node_id and not s.get("resolved", False)
|
||||
)
|
||||
# Subtract locked market stakes
|
||||
locked += sum(
|
||||
s["amount"]
|
||||
for s in self.market_stakes
|
||||
if s["node_id"] == node_id and not s.get("resolved", False)
|
||||
)
|
||||
return round(max(0, total - locked), 3)
|
||||
|
||||
def get_total_oracle_rep(self, node_id: str) -> float:
|
||||
"""Get total oracle rep including locked stakes."""
|
||||
return round(self.oracle_rep.get(node_id, 0.0), 3)
|
||||
|
||||
def _add_oracle_rep(self, node_id: str, amount: float):
|
||||
"""Add oracle rep to a node."""
|
||||
self.oracle_rep[node_id] = self.oracle_rep.get(node_id, 0.0) + amount
|
||||
|
||||
def _remove_oracle_rep(self, node_id: str, amount: float):
|
||||
"""Remove oracle rep from a node (floor at 0)."""
|
||||
self.oracle_rep[node_id] = max(0, self.oracle_rep.get(node_id, 0.0) - amount)
|
||||
|
||||
# ─── Predictions ──────────────────────────────────────────────────
|
||||
|
||||
def place_prediction(
|
||||
self, node_id: str, market_title: str, side: str, probability_at_bet: float
|
||||
) -> tuple[bool, str]:
|
||||
"""Place a FREE prediction on a market outcome (no rep risked).
|
||||
|
||||
Args:
|
||||
node_id: Predictor's node ID
|
||||
market_title: Title of the prediction market
|
||||
side: "yes", "no", or any outcome name for multi-outcome markets
|
||||
probability_at_bet: Current probability (0-100) of the chosen side
|
||||
|
||||
Returns (success, detail)
|
||||
"""
|
||||
if not side or not side.strip():
|
||||
return False, "Side is required"
|
||||
|
||||
if not (0 <= probability_at_bet <= 100):
|
||||
return False, "Probability must be 0-100"
|
||||
|
||||
# Check for duplicate predictions on same market
|
||||
existing = [
|
||||
p
|
||||
for p in self.predictions
|
||||
if p["node_id"] == node_id
|
||||
and p["market_title"] == market_title
|
||||
and not p.get("resolved", False)
|
||||
]
|
||||
if existing:
|
||||
return (
|
||||
False,
|
||||
f"You already have an active prediction on '{market_title}'. Your decision was FINAL.",
|
||||
)
|
||||
|
||||
# Also check market stakes — can't free-pick AND stake on same market
|
||||
existing_stake = [
|
||||
s
|
||||
for s in self.market_stakes
|
||||
if s["node_id"] == node_id
|
||||
and s["market_title"] == market_title
|
||||
and not s.get("resolved", False)
|
||||
]
|
||||
if existing_stake:
|
||||
return (
|
||||
False,
|
||||
f"You already have a STAKED prediction on '{market_title}'. Your decision was FINAL.",
|
||||
)
|
||||
|
||||
self.predictions.append(
|
||||
{
|
||||
"prediction_id": secrets.token_hex(6),
|
||||
"node_id": node_id,
|
||||
"market_title": market_title,
|
||||
"side": side,
|
||||
"probability_at_bet": probability_at_bet,
|
||||
"timestamp": time.time(),
|
||||
"resolved": False,
|
||||
"correct": None,
|
||||
"rep_earned": 0.0,
|
||||
}
|
||||
)
|
||||
self._save()
|
||||
|
||||
# Potential rep = contrarianism score
|
||||
potential = round(1.0 - probability_at_bet / 100, 3)
|
||||
|
||||
logger.info(
|
||||
f"FREE prediction: {node_id} picks '{side}' on '{market_title}' "
|
||||
f"at {probability_at_bet}% (potential: {potential} oracle rep)"
|
||||
)
|
||||
return True, (
|
||||
f"FREE PICK placed: {side.upper()} on '{market_title}' "
|
||||
f"at {probability_at_bet}%. Potential oracle rep: {potential}. "
|
||||
f"This decision is FINAL."
|
||||
)
|
||||
|
||||
def resolve_market(self, market_title: str, outcome: str) -> tuple[int, int]:
|
||||
"""Resolve all FREE predictions on a market.
|
||||
|
||||
Args:
|
||||
market_title: Title of the market
|
||||
outcome: "yes", "no", or any outcome name for multi-outcome markets
|
||||
|
||||
Returns (winners, losers) counts
|
||||
"""
|
||||
if not outcome:
|
||||
return 0, 0
|
||||
|
||||
outcome_lower = outcome.lower()
|
||||
winners, losers = 0, 0
|
||||
now = time.time()
|
||||
|
||||
for p in self.predictions:
|
||||
if p["market_title"] != market_title or p.get("resolved", False):
|
||||
continue
|
||||
|
||||
p["resolved"] = True
|
||||
correct = p["side"].lower() == outcome_lower
|
||||
p["correct"] = correct
|
||||
|
||||
if correct:
|
||||
# Rep earned = contrarianism score
|
||||
rep = round(1.0 - p["probability_at_bet"] / 100, 3)
|
||||
rep = max(0.01, rep) # Minimum 0.01 even for easy bets
|
||||
p["rep_earned"] = rep
|
||||
self._add_oracle_rep(p["node_id"], rep)
|
||||
winners += 1
|
||||
|
||||
self.prediction_log.append(
|
||||
{
|
||||
"node_id": p["node_id"],
|
||||
"market_title": market_title,
|
||||
"side": p["side"],
|
||||
"outcome": outcome,
|
||||
"probability_at_bet": p["probability_at_bet"],
|
||||
"rep_earned": rep,
|
||||
"timestamp": p["timestamp"],
|
||||
"resolved_at": now,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Oracle win: {p['node_id']} earned {rep} oracle rep "
|
||||
f"on '{market_title}' ({p['side']} at {p['probability_at_bet']}%)"
|
||||
)
|
||||
else:
|
||||
p["rep_earned"] = 0.0
|
||||
losers += 1
|
||||
|
||||
self.prediction_log.append(
|
||||
{
|
||||
"node_id": p["node_id"],
|
||||
"market_title": market_title,
|
||||
"side": p["side"],
|
||||
"outcome": outcome,
|
||||
"probability_at_bet": p["probability_at_bet"],
|
||||
"rep_earned": 0.0,
|
||||
"timestamp": p["timestamp"],
|
||||
"resolved_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
self._save()
|
||||
return winners, losers
|
||||
|
||||
def get_active_markets(self) -> list[str]:
|
||||
"""Get list of market titles with unresolved predictions or stakes."""
|
||||
titles = set()
|
||||
for p in self.predictions:
|
||||
if not p.get("resolved", False):
|
||||
titles.add(p["market_title"])
|
||||
for s in self.market_stakes:
|
||||
if not s.get("resolved", False):
|
||||
titles.add(s["market_title"])
|
||||
return list(titles)
|
||||
|
||||
# ─── Market Stakes (prediction markets) ────────────────────────────
|
||||
|
||||
def place_market_stake(
|
||||
self, node_id: str, market_title: str, side: str, amount: float, probability_at_bet: float
|
||||
) -> tuple[bool, str]:
|
||||
"""Stake oracle rep on a prediction market outcome. FINAL decision.
|
||||
|
||||
Args:
|
||||
node_id: Staker's node ID
|
||||
market_title: Title of the prediction market
|
||||
side: "yes", "no", or outcome name for multi-outcome markets
|
||||
amount: How much oracle rep to risk
|
||||
probability_at_bet: Current probability (0-100) of the chosen side
|
||||
|
||||
Returns (success, detail)
|
||||
"""
|
||||
if not side or not side.strip():
|
||||
return False, "Side is required"
|
||||
|
||||
if amount <= 0:
|
||||
return False, "Stake amount must be positive"
|
||||
|
||||
if not (0 <= probability_at_bet <= 100):
|
||||
return False, "Probability must be 0-100"
|
||||
|
||||
available = self.get_oracle_rep(node_id)
|
||||
if available < amount:
|
||||
return False, f"Insufficient oracle rep (have {available:.2f}, need {amount:.2f})"
|
||||
|
||||
# Can't have both a free pick AND a stake on the same market
|
||||
existing_free = [
|
||||
p
|
||||
for p in self.predictions
|
||||
if p["node_id"] == node_id
|
||||
and p["market_title"] == market_title
|
||||
and not p.get("resolved", False)
|
||||
]
|
||||
if existing_free:
|
||||
return (
|
||||
False,
|
||||
f"You already have a FREE prediction on '{market_title}'. Your decision was FINAL.",
|
||||
)
|
||||
|
||||
# Can't stake twice on the same market
|
||||
existing_stake = [
|
||||
s
|
||||
for s in self.market_stakes
|
||||
if s["node_id"] == node_id
|
||||
and s["market_title"] == market_title
|
||||
and not s.get("resolved", False)
|
||||
]
|
||||
if existing_stake:
|
||||
return (
|
||||
False,
|
||||
f"You already have a STAKED prediction on '{market_title}'. Your decision was FINAL.",
|
||||
)
|
||||
|
||||
self.market_stakes.append(
|
||||
{
|
||||
"stake_id": secrets.token_hex(6),
|
||||
"node_id": node_id,
|
||||
"market_title": market_title,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"probability_at_bet": probability_at_bet,
|
||||
"timestamp": time.time(),
|
||||
"resolved": False,
|
||||
"correct": None,
|
||||
"rep_earned": 0.0,
|
||||
}
|
||||
)
|
||||
self._save()
|
||||
|
||||
logger.info(
|
||||
f"MARKET STAKE: {node_id} stakes {amount:.2f} rep on '{side}' "
|
||||
f"for '{market_title}' at {probability_at_bet}%"
|
||||
)
|
||||
return True, (
|
||||
f"STAKED {amount:.2f} oracle rep on {side.upper()} for '{market_title}' "
|
||||
f"at {probability_at_bet}%. This decision is FINAL. "
|
||||
f"If correct, you split the loser pool proportionally."
|
||||
)
|
||||
|
||||
def resolve_market_stakes(self, market_title: str, outcome: str) -> dict:
|
||||
"""Resolve all market stakes for a concluded market.
|
||||
|
||||
Winners split the loser pool proportionally to their stake.
|
||||
If everyone picked the same side, stakes are returned (no profit, no loss).
|
||||
|
||||
Returns summary dict.
|
||||
"""
|
||||
if not outcome:
|
||||
return {"resolved": 0}
|
||||
|
||||
outcome_lower = outcome.lower()
|
||||
active = [
|
||||
s
|
||||
for s in self.market_stakes
|
||||
if s["market_title"] == market_title and not s.get("resolved", False)
|
||||
]
|
||||
|
||||
if not active:
|
||||
return {"resolved": 0}
|
||||
|
||||
winners = [s for s in active if s["side"].lower() == outcome_lower]
|
||||
losers = [s for s in active if s["side"].lower() != outcome_lower]
|
||||
|
||||
winner_pool = sum(s["amount"] for s in winners)
|
||||
loser_pool = sum(s["amount"] for s in losers)
|
||||
|
||||
now = time.time()
|
||||
|
||||
if not losers:
|
||||
# Everyone picked the same side — return stakes, no profit
|
||||
for s in active:
|
||||
s["resolved"] = True
|
||||
s["correct"] = True
|
||||
s["rep_earned"] = 0.0 # No profit when no opposition
|
||||
self._save()
|
||||
logger.info(
|
||||
f"Market stake resolution [{market_title}]: unanimous '{outcome}', "
|
||||
f"{len(winners)} stakers get rep back (no loser pool)"
|
||||
)
|
||||
return {
|
||||
"resolved": len(active),
|
||||
"winners": len(winners),
|
||||
"losers": 0,
|
||||
"winner_pool": winner_pool,
|
||||
"loser_pool": 0,
|
||||
"unanimous": True,
|
||||
}
|
||||
|
||||
# Losers lose their staked rep
|
||||
for s in losers:
|
||||
self._remove_oracle_rep(s["node_id"], s["amount"])
|
||||
s["resolved"] = True
|
||||
s["correct"] = False
|
||||
s["rep_earned"] = 0.0
|
||||
|
||||
self.prediction_log.append(
|
||||
{
|
||||
"node_id": s["node_id"],
|
||||
"market_title": market_title,
|
||||
"side": s["side"],
|
||||
"outcome": outcome,
|
||||
"probability_at_bet": s["probability_at_bet"],
|
||||
"rep_earned": 0.0,
|
||||
"staked": s["amount"],
|
||||
"timestamp": s["timestamp"],
|
||||
"resolved_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
# Winners split loser pool proportionally + keep their own stake
|
||||
for s in winners:
|
||||
proportion = s["amount"] / winner_pool if winner_pool > 0 else 0
|
||||
winnings = round(loser_pool * proportion, 3)
|
||||
s["resolved"] = True
|
||||
s["correct"] = True
|
||||
s["rep_earned"] = winnings
|
||||
self._add_oracle_rep(s["node_id"], winnings)
|
||||
|
||||
self.prediction_log.append(
|
||||
{
|
||||
"node_id": s["node_id"],
|
||||
"market_title": market_title,
|
||||
"side": s["side"],
|
||||
"outcome": outcome,
|
||||
"probability_at_bet": s["probability_at_bet"],
|
||||
"rep_earned": winnings,
|
||||
"staked": s["amount"],
|
||||
"timestamp": s["timestamp"],
|
||||
"resolved_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
self._save()
|
||||
logger.info(
|
||||
f"Market stake resolution [{market_title}]: '{outcome}' wins. "
|
||||
f"{len(winners)} winners split {loser_pool:.2f} rep from {len(losers)} losers"
|
||||
)
|
||||
return {
|
||||
"resolved": len(active),
|
||||
"winners": len(winners),
|
||||
"losers": len(losers),
|
||||
"winner_pool": round(winner_pool, 3),
|
||||
"loser_pool": round(loser_pool, 3),
|
||||
}
|
||||
|
||||
def get_market_consensus(self, market_title: str) -> dict:
|
||||
"""Get network consensus for a single market — picks + stakes per side."""
|
||||
sides: dict[str, dict] = {}
|
||||
|
||||
# Count free predictions
|
||||
for p in self.predictions:
|
||||
if p["market_title"] != market_title or p.get("resolved", False):
|
||||
continue
|
||||
s = p["side"]
|
||||
if s not in sides:
|
||||
sides[s] = {"picks": 0, "staked": 0.0}
|
||||
sides[s]["picks"] += 1
|
||||
|
||||
# Count market stakes
|
||||
for st in self.market_stakes:
|
||||
if st["market_title"] != market_title or st.get("resolved", False):
|
||||
continue
|
||||
s = st["side"]
|
||||
if s not in sides:
|
||||
sides[s] = {"picks": 0, "staked": 0.0}
|
||||
sides[s]["picks"] += 1
|
||||
sides[s]["staked"] = round(sides[s]["staked"] + st["amount"], 3)
|
||||
|
||||
total_picks = sum(v["picks"] for v in sides.values())
|
||||
total_staked = round(sum(v["staked"] for v in sides.values()), 3)
|
||||
|
||||
return {
|
||||
"market_title": market_title,
|
||||
"total_picks": total_picks,
|
||||
"total_staked": total_staked,
|
||||
"sides": sides,
|
||||
}
|
||||
|
||||
def get_all_market_consensus(self) -> dict[str, dict]:
|
||||
"""Bulk consensus for all active markets. Returns {market_title: consensus_summary}."""
|
||||
titles = set()
|
||||
for p in self.predictions:
|
||||
if not p.get("resolved", False):
|
||||
titles.add(p["market_title"])
|
||||
for s in self.market_stakes:
|
||||
if not s.get("resolved", False):
|
||||
titles.add(s["market_title"])
|
||||
|
||||
result = {}
|
||||
for title in titles:
|
||||
c = self.get_market_consensus(title)
|
||||
result[title] = {
|
||||
"total_picks": c["total_picks"],
|
||||
"total_staked": c["total_staked"],
|
||||
"sides": c["sides"],
|
||||
}
|
||||
return result
|
||||
|
||||
# ─── Truth Stakes (posts/comments — separate system) ──────────────
|
||||
|
||||
def place_stake(
|
||||
self,
|
||||
staker_id: str,
|
||||
message_id: str,
|
||||
poster_id: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
duration_days: int,
|
||||
) -> tuple[bool, str]:
|
||||
"""Stake oracle rep on a post's truthfulness.
|
||||
|
||||
Args:
|
||||
staker_id: Oracle staking their rep
|
||||
message_id: The post/message being evaluated
|
||||
poster_id: Who posted the original message
|
||||
side: "truth" or "false"
|
||||
amount: How much oracle rep to stake
|
||||
duration_days: 1-7 days before resolution
|
||||
|
||||
Returns (success, detail)
|
||||
"""
|
||||
if side not in ("truth", "false"):
|
||||
return False, "Side must be 'truth' or 'false'"
|
||||
|
||||
if not (MIN_STAKE_DAYS <= duration_days <= MAX_STAKE_DAYS):
|
||||
return False, f"Duration must be {MIN_STAKE_DAYS}-{MAX_STAKE_DAYS} days"
|
||||
|
||||
if amount <= 0:
|
||||
return False, "Stake amount must be positive"
|
||||
|
||||
available = self.get_oracle_rep(staker_id)
|
||||
if available < amount:
|
||||
return False, f"Insufficient oracle rep (have {available}, need {amount})"
|
||||
|
||||
# Check if this staker already has an active stake on this message
|
||||
existing = [
|
||||
s
|
||||
for s in self.stakes
|
||||
if s["staker_id"] == staker_id
|
||||
and s["message_id"] == message_id
|
||||
and not s.get("resolved", False)
|
||||
]
|
||||
if existing:
|
||||
return False, "You already have an active stake on this message"
|
||||
|
||||
now = time.time()
|
||||
expires = now + (duration_days * 86400)
|
||||
|
||||
# Check if there are existing stakes — extend grace period
|
||||
active_stakes = [
|
||||
s for s in self.stakes if s["message_id"] == message_id and not s.get("resolved", False)
|
||||
]
|
||||
# If this is a counter-stake, ensure the expiry is at least GRACE_PERIOD_HOURS
|
||||
# after the latest stake on the other side
|
||||
for s in active_stakes:
|
||||
if s["side"] != side:
|
||||
min_expires = s.get("last_counter_at", s["created_at"]) + (
|
||||
GRACE_PERIOD_HOURS * 3600
|
||||
)
|
||||
if expires < min_expires:
|
||||
expires = min_expires
|
||||
|
||||
stake = {
|
||||
"stake_id": secrets.token_hex(6),
|
||||
"message_id": message_id,
|
||||
"poster_id": poster_id,
|
||||
"staker_id": staker_id,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"duration_days": duration_days,
|
||||
"created_at": now,
|
||||
"expires_at": expires,
|
||||
"resolved": False,
|
||||
"last_counter_at": now,
|
||||
}
|
||||
self.stakes.append(stake)
|
||||
|
||||
# Update last_counter_at on opposing stakes (extends their grace period)
|
||||
for s in active_stakes:
|
||||
if s["side"] != side:
|
||||
s["last_counter_at"] = now
|
||||
|
||||
self._save()
|
||||
|
||||
days_str = f"{duration_days} day{'s' if duration_days > 1 else ''}"
|
||||
logger.info(
|
||||
f"Oracle stake: {staker_id} stakes {amount} oracle rep "
|
||||
f"as '{side}' on message {message_id} for {days_str}"
|
||||
)
|
||||
return True, (
|
||||
f"Staked {amount} oracle rep as '{side.upper()}' on message "
|
||||
f"{message_id} for {days_str}. Expires {time.strftime('%Y-%m-%d %H:%M', time.localtime(expires))}"
|
||||
)
|
||||
|
||||
def resolve_expired_stakes(self) -> list[dict]:
|
||||
"""Resolve all expired stake contests. Called periodically.
|
||||
|
||||
Returns list of resolution summaries.
|
||||
"""
|
||||
now = time.time()
|
||||
resolutions = []
|
||||
|
||||
# Group active stakes by message_id
|
||||
active_by_msg: dict[str, list[dict]] = {}
|
||||
for s in self.stakes:
|
||||
if not s.get("resolved", False):
|
||||
active_by_msg.setdefault(s["message_id"], []).append(s)
|
||||
|
||||
for msg_id, stakes in active_by_msg.items():
|
||||
# Check if ALL stakes for this message have expired
|
||||
if not all(s["expires_at"] <= now for s in stakes):
|
||||
continue # Some stakes haven't expired yet
|
||||
|
||||
# Tally sides
|
||||
truth_total = sum(s["amount"] for s in stakes if s["side"] == "truth")
|
||||
false_total = sum(s["amount"] for s in stakes if s["side"] == "false")
|
||||
|
||||
if truth_total == false_total:
|
||||
# Tie — everyone gets their rep back, no resolution
|
||||
for s in stakes:
|
||||
s["resolved"] = True
|
||||
resolutions.append(
|
||||
{
|
||||
"message_id": msg_id,
|
||||
"outcome": "tie",
|
||||
"truth_total": truth_total,
|
||||
"false_total": false_total,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
winning_side = "truth" if truth_total > false_total else "false"
|
||||
losing_total = false_total if winning_side == "truth" else truth_total
|
||||
winning_total = truth_total if winning_side == "truth" else false_total
|
||||
|
||||
winners = [s for s in stakes if s["side"] == winning_side]
|
||||
losers = [s for s in stakes if s["side"] != winning_side]
|
||||
|
||||
# Losers lose their staked rep
|
||||
for s in losers:
|
||||
self._remove_oracle_rep(s["staker_id"], s["amount"])
|
||||
s["resolved"] = True
|
||||
|
||||
# Winners divide losers' rep proportionally
|
||||
for s in winners:
|
||||
proportion = s["amount"] / winning_total if winning_total > 0 else 0
|
||||
winnings = round(losing_total * proportion, 3)
|
||||
self._add_oracle_rep(s["staker_id"], winnings)
|
||||
s["resolved"] = True
|
||||
|
||||
# Duration weight for the poster's reputation effect
|
||||
max_duration = max(s["duration_days"] for s in stakes)
|
||||
duration_label = (
|
||||
"resounding" if max_duration >= 7 else "contested" if max_duration >= 3 else "brief"
|
||||
)
|
||||
|
||||
resolution = {
|
||||
"message_id": msg_id,
|
||||
"poster_id": stakes[0].get("poster_id", ""),
|
||||
"outcome": winning_side,
|
||||
"truth_total": round(truth_total, 3),
|
||||
"false_total": round(false_total, 3),
|
||||
"duration_label": duration_label,
|
||||
"max_duration_days": max_duration,
|
||||
"winners": [
|
||||
{
|
||||
"node_id": s["staker_id"],
|
||||
"staked": s["amount"],
|
||||
"won": (
|
||||
round(losing_total * (s["amount"] / winning_total), 3)
|
||||
if winning_total > 0
|
||||
else 0
|
||||
),
|
||||
}
|
||||
for s in winners
|
||||
],
|
||||
"losers": [
|
||||
{
|
||||
"node_id": s["staker_id"],
|
||||
"lost": s["amount"],
|
||||
}
|
||||
for s in losers
|
||||
],
|
||||
}
|
||||
resolutions.append(resolution)
|
||||
logger.info(
|
||||
f"Oracle resolution [{msg_id}]: {winning_side.upper()} wins "
|
||||
f"({truth_total} vs {false_total}), {duration_label} verdict"
|
||||
)
|
||||
|
||||
if resolutions:
|
||||
self._save()
|
||||
return resolutions
|
||||
|
||||
def get_stakes_for_message(self, message_id: str) -> dict:
|
||||
"""Get all stakes on a message with totals."""
|
||||
active = [
|
||||
s for s in self.stakes if s["message_id"] == message_id and not s.get("resolved", False)
|
||||
]
|
||||
truth_stakes = [s for s in active if s["side"] == "truth"]
|
||||
false_stakes = [s for s in active if s["side"] == "false"]
|
||||
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"truth_total": round(sum(s["amount"] for s in truth_stakes), 3),
|
||||
"false_total": round(sum(s["amount"] for s in false_stakes), 3),
|
||||
"truth_stakers": [
|
||||
{"node_id": s["staker_id"], "amount": s["amount"], "expires": s["expires_at"]}
|
||||
for s in truth_stakes
|
||||
],
|
||||
"false_stakers": [
|
||||
{"node_id": s["staker_id"], "amount": s["amount"], "expires": s["expires_at"]}
|
||||
for s in false_stakes
|
||||
],
|
||||
"earliest_expiry": min((s["expires_at"] for s in active), default=0),
|
||||
}
|
||||
|
||||
# ─── Oracle Profile ───────────────────────────────────────────────
|
||||
|
||||
def get_oracle_profile(self, node_id: str) -> dict:
|
||||
"""Full oracle profile — rep, prediction history, active stakes."""
|
||||
total_rep = self.get_total_oracle_rep(node_id)
|
||||
available_rep = self.get_oracle_rep(node_id)
|
||||
|
||||
# Prediction stats
|
||||
my_predictions = [p for p in self.prediction_log if p["node_id"] == node_id]
|
||||
wins = [p for p in my_predictions if p["rep_earned"] > 0]
|
||||
losses = [p for p in my_predictions if p["rep_earned"] == 0]
|
||||
|
||||
# Active stakes
|
||||
active_stakes = [
|
||||
{
|
||||
"message_id": s["message_id"],
|
||||
"side": s["side"],
|
||||
"amount": s["amount"],
|
||||
"expires": s["expires_at"],
|
||||
}
|
||||
for s in self.stakes
|
||||
if s["staker_id"] == node_id and not s.get("resolved", False)
|
||||
]
|
||||
|
||||
# Recent prediction log (last 20)
|
||||
recent = sorted(my_predictions, key=lambda x: x.get("resolved_at", 0), reverse=True)[:20]
|
||||
prediction_history = [
|
||||
{
|
||||
"market": p["market_title"][:50],
|
||||
"side": p["side"],
|
||||
"probability": p["probability_at_bet"],
|
||||
"outcome": p.get("outcome", "?"),
|
||||
"rep_earned": p["rep_earned"],
|
||||
"correct": p["rep_earned"] > 0,
|
||||
"age": f"{int((time.time() - p.get('resolved_at', p['timestamp'])) / 86400)}d ago",
|
||||
}
|
||||
for p in recent
|
||||
]
|
||||
|
||||
# Farming score — what % of bets were on >80% probability outcomes
|
||||
if my_predictions:
|
||||
easy_bets = sum(
|
||||
1
|
||||
for p in my_predictions
|
||||
if (p["side"] == "yes" and p["probability_at_bet"] > 80)
|
||||
or (p["side"] == "no" and p["probability_at_bet"] < 20)
|
||||
)
|
||||
farming_pct = round(easy_bets / len(my_predictions) * 100)
|
||||
else:
|
||||
farming_pct = 0
|
||||
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"oracle_rep": available_rep,
|
||||
"oracle_rep_total": total_rep,
|
||||
"oracle_rep_locked": round(total_rep - available_rep, 3),
|
||||
"predictions_won": len(wins),
|
||||
"predictions_lost": len(losses),
|
||||
"win_rate": round(len(wins) / max(1, len(wins) + len(losses)) * 100),
|
||||
"farming_pct": farming_pct,
|
||||
"active_stakes": active_stakes,
|
||||
"prediction_history": prediction_history,
|
||||
}
|
||||
|
||||
def get_active_predictions(self, node_id: str) -> list[dict]:
|
||||
"""Get a node's unresolved predictions (free picks + staked)."""
|
||||
results = []
|
||||
now = time.time()
|
||||
|
||||
# Free picks
|
||||
for p in self.predictions:
|
||||
if p["node_id"] != node_id or p.get("resolved", False):
|
||||
continue
|
||||
potential = round(1.0 - p["probability_at_bet"] / 100, 3)
|
||||
days = int((now - p["timestamp"]) / 86400)
|
||||
results.append(
|
||||
{
|
||||
"prediction_id": p["prediction_id"],
|
||||
"market_title": p["market_title"],
|
||||
"side": p["side"],
|
||||
"probability_at_bet": p["probability_at_bet"],
|
||||
"potential_rep": potential,
|
||||
"staked": 0,
|
||||
"mode": "free",
|
||||
"placed": f"{days}d ago",
|
||||
}
|
||||
)
|
||||
|
||||
# Market stakes
|
||||
for s in self.market_stakes:
|
||||
if s["node_id"] != node_id or s.get("resolved", False):
|
||||
continue
|
||||
days = int((now - s["timestamp"]) / 86400)
|
||||
results.append(
|
||||
{
|
||||
"prediction_id": s["stake_id"],
|
||||
"market_title": s["market_title"],
|
||||
"side": s["side"],
|
||||
"probability_at_bet": s["probability_at_bet"],
|
||||
"potential_rep": 0, # Depends on loser pool — unknown until resolution
|
||||
"staked": s["amount"],
|
||||
"mode": "staked",
|
||||
"placed": f"{days}d ago",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# ─── Cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
def cleanup_old_data(self):
|
||||
"""Remove resolved predictions and market stakes older than decay window."""
|
||||
cutoff = time.time() - (ORACLE_DECAY_DAYS * 86400)
|
||||
before_pred = len(self.predictions)
|
||||
before_stakes = len(self.market_stakes)
|
||||
self.predictions = [
|
||||
p for p in self.predictions if not p.get("resolved", False) or p["timestamp"] >= cutoff
|
||||
]
|
||||
self.market_stakes = [
|
||||
s
|
||||
for s in self.market_stakes
|
||||
if not s.get("resolved", False) or s["timestamp"] >= cutoff
|
||||
]
|
||||
# Trim prediction log
|
||||
self.prediction_log = [
|
||||
p for p in self.prediction_log if p.get("resolved_at", p["timestamp"]) >= cutoff
|
||||
]
|
||||
removed = (before_pred - len(self.predictions)) + (before_stakes - len(self.market_stakes))
|
||||
if removed:
|
||||
self._save()
|
||||
logger.info(f"Cleaned up {removed} old predictions/stakes")
|
||||
|
||||
|
||||
# ─── Module-level singleton ──────────────────────────────────────────────
|
||||
|
||||
oracle_ledger = OracleLedger()
|
||||
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_PEER_STORE_PATH = DATA_DIR / "peer_store.json"
|
||||
PEER_STORE_VERSION = 1
|
||||
ALLOWED_PEER_BUCKETS = {"bootstrap", "sync", "push"}
|
||||
ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime"}
|
||||
ALLOWED_PEER_TRANSPORTS = {"clearnet", "onion"}
|
||||
ALLOWED_PEER_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
class PeerStoreError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _atomic_write_text(target: Path, content: str) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(target.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, str(target))
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerRecord:
|
||||
bucket: str
|
||||
source: str
|
||||
peer_url: str
|
||||
transport: str
|
||||
role: str
|
||||
label: str = ""
|
||||
signer_id: str = ""
|
||||
enabled: bool = True
|
||||
added_at: int = 0
|
||||
updated_at: int = 0
|
||||
last_seen_at: int = 0
|
||||
last_sync_ok_at: int = 0
|
||||
last_push_ok_at: int = 0
|
||||
last_error: str = ""
|
||||
failure_count: int = 0
|
||||
cooldown_until: int = 0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def record_key(self) -> str:
|
||||
return f"{self.bucket}:{self.peer_url}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _normalize_peer_record(data: dict[str, Any]) -> PeerRecord:
|
||||
bucket = str(data.get("bucket", "") or "").strip().lower()
|
||||
source = str(data.get("source", "") or "").strip().lower()
|
||||
peer_url = str(data.get("peer_url", "") or "").strip()
|
||||
transport = str(data.get("transport", "") or "").strip().lower()
|
||||
role = str(data.get("role", "") or "").strip().lower()
|
||||
label = str(data.get("label", "") or "").strip()
|
||||
signer_id = str(data.get("signer_id", "") or "").strip()
|
||||
enabled = bool(data.get("enabled", True))
|
||||
metadata = data.get("metadata", {})
|
||||
|
||||
if bucket not in ALLOWED_PEER_BUCKETS:
|
||||
raise PeerStoreError(f"unsupported peer bucket: {bucket or 'missing'}")
|
||||
if source not in ALLOWED_PEER_SOURCES:
|
||||
raise PeerStoreError(f"unsupported peer source: {source or 'missing'}")
|
||||
if transport not in ALLOWED_PEER_TRANSPORTS:
|
||||
raise PeerStoreError(f"unsupported peer transport: {transport or 'missing'}")
|
||||
if role not in ALLOWED_PEER_ROLES:
|
||||
raise PeerStoreError(f"unsupported peer role: {role or 'missing'}")
|
||||
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized or normalized != peer_url:
|
||||
raise PeerStoreError("peer_url must be normalized")
|
||||
parsed = urlparse(normalized)
|
||||
hostname = str(parsed.hostname or "").strip().lower()
|
||||
if transport == "clearnet":
|
||||
if parsed.scheme not in ("https", "http") or hostname.endswith(".onion"):
|
||||
raise PeerStoreError("clearnet peers must use https:// (or http:// for LAN/testnet)")
|
||||
elif transport == "onion":
|
||||
if parsed.scheme != "http" or not hostname.endswith(".onion"):
|
||||
raise PeerStoreError("onion peers must use http://*.onion")
|
||||
|
||||
if not isinstance(metadata, dict):
|
||||
raise PeerStoreError("peer metadata must be an object")
|
||||
|
||||
return PeerRecord(
|
||||
bucket=bucket,
|
||||
source=source,
|
||||
peer_url=normalized,
|
||||
transport=transport,
|
||||
role=role,
|
||||
label=label,
|
||||
signer_id=signer_id,
|
||||
enabled=enabled,
|
||||
added_at=int(data.get("added_at", 0) or 0),
|
||||
updated_at=int(data.get("updated_at", 0) or 0),
|
||||
last_seen_at=int(data.get("last_seen_at", 0) or 0),
|
||||
last_sync_ok_at=int(data.get("last_sync_ok_at", 0) or 0),
|
||||
last_push_ok_at=int(data.get("last_push_ok_at", 0) or 0),
|
||||
last_error=str(data.get("last_error", "") or ""),
|
||||
failure_count=int(data.get("failure_count", 0) or 0),
|
||||
cooldown_until=int(data.get("cooldown_until", 0) or 0),
|
||||
metadata=dict(metadata),
|
||||
)
|
||||
|
||||
|
||||
def make_bootstrap_peer_record(
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str,
|
||||
signer_id: str,
|
||||
label: str = "",
|
||||
now: float | None = None,
|
||||
) -> PeerRecord:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
return _normalize_peer_record(
|
||||
{
|
||||
"bucket": "bootstrap",
|
||||
"source": "bundle",
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"signer_id": signer_id,
|
||||
"enabled": True,
|
||||
"added_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_sync_peer_record(
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str = "participant",
|
||||
source: str = "operator",
|
||||
label: str = "",
|
||||
signer_id: str = "",
|
||||
now: float | None = None,
|
||||
) -> PeerRecord:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
return _normalize_peer_record(
|
||||
{
|
||||
"bucket": "sync",
|
||||
"source": source,
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"signer_id": signer_id,
|
||||
"enabled": True,
|
||||
"added_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_push_peer_record(
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str = "relay",
|
||||
source: str = "operator",
|
||||
label: str = "",
|
||||
now: float | None = None,
|
||||
) -> PeerRecord:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
return _normalize_peer_record(
|
||||
{
|
||||
"bucket": "push",
|
||||
"source": source,
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"enabled": True,
|
||||
"added_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PeerStore:
|
||||
def __init__(self, path: str | Path = DEFAULT_PEER_STORE_PATH):
|
||||
self.path = Path(path)
|
||||
self._records: dict[str, PeerRecord] = {}
|
||||
|
||||
def load(self) -> list[PeerRecord]:
|
||||
if not self.path.exists():
|
||||
self._records = {}
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PeerStoreError("peer store is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise PeerStoreError("peer store root must be an object")
|
||||
version = int(raw.get("version", 0) or 0)
|
||||
if version != PEER_STORE_VERSION:
|
||||
raise PeerStoreError(f"unsupported peer store version: {version}")
|
||||
records_raw = raw.get("records", [])
|
||||
if not isinstance(records_raw, list):
|
||||
raise PeerStoreError("peer store records must be a list")
|
||||
|
||||
records: dict[str, PeerRecord] = {}
|
||||
for entry in records_raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise PeerStoreError("peer store records must be objects")
|
||||
record = _normalize_peer_record(entry)
|
||||
records[record.record_key()] = record
|
||||
self._records = records
|
||||
return self.records()
|
||||
|
||||
def save(self) -> None:
|
||||
payload = {
|
||||
"version": PEER_STORE_VERSION,
|
||||
"records": [record.to_dict() for record in self.records()],
|
||||
}
|
||||
_atomic_write_text(
|
||||
self.path,
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False),
|
||||
)
|
||||
|
||||
def records(self) -> list[PeerRecord]:
|
||||
return sorted(self._records.values(), key=lambda item: (item.bucket, item.peer_url))
|
||||
|
||||
def records_for_bucket(self, bucket: str) -> list[PeerRecord]:
|
||||
normalized_bucket = str(bucket or "").strip().lower()
|
||||
return [record for record in self.records() if record.bucket == normalized_bucket]
|
||||
|
||||
def upsert(self, record: PeerRecord) -> PeerRecord:
|
||||
existing = self._records.get(record.record_key())
|
||||
if existing is None:
|
||||
self._records[record.record_key()] = record
|
||||
return record
|
||||
|
||||
merged = PeerRecord(
|
||||
bucket=record.bucket,
|
||||
source=record.source,
|
||||
peer_url=record.peer_url,
|
||||
transport=record.transport,
|
||||
role=record.role,
|
||||
label=record.label or existing.label,
|
||||
signer_id=record.signer_id or existing.signer_id,
|
||||
enabled=record.enabled,
|
||||
added_at=existing.added_at or record.added_at,
|
||||
updated_at=max(existing.updated_at, record.updated_at),
|
||||
last_seen_at=max(existing.last_seen_at, record.last_seen_at),
|
||||
last_sync_ok_at=max(existing.last_sync_ok_at, record.last_sync_ok_at),
|
||||
last_push_ok_at=max(existing.last_push_ok_at, record.last_push_ok_at),
|
||||
last_error=record.last_error or existing.last_error,
|
||||
failure_count=max(existing.failure_count, record.failure_count),
|
||||
cooldown_until=max(existing.cooldown_until, record.cooldown_until),
|
||||
metadata={**existing.metadata, **record.metadata},
|
||||
)
|
||||
self._records[record.record_key()] = merged
|
||||
return merged
|
||||
|
||||
def mark_seen(self, peer_url: str, bucket: str, *, now: float | None = None) -> PeerRecord:
|
||||
record = self._require_record(peer_url, bucket)
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
updated = PeerRecord(
|
||||
**{
|
||||
**record.to_dict(),
|
||||
"last_seen_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
self._records[updated.record_key()] = updated
|
||||
return updated
|
||||
|
||||
def mark_sync_success(self, peer_url: str, bucket: str = "sync", *, now: float | None = None) -> PeerRecord:
|
||||
record = self._require_record(peer_url, bucket)
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
updated = PeerRecord(
|
||||
**{
|
||||
**record.to_dict(),
|
||||
"last_sync_ok_at": timestamp,
|
||||
"last_error": "",
|
||||
"failure_count": 0,
|
||||
"cooldown_until": 0,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
self._records[updated.record_key()] = updated
|
||||
return updated
|
||||
|
||||
def mark_push_success(self, peer_url: str, bucket: str = "push", *, now: float | None = None) -> PeerRecord:
|
||||
record = self._require_record(peer_url, bucket)
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
updated = PeerRecord(
|
||||
**{
|
||||
**record.to_dict(),
|
||||
"last_push_ok_at": timestamp,
|
||||
"last_error": "",
|
||||
"failure_count": 0,
|
||||
"cooldown_until": 0,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
self._records[updated.record_key()] = updated
|
||||
return updated
|
||||
|
||||
def mark_failure(
|
||||
self,
|
||||
peer_url: str,
|
||||
bucket: str,
|
||||
*,
|
||||
error: str,
|
||||
cooldown_s: int = 0,
|
||||
now: float | None = None,
|
||||
) -> PeerRecord:
|
||||
record = self._require_record(peer_url, bucket)
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
updated = PeerRecord(
|
||||
**{
|
||||
**record.to_dict(),
|
||||
"last_error": str(error or "").strip(),
|
||||
"failure_count": int(record.failure_count) + 1,
|
||||
"cooldown_until": timestamp + max(0, int(cooldown_s or 0)),
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
self._records[updated.record_key()] = updated
|
||||
return updated
|
||||
|
||||
def _require_record(self, peer_url: str, bucket: str) -> PeerRecord:
|
||||
normalized_url = normalize_peer_url(peer_url)
|
||||
key = f"{str(bucket or '').strip().lower()}:{normalized_url}"
|
||||
if key not in self._records:
|
||||
raise PeerStoreError(f"peer record not found: {key}")
|
||||
return self._records[key]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Helpers for privacy-aware operational logging.
|
||||
|
||||
These helpers keep logs useful for debugging classes of failures without
|
||||
recording stable private-plane identifiers verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def privacy_log_label(value: str, *, label: str = "") -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return f"{label}#none" if label else ""
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
if not label:
|
||||
return digest
|
||||
return f"{label}#{digest}"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Mesh protocol helpers for canonical payloads and versioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
PROTOCOL_VERSION = "infonet/2"
|
||||
NETWORK_ID = "sb-testnet-0"
|
||||
|
||||
|
||||
def _safe_int(val, default=0):
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_number(value: Any) -> int | float:
|
||||
try:
|
||||
num = float(value)
|
||||
except Exception:
|
||||
return 0
|
||||
if num.is_integer():
|
||||
return int(num)
|
||||
return num
|
||||
|
||||
|
||||
def normalize_message_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = {
|
||||
"message": str(payload.get("message", "")),
|
||||
"destination": str(payload.get("destination", "")),
|
||||
"channel": str(payload.get("channel", "LongFast")),
|
||||
"priority": str(payload.get("priority", "normal")),
|
||||
"ephemeral": bool(payload.get("ephemeral", False)),
|
||||
}
|
||||
transport_lock = str(payload.get("transport_lock", "") or "").strip().lower()
|
||||
if transport_lock:
|
||||
normalized["transport_lock"] = transport_lock
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_gate_message_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = {
|
||||
"gate": str(payload.get("gate", "")).strip().lower(),
|
||||
"ciphertext": str(payload.get("ciphertext", "")),
|
||||
"nonce": str(payload.get("nonce", payload.get("iv", ""))),
|
||||
"sender_ref": str(payload.get("sender_ref", "")),
|
||||
"format": str(payload.get("format", "mls1") or "mls1").strip().lower(),
|
||||
}
|
||||
epoch = _safe_int(payload.get("epoch", 0), 0)
|
||||
if epoch > 0:
|
||||
normalized["epoch"] = epoch
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_vote_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
vote_val = _safe_int(payload.get("vote", 0), 0)
|
||||
return {
|
||||
"target_id": str(payload.get("target_id", "")),
|
||||
"vote": vote_val,
|
||||
"gate": str(payload.get("gate", "")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_gate_create_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
rules = payload.get("rules", {})
|
||||
if not isinstance(rules, dict):
|
||||
rules = {}
|
||||
return {
|
||||
"gate_id": str(payload.get("gate_id", "")).lower(),
|
||||
"display_name": str(payload.get("display_name", ""))[:64],
|
||||
"rules": rules,
|
||||
}
|
||||
|
||||
|
||||
def normalize_prediction_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"market_title": str(payload.get("market_title", "")),
|
||||
"side": str(payload.get("side", "")),
|
||||
"stake_amount": _normalize_number(payload.get("stake_amount", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def normalize_stake_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"message_id": str(payload.get("message_id", "")),
|
||||
"poster_id": str(payload.get("poster_id", "")),
|
||||
"side": str(payload.get("side", "")),
|
||||
"amount": _normalize_number(payload.get("amount", 0.0)),
|
||||
"duration_days": _safe_int(payload.get("duration_days", 0), 0),
|
||||
}
|
||||
|
||||
|
||||
def normalize_dm_key_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"dh_pub_key": str(payload.get("dh_pub_key", "")),
|
||||
"dh_algo": str(payload.get("dh_algo", "")),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
}
|
||||
|
||||
|
||||
def normalize_dm_message_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = {
|
||||
"recipient_id": str(payload.get("recipient_id", "")),
|
||||
"delivery_class": str(payload.get("delivery_class", "")).lower(),
|
||||
"recipient_token": str(payload.get("recipient_token", "")),
|
||||
"ciphertext": str(payload.get("ciphertext", "")),
|
||||
"msg_id": str(payload.get("msg_id", "")),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
"format": str(payload.get("format", "dm1") or "dm1").strip().lower(),
|
||||
}
|
||||
session_welcome = payload.get("session_welcome")
|
||||
if session_welcome:
|
||||
normalized["session_welcome"] = str(session_welcome)
|
||||
sender_seal = str(payload.get("sender_seal", "") or "")
|
||||
if sender_seal:
|
||||
normalized["sender_seal"] = sender_seal
|
||||
relay_salt = str(payload.get("relay_salt", "") or "").strip().lower()
|
||||
if relay_salt:
|
||||
normalized["relay_salt"] = relay_salt
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_dm_message_payload_legacy(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"recipient_id": str(payload.get("recipient_id", "")),
|
||||
"delivery_class": str(payload.get("delivery_class", "")).lower(),
|
||||
"recipient_token": str(payload.get("recipient_token", "")),
|
||||
"ciphertext": str(payload.get("ciphertext", "")),
|
||||
"msg_id": str(payload.get("msg_id", "")),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
}
|
||||
|
||||
|
||||
def normalize_mailbox_claims(payload: dict[str, Any]) -> list[dict[str, str]]:
|
||||
claims = payload.get("mailbox_claims", [])
|
||||
if not isinstance(claims, list):
|
||||
return []
|
||||
normalized: list[dict[str, str]] = []
|
||||
for claim in claims:
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"type": str(claim.get("type", "")).lower(),
|
||||
"token": str(claim.get("token", "")),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_dm_poll_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"mailbox_claims": normalize_mailbox_claims(payload),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
"nonce": str(payload.get("nonce", "")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_dm_count_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return normalize_dm_poll_payload(payload)
|
||||
|
||||
|
||||
def normalize_dm_block_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"blocked_id": str(payload.get("blocked_id", "")),
|
||||
"action": str(payload.get("action", "block")).lower(),
|
||||
}
|
||||
|
||||
|
||||
def normalize_dm_key_witness_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": str(payload.get("target_id", "")),
|
||||
"dh_pub_key": str(payload.get("dh_pub_key", "")),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
}
|
||||
|
||||
|
||||
def normalize_trust_vouch_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": str(payload.get("target_id", "")),
|
||||
"note": str(payload.get("note", ""))[:140],
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
}
|
||||
|
||||
|
||||
def normalize_key_rotate_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"old_node_id": str(payload.get("old_node_id", "")),
|
||||
"old_public_key": str(payload.get("old_public_key", "")),
|
||||
"old_public_key_algo": str(payload.get("old_public_key_algo", "")),
|
||||
"new_public_key": str(payload.get("new_public_key", "")),
|
||||
"new_public_key_algo": str(payload.get("new_public_key_algo", "")),
|
||||
"timestamp": _safe_int(payload.get("timestamp", 0), 0),
|
||||
"old_signature": str(payload.get("old_signature", "")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_key_revoke_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"revoked_public_key": str(payload.get("revoked_public_key", "")),
|
||||
"revoked_public_key_algo": str(payload.get("revoked_public_key_algo", "")),
|
||||
"revoked_at": _safe_int(payload.get("revoked_at", 0), 0),
|
||||
"grace_until": _safe_int(payload.get("grace_until", 0), 0),
|
||||
"reason": str(payload.get("reason", ""))[:140],
|
||||
}
|
||||
|
||||
|
||||
def normalize_abuse_report_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": str(payload.get("target_id", "")),
|
||||
"reason": str(payload.get("reason", ""))[:280],
|
||||
"gate": str(payload.get("gate", "")),
|
||||
"evidence": str(payload.get("evidence", ""))[:256],
|
||||
}
|
||||
|
||||
|
||||
def normalize_payload(event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if event_type == "message":
|
||||
return normalize_message_payload(payload)
|
||||
if event_type == "gate_message":
|
||||
return normalize_gate_message_payload(payload)
|
||||
if event_type == "vote":
|
||||
return normalize_vote_payload(payload)
|
||||
if event_type == "gate_create":
|
||||
return normalize_gate_create_payload(payload)
|
||||
if event_type == "prediction":
|
||||
return normalize_prediction_payload(payload)
|
||||
if event_type == "stake":
|
||||
return normalize_stake_payload(payload)
|
||||
if event_type == "dm_key":
|
||||
return normalize_dm_key_payload(payload)
|
||||
if event_type == "dm_message":
|
||||
return normalize_dm_message_payload(payload)
|
||||
if event_type == "dm_poll":
|
||||
return normalize_dm_poll_payload(payload)
|
||||
if event_type == "dm_count":
|
||||
return normalize_dm_count_payload(payload)
|
||||
if event_type == "dm_block":
|
||||
return normalize_dm_block_payload(payload)
|
||||
if event_type == "dm_key_witness":
|
||||
return normalize_dm_key_witness_payload(payload)
|
||||
if event_type == "trust_vouch":
|
||||
return normalize_trust_vouch_payload(payload)
|
||||
if event_type == "key_rotate":
|
||||
return normalize_key_rotate_payload(payload)
|
||||
if event_type == "key_revoke":
|
||||
return normalize_key_revoke_payload(payload)
|
||||
if event_type == "abuse_report":
|
||||
return normalize_abuse_report_payload(payload)
|
||||
return payload
|
||||
@@ -0,0 +1,985 @@
|
||||
"""Mesh Reputation Ledger — decentralized node trust scoring with gates.
|
||||
|
||||
Every node maintains a local reputation ledger. Votes are weighted by voter
|
||||
reputation and tenure (anti-Sybil). Scores decay linearly over a 2-year window.
|
||||
|
||||
Gates are reputation-scoped communities. Entry requires meeting rep thresholds.
|
||||
Getting downvoted below the threshold bars you automatically — no moderator needed.
|
||||
|
||||
Persistence: JSON files in backend/data/ (auto-saved on change, loaded on start).
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import atexit
|
||||
import hmac
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from services.mesh.mesh_privacy_logging import privacy_log_label
|
||||
from services.mesh.mesh_secure_storage import (
|
||||
read_domain_json,
|
||||
read_secure_json,
|
||||
write_domain_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("services.mesh_reputation")
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
LEDGER_FILE = DATA_DIR / "reputation_ledger.json"
|
||||
GATES_FILE = DATA_DIR / "gates.json"
|
||||
LEDGER_DOMAIN = "reputation"
|
||||
GATES_DOMAIN = "gates"
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
VOTE_DECAY_MONTHS = 24 # Votes decay over 24 months. Recent votes weigh heaviest.
|
||||
VOTE_DECAY_DAYS = VOTE_DECAY_MONTHS * 30 # ~720 days total window
|
||||
MIN_REP_TO_VOTE = 3 # Minimum reputation to cast any vote (only enforced after bootstrap)
|
||||
BOOTSTRAP_THRESHOLD = 1000 # Rep-to-vote rule kicks in after this many nodes join
|
||||
MIN_REP_TO_CREATE_GATE = 10 # Minimum overall rep to create a gate
|
||||
GATE_RATIFICATION_REP = (
|
||||
50 # Cumulative member rep needed for a gate to be ratified (after bootstrap)
|
||||
)
|
||||
ALLOW_DYNAMIC_GATES = False
|
||||
_VOTE_STORAGE_SALT_CACHE: bytes | None = None
|
||||
_VOTE_STORAGE_SALT_WARNING_EMITTED = False
|
||||
|
||||
DEFAULT_PRIVATE_GATES: dict[str, dict] = {
|
||||
"infonet": {
|
||||
"display_name": "Main Infonet",
|
||||
"description": "Private network operations floor. Core testnet traffic, protocol notes, and live coordination stay here.",
|
||||
"welcome": "WELCOME TO MAIN INFONET. Treat this as the protocol floor, not a public lobby.",
|
||||
"sort_order": 10,
|
||||
},
|
||||
"general-talk": {
|
||||
"display_name": "General Talk",
|
||||
"description": "Lower-friction private lounge for day-to-day chatter, intros, and community pulse checks.",
|
||||
"welcome": "WELCOME TO GENERAL TALK. Keep it human, but remember the lane is still private and reputation-backed.",
|
||||
"sort_order": 20,
|
||||
},
|
||||
"gathered-intel": {
|
||||
"display_name": "Gathered Intel",
|
||||
"description": "Drop sourced observations, OSINT fragments, and operator notes worth preserving for later review.",
|
||||
"welcome": "WELCOME TO GATHERED INTEL. Bring sources, timestamps, and enough context for someone else to verify you.",
|
||||
"sort_order": 30,
|
||||
},
|
||||
"tracked-planes": {
|
||||
"display_name": "Tracked Planes",
|
||||
"description": "Aviation watchers, route anomalies, military traffic, and callout chatter for flights worth tracking.",
|
||||
"welcome": "WELCOME TO TRACKED PLANES. Call out the flight, route, why it matters, and what pattern you think you see.",
|
||||
"sort_order": 40,
|
||||
},
|
||||
"ukraine-front": {
|
||||
"display_name": "Ukraine Front",
|
||||
"description": "Focused room for Ukraine war developments, map observations, and source cross-checking.",
|
||||
"welcome": "WELCOME TO UKRAINE FRONT. Keep reporting tight, sourced, and separated from wishcasting.",
|
||||
"sort_order": 50,
|
||||
},
|
||||
"iran-front": {
|
||||
"display_name": "Iran Front",
|
||||
"description": "Iran flashpoint monitoring, regional spillover, and escalation watch from a private-lane perspective.",
|
||||
"welcome": "WELCOME TO IRAN FRONT. Track escalation, proxies, logistics, and what changes the risk picture.",
|
||||
"sort_order": 60,
|
||||
},
|
||||
"world-news": {
|
||||
"display_name": "World News",
|
||||
"description": "Big-picture geopolitical developments, breaking stories, and broader context outside the narrow fronts.",
|
||||
"welcome": "WELCOME TO WORLD NEWS. Use this room when the story matters but does not fit a narrower gate.",
|
||||
"sort_order": 70,
|
||||
},
|
||||
"prediction-markets": {
|
||||
"display_name": "Prediction Markets",
|
||||
"description": "Discuss market signals, event contracts, and whether crowd pricing is tracking reality or pure narrative.",
|
||||
"welcome": "WELCOME TO PREDICTION MARKETS. Bring the market angle and the narrative angle, then compare them honestly.",
|
||||
"sort_order": 80,
|
||||
},
|
||||
"finance": {
|
||||
"display_name": "Finance",
|
||||
"description": "Macro moves, defense names, rates, liquidity stress, and the parts of finance that steer the rest of the board.",
|
||||
"welcome": "WELCOME TO FINANCE. Macro, defense names, liquidity stress, and market structure all belong here.",
|
||||
"sort_order": 90,
|
||||
},
|
||||
"cryptography": {
|
||||
"display_name": "Cryptography",
|
||||
"description": "Protocol design, primitives, breakage reports, and the sharper math behind the network.",
|
||||
"welcome": "WELCOME TO CRYPTOGRAPHY. If you think something can be broken, this is where you try to prove it.",
|
||||
"sort_order": 100,
|
||||
},
|
||||
"cryptocurrencies": {
|
||||
"display_name": "Cryptocurrencies",
|
||||
"description": "Chain activity, privacy coin chatter, market structure, and crypto-adjacent threat intel.",
|
||||
"welcome": "WELCOME TO CRYPTOCURRENCIES. Chain behavior, privacy tooling, and market weirdness all go on the table.",
|
||||
"sort_order": 110,
|
||||
},
|
||||
"meet-chat": {
|
||||
"display_name": "Meet Chat",
|
||||
"description": "Casual private hangout for getting to know the other operators behind the personas.",
|
||||
"welcome": "WELCOME TO MEET CHAT. Lighten up a little and let the community feel like it has actual people in it.",
|
||||
"sort_order": 120,
|
||||
},
|
||||
"opsec-lab": {
|
||||
"display_name": "OPSEC Lab",
|
||||
"description": "Stress-test assumptions, try to break rep or persona boundaries, and document privacy failures without mercy.",
|
||||
"welcome": "WELCOME TO OPSEC LAB. Be ruthless, document the leak, and assume everyone is smarter than the last audit.",
|
||||
"sort_order": 130,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _blind_voter(voter_id: str, salt: bytes) -> str:
|
||||
if not voter_id:
|
||||
return ""
|
||||
digest = hmac.new(salt, voter_id.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return f"{digest[:8]}…"
|
||||
|
||||
|
||||
def _vote_storage_salt() -> bytes:
|
||||
global _VOTE_STORAGE_SALT_CACHE, _VOTE_STORAGE_SALT_WARNING_EMITTED
|
||||
if _VOTE_STORAGE_SALT_CACHE is not None:
|
||||
return _VOTE_STORAGE_SALT_CACHE
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip()
|
||||
except Exception:
|
||||
secret = ""
|
||||
if not secret and not _VOTE_STORAGE_SALT_WARNING_EMITTED:
|
||||
logger.warning("MESH_PEER_PUSH_SECRET missing; falling back to local voter blinding salt")
|
||||
_VOTE_STORAGE_SALT_WARNING_EMITTED = True
|
||||
if secret:
|
||||
_VOTE_STORAGE_SALT_CACHE = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
b"shadowbroker|reputation|voter-blind|v1",
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
else:
|
||||
# Persist a stable salt to disk so blinded voter IDs survive restarts.
|
||||
# Without this, duplicate-vote detection breaks on every restart
|
||||
# because the blinded ID changes with a new random salt.
|
||||
salt_path = DATA_DIR / "voter_blind_salt.bin"
|
||||
try:
|
||||
if salt_path.exists() and salt_path.stat().st_size == 32:
|
||||
_VOTE_STORAGE_SALT_CACHE = salt_path.read_bytes()
|
||||
else:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
new_salt = os.urandom(32)
|
||||
salt_path.write_bytes(new_salt)
|
||||
_VOTE_STORAGE_SALT_CACHE = new_salt
|
||||
logger.info("Generated new persistent voter blinding salt")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist voter salt, falling back to random: {e}")
|
||||
_VOTE_STORAGE_SALT_CACHE = os.urandom(32)
|
||||
return _VOTE_STORAGE_SALT_CACHE
|
||||
|
||||
|
||||
def _stored_voter_id(vote: dict) -> str:
|
||||
blinded = str(vote.get("blinded_voter_id", "") or "").strip()
|
||||
if blinded:
|
||||
return blinded
|
||||
raw = str(vote.get("voter_id", "") or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return _blind_voter(raw, _vote_storage_salt())
|
||||
|
||||
|
||||
def _serialize_vote_record(vote: dict) -> dict:
|
||||
blinded = _stored_voter_id(vote)
|
||||
payload = dict(vote or {})
|
||||
payload.pop("voter_id", None)
|
||||
if blinded:
|
||||
payload["blinded_voter_id"] = blinded
|
||||
return payload
|
||||
|
||||
|
||||
# ─── Vote Record ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReputationLedger:
|
||||
"""Local reputation ledger — each node maintains its own view.
|
||||
|
||||
Storage format:
|
||||
nodes: {node_id: {first_seen, public_key, agent}}
|
||||
votes: [{voter_id, target_id, vote (+1/-1), gate (optional), timestamp}]
|
||||
scores_cache: {node_id: {overall: int, gates: {gate_id: int}}}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: dict[str, dict] = {} # {node_id: {first_seen, public_key, agent}}
|
||||
self.votes: list[dict] = [] # [{voter_id, target_id, vote, gate, timestamp}]
|
||||
self.vouches: list[dict] = [] # [{voucher_id, target_id, note, timestamp}]
|
||||
self.aliases: dict[str, str] = {} # {new_node_id: old_node_id}
|
||||
self._scores_dirty = True
|
||||
self._scores_cache: dict[str, dict] = {}
|
||||
self._dirty = False
|
||||
self._save_lock = threading.Lock()
|
||||
self._save_timer: threading.Timer | None = None
|
||||
self._SAVE_INTERVAL = 5.0
|
||||
atexit.register(self._flush)
|
||||
self._load()
|
||||
|
||||
# ─── Persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
"""Load ledger from disk."""
|
||||
domain_path = DATA_DIR / LEDGER_DOMAIN / LEDGER_FILE.name
|
||||
if not domain_path.exists() and LEDGER_FILE.exists():
|
||||
try:
|
||||
legacy = read_secure_json(
|
||||
LEDGER_FILE,
|
||||
lambda: {"nodes": {}, "votes": [], "vouches": [], "aliases": {}},
|
||||
)
|
||||
write_domain_json(LEDGER_DOMAIN, LEDGER_FILE.name, legacy)
|
||||
LEDGER_FILE.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate reputation ledger: {e}")
|
||||
try:
|
||||
data = read_domain_json(
|
||||
LEDGER_DOMAIN,
|
||||
LEDGER_FILE.name,
|
||||
lambda: {"nodes": {}, "votes": [], "vouches": [], "aliases": {}},
|
||||
)
|
||||
self.nodes = data.get("nodes", {})
|
||||
raw_votes = data.get("votes", [])
|
||||
# Purge legacy __system__ cost votes — they stored raw voter
|
||||
# identities as target_id, which is a privacy leak.
|
||||
before = len(raw_votes)
|
||||
self.votes = [v for v in raw_votes if not v.get("system_cost")]
|
||||
purged = before - len(self.votes)
|
||||
if purged:
|
||||
self._dirty = True # re-save without the leaked records
|
||||
logger.info(f"Purged {purged} legacy system_cost vote(s) with raw identity leak")
|
||||
self.vouches = data.get("vouches", [])
|
||||
self.aliases = data.get("aliases", {})
|
||||
self._scores_dirty = True
|
||||
logger.info(
|
||||
f"Loaded reputation ledger: {len(self.nodes)} nodes, {len(self.votes)} votes"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load reputation ledger: {e}")
|
||||
|
||||
def _save(self):
|
||||
"""Mark dirty and schedule a coalesced disk write."""
|
||||
self._dirty = True
|
||||
with self._save_lock:
|
||||
if self._save_timer is None or not self._save_timer.is_alive():
|
||||
self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush)
|
||||
self._save_timer.daemon = True
|
||||
self._save_timer.start()
|
||||
|
||||
def _flush(self):
|
||||
"""Actually write to disk (called by timer or atexit)."""
|
||||
if not self._dirty:
|
||||
return
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"nodes": self.nodes,
|
||||
"votes": [_serialize_vote_record(vote) for vote in self.votes],
|
||||
"vouches": self.vouches,
|
||||
"aliases": self.aliases,
|
||||
}
|
||||
write_domain_json(LEDGER_DOMAIN, LEDGER_FILE.name, data)
|
||||
LEDGER_FILE.unlink(missing_ok=True)
|
||||
self._dirty = False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save reputation ledger: {e}")
|
||||
|
||||
# ─── Node Registration ────────────────────────────────────────────
|
||||
|
||||
def register_node(
|
||||
self, node_id: str, public_key: str = "", public_key_algo: str = "", agent: bool = False
|
||||
):
|
||||
"""Register a node if not already known. Updates public_key if provided."""
|
||||
if node_id not in self.nodes:
|
||||
self.nodes[node_id] = {
|
||||
"first_seen": time.time(),
|
||||
"public_key": public_key,
|
||||
"public_key_algo": public_key_algo,
|
||||
"agent": agent,
|
||||
}
|
||||
self._save()
|
||||
logger.info(
|
||||
"Registered new node: %s",
|
||||
privacy_log_label(node_id, label="node"),
|
||||
)
|
||||
elif public_key and not self.nodes[node_id].get("public_key"):
|
||||
self.nodes[node_id]["public_key"] = public_key
|
||||
if public_key_algo:
|
||||
self.nodes[node_id]["public_key_algo"] = public_key_algo
|
||||
self._save()
|
||||
|
||||
def link_identities(self, old_id: str, new_id: str) -> tuple[bool, str]:
|
||||
"""Link a new node_id to an old one for reputation continuity."""
|
||||
if not old_id or not new_id:
|
||||
return False, "Missing old_id or new_id"
|
||||
if old_id == new_id:
|
||||
return False, "Old and new IDs must differ"
|
||||
if new_id in self.aliases:
|
||||
return False, f"{new_id} is already linked"
|
||||
if old_id in self.aliases:
|
||||
return False, f"{old_id} is already linked to {self.aliases[old_id]}"
|
||||
if old_id in self.aliases.values():
|
||||
return False, f"{old_id} is already the source of a link"
|
||||
|
||||
self.aliases[new_id] = old_id
|
||||
self._scores_dirty = True
|
||||
self._save()
|
||||
logger.info(
|
||||
"Linked identity: %s -> %s",
|
||||
privacy_log_label(old_id, label="node"),
|
||||
privacy_log_label(new_id, label="node"),
|
||||
)
|
||||
return True, "linked"
|
||||
|
||||
def get_node_age_days(self, node_id: str) -> float:
|
||||
"""Get node age in days."""
|
||||
node = self.nodes.get(node_id)
|
||||
if not node:
|
||||
return 0
|
||||
return (time.time() - node.get("first_seen", time.time())) / 86400
|
||||
|
||||
def is_agent(self, node_id: str) -> bool:
|
||||
"""Check if node is registered as an Agent (bot/AI)."""
|
||||
return self.nodes.get(node_id, {}).get("agent", False)
|
||||
|
||||
# ─── Voting ───────────────────────────────────────────────────────
|
||||
|
||||
def _compute_vote_weight(self, voter_id: str) -> float:
|
||||
"""Rep-weighted voting — your vote's power scales with reputation and tenure.
|
||||
|
||||
Two factors combine:
|
||||
rep_factor: log10(1 + |rep|) / log10(101) → 0 rep ≈ 0.0, 100 rep = 1.0
|
||||
tenure_factor: age_days / 720 → new = 0.0, 2yr+ = 1.0
|
||||
|
||||
Combined weight = max(0.1, rep_factor × tenure_factor), capped at 1.0.
|
||||
Floor of 0.1 ensures every user's vote still counts for something.
|
||||
|
||||
| Rep | Day 1 | 6 months | 1 year | 2 years |
|
||||
|------|-------|----------|--------|---------|
|
||||
| 0 | 0.10 | 0.10 | 0.10 | 0.10 |
|
||||
| 5 | 0.10 | 0.10 | 0.20 | 0.39 |
|
||||
| 25 | 0.10 | 0.18 | 0.35 | 0.70 |
|
||||
| 50 | 0.10 | 0.21 | 0.42 | 0.85 |
|
||||
| 100+ | 0.10 | 0.25 | 0.50 | 1.00 |
|
||||
"""
|
||||
# ── Rep factor: logarithmic scale, 100 rep = full power ──
|
||||
rep = self.get_reputation(voter_id).get("overall", 0)
|
||||
rep_factor = math.log10(1 + abs(rep)) / math.log10(101) # 0→0.0, 100→1.0
|
||||
|
||||
# ── Tenure factor: linear over the 2-year decay window ──
|
||||
age_days = self.get_node_age_days(voter_id)
|
||||
decay_window = float(VOTE_DECAY_DAYS) if VOTE_DECAY_DAYS > 0 else 720.0
|
||||
tenure_factor = min(1.0, age_days / decay_window)
|
||||
|
||||
weight = rep_factor * tenure_factor
|
||||
return max(0.1, min(1.0, weight))
|
||||
|
||||
def cast_vote(
|
||||
self, voter_id: str, target_id: str, vote: int, gate: str = ""
|
||||
) -> tuple[bool, str, float]:
|
||||
"""Cast a vote. Returns (success, reason, weight).
|
||||
|
||||
Rules:
|
||||
- Self-votes allowed (costs -1 rep like any vote — net negative for voter)
|
||||
- Must have rep >= MIN_REP_TO_VOTE (except first few bootstrap votes)
|
||||
- One vote per voter per target per gate (can change direction)
|
||||
- Vote value is weighted by voter's reputation and tenure
|
||||
"""
|
||||
if vote not in (1, -1):
|
||||
return False, "Vote must be +1 or -1", 0.0
|
||||
|
||||
# Reputation burn: minimum rep to vote — only enforced after network bootstraps
|
||||
network_size = len(self.nodes)
|
||||
if network_size >= BOOTSTRAP_THRESHOLD:
|
||||
voter_rep = self.get_reputation(voter_id).get("overall", 0)
|
||||
if voter_rep < MIN_REP_TO_VOTE:
|
||||
return (
|
||||
False,
|
||||
f"Need {MIN_REP_TO_VOTE} reputation to vote (you have {voter_rep}). Network has {network_size} nodes — rep-to-vote is active.",
|
||||
0.0,
|
||||
)
|
||||
|
||||
blinded_voter_id = _blind_voter(voter_id, _vote_storage_salt())
|
||||
existing_vote = next(
|
||||
(
|
||||
v
|
||||
for v in self.votes
|
||||
if _stored_voter_id(v) == blinded_voter_id
|
||||
and v["target_id"] == target_id
|
||||
and v.get("gate", "") == gate
|
||||
),
|
||||
None,
|
||||
)
|
||||
existing_vote_value = None
|
||||
if existing_vote is not None:
|
||||
try:
|
||||
existing_vote_value = int(existing_vote.get("vote", 0))
|
||||
except (TypeError, ValueError):
|
||||
existing_vote_value = None
|
||||
if existing_vote and existing_vote_value == vote:
|
||||
direction = "up" if vote == 1 else "down"
|
||||
gate_str = f" in gate '{gate}'" if gate else ""
|
||||
return False, f"Vote already set to {direction} on {target_id}{gate_str}", 0.0
|
||||
|
||||
# Remove existing vote from this voter for this target in this gate
|
||||
self.votes = [
|
||||
v
|
||||
for v in self.votes
|
||||
if not (
|
||||
_stored_voter_id(v) == blinded_voter_id
|
||||
and v["target_id"] == target_id
|
||||
and v.get("gate", "") == gate
|
||||
)
|
||||
]
|
||||
|
||||
# Record the new vote
|
||||
now = time.time()
|
||||
weight = self._compute_vote_weight(voter_id)
|
||||
is_direction_change = existing_vote is not None
|
||||
self.votes.append(
|
||||
{
|
||||
"voter_id": voter_id,
|
||||
"target_id": target_id,
|
||||
"vote": vote,
|
||||
"gate": gate,
|
||||
"timestamp": now,
|
||||
"weight": weight,
|
||||
"agent_verify": self.is_agent(voter_id),
|
||||
}
|
||||
)
|
||||
|
||||
# Vote cost: costs the same as the vote's weight (if your vote only
|
||||
# moves the score by 0.1, you only pay 0.1 — not a flat 1.0).
|
||||
# Only on first vote, not direction changes. The target_id is the
|
||||
# *blinded* voter ID so the root identity never touches disk.
|
||||
if not is_direction_change:
|
||||
self.votes.append(
|
||||
{
|
||||
"voter_id": "__system__",
|
||||
"target_id": blinded_voter_id,
|
||||
"vote": -1,
|
||||
"gate": "",
|
||||
"timestamp": now,
|
||||
"weight": weight,
|
||||
"agent_verify": False,
|
||||
"vote_cost": True,
|
||||
}
|
||||
)
|
||||
|
||||
self._scores_dirty = True
|
||||
self._save()
|
||||
|
||||
direction = "up" if vote == 1 else "down"
|
||||
gate_str = f" in gate '{gate}'" if gate else ""
|
||||
logger.info(
|
||||
"Vote: %s voted %s on %s%s",
|
||||
privacy_log_label(voter_id, label="node"),
|
||||
direction,
|
||||
privacy_log_label(target_id, label="node"),
|
||||
f" in {privacy_log_label(gate, label='gate')}" if gate else "",
|
||||
)
|
||||
return True, f"Voted {direction} on {target_id}{gate_str}", weight
|
||||
|
||||
# ─── Trust Vouches ────────────────────────────────────────────────
|
||||
|
||||
def add_vouch(
|
||||
self, voucher_id: str, target_id: str, note: str = "", timestamp: float | None = None
|
||||
) -> tuple[bool, str]:
|
||||
if not voucher_id or not target_id:
|
||||
return False, "Missing voucher_id or target_id"
|
||||
if voucher_id == target_id:
|
||||
return False, "Cannot vouch for yourself"
|
||||
ts = timestamp if timestamp is not None else time.time()
|
||||
# Deduplicate vouches from same voucher to same target within 30 days
|
||||
cutoff = ts - (30 * 86400)
|
||||
for v in self.vouches:
|
||||
if (
|
||||
v.get("voucher_id") == voucher_id
|
||||
and v.get("target_id") == target_id
|
||||
and float(v.get("timestamp", 0)) >= cutoff
|
||||
):
|
||||
return False, "Duplicate vouch"
|
||||
self.vouches.append(
|
||||
{
|
||||
"voucher_id": voucher_id,
|
||||
"target_id": target_id,
|
||||
"note": str(note)[:140],
|
||||
"timestamp": float(ts),
|
||||
}
|
||||
)
|
||||
self._save()
|
||||
return True, "vouched"
|
||||
|
||||
def get_vouches(self, target_id: str, limit: int = 50) -> list[dict]:
|
||||
if not target_id:
|
||||
return []
|
||||
entries = [v for v in self.vouches if v.get("target_id") == target_id]
|
||||
entries = sorted(entries, key=lambda v: v.get("timestamp", 0), reverse=True)
|
||||
return entries[: max(1, limit)]
|
||||
|
||||
# ─── Score Computation ────────────────────────────────────────────
|
||||
|
||||
def _recompute_scores(self):
|
||||
"""Recompute all scores with time-weighted decay.
|
||||
|
||||
Votes decay linearly over VOTE_DECAY_MONTHS (24 months).
|
||||
A vote cast today has full weight (1.0). A vote cast 12 months ago
|
||||
has ~0.5 weight. A vote older than 24 months has 0 weight and is
|
||||
skipped entirely. This means recent activity always matters more
|
||||
than ancient history, but nothing ever fully disappears until 2 years
|
||||
have passed.
|
||||
"""
|
||||
if not self._scores_dirty:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
decay_seconds = VOTE_DECAY_DAYS * 86400 if VOTE_DECAY_DAYS > 0 else 0
|
||||
scores: dict[str, dict] = {}
|
||||
|
||||
for v in self.votes:
|
||||
age = now - v["timestamp"]
|
||||
|
||||
# If decay is enabled, skip votes older than the window
|
||||
if decay_seconds and age >= decay_seconds:
|
||||
continue
|
||||
|
||||
# Time-decay multiplier: 1.0 for brand new, 0.0 at the boundary.
|
||||
# Recent months weigh heaviest.
|
||||
if decay_seconds:
|
||||
decay_factor = 1.0 - (age / decay_seconds)
|
||||
else:
|
||||
decay_factor = 1.0
|
||||
|
||||
target = v["target_id"]
|
||||
if target not in scores:
|
||||
scores[target] = {"overall": 0.0, "gates": {}, "upvotes": 0, "downvotes": 0}
|
||||
|
||||
weighted = v["vote"] * v.get("weight", 1.0) * decay_factor
|
||||
scores[target]["overall"] += weighted
|
||||
|
||||
if v["vote"] > 0:
|
||||
scores[target]["upvotes"] += 1
|
||||
else:
|
||||
scores[target]["downvotes"] += 1
|
||||
|
||||
gate = v.get("gate", "")
|
||||
if gate:
|
||||
scores[target]["gates"].setdefault(gate, 0.0)
|
||||
scores[target]["gates"][gate] += weighted
|
||||
|
||||
# Round to 1 decimal place — weighted votes produce fractional scores
|
||||
for nid in scores:
|
||||
scores[nid]["overall"] = round(scores[nid]["overall"], 1)
|
||||
for gid in scores[nid]["gates"]:
|
||||
scores[nid]["gates"][gid] = round(scores[nid]["gates"][gid], 1)
|
||||
|
||||
self._scores_cache = scores
|
||||
self._scores_dirty = False
|
||||
|
||||
def get_reputation(self, node_id: str) -> dict:
|
||||
"""Get reputation for a single node.
|
||||
|
||||
Returns {overall: int, gates: {gate_id: int}, upvotes: int, downvotes: int}
|
||||
|
||||
Scores are merged from three possible sources:
|
||||
1. Direct scores on ``node_id`` (votes targeting posts by this identity).
|
||||
2. Alias scores (if ``node_id`` is linked to an older identity).
|
||||
3. Blinded-wallet scores — vote costs are stored under the deterministic
|
||||
HMAC-blinded form of the voter's root identity so the raw private key
|
||||
never touches disk. When the caller supplies the raw node_id we can
|
||||
recompute the blind and merge those costs in.
|
||||
"""
|
||||
self._recompute_scores()
|
||||
_zero = lambda: {"overall": 0, "gates": {}, "upvotes": 0, "downvotes": 0}
|
||||
base = self._scores_cache.get(node_id, _zero())
|
||||
|
||||
# Merge alias (old identity linked to this one)
|
||||
alias = self.aliases.get(node_id)
|
||||
if alias:
|
||||
old = self._scores_cache.get(alias, _zero())
|
||||
base = self._merge_scores(base, old)
|
||||
|
||||
# Merge blinded-wallet costs (vote-cost records target the blinded ID)
|
||||
blinded = _blind_voter(node_id, _vote_storage_salt())
|
||||
if blinded and blinded != node_id:
|
||||
wallet = self._scores_cache.get(blinded, _zero())
|
||||
if wallet["overall"] != 0 or wallet["upvotes"] != 0 or wallet["downvotes"] != 0:
|
||||
base = self._merge_scores(base, wallet)
|
||||
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _merge_scores(a: dict, b: dict) -> dict:
|
||||
merged = {
|
||||
"overall": a["overall"] + b["overall"],
|
||||
"gates": {},
|
||||
"upvotes": a["upvotes"] + b["upvotes"],
|
||||
"downvotes": a["downvotes"] + b["downvotes"],
|
||||
}
|
||||
gates = set(a.get("gates", {}).keys()) | set(b.get("gates", {}).keys())
|
||||
for g in gates:
|
||||
merged["gates"][g] = a.get("gates", {}).get(g, 0) + b.get("gates", {}).get(g, 0)
|
||||
return merged
|
||||
|
||||
def get_all_reputations(self) -> dict[str, int]:
|
||||
"""Get overall reputation for all known nodes."""
|
||||
self._recompute_scores()
|
||||
return {nid: s["overall"] for nid, s in self._scores_cache.items()}
|
||||
|
||||
def get_reputation_log(self, node_id: str, *, detailed: bool = False) -> dict:
|
||||
"""Return reputation data for a node.
|
||||
|
||||
Public callers receive a summary-only view. Rich breakdowns remain
|
||||
available to authenticated audit tooling.
|
||||
"""
|
||||
cutoff = time.time() - (VOTE_DECAY_DAYS * 86400)
|
||||
rep = self.get_reputation(node_id)
|
||||
result = {
|
||||
"node_id": node_id,
|
||||
"overall": rep.get("overall", 0),
|
||||
"upvotes": rep.get("upvotes", 0),
|
||||
"downvotes": rep.get("downvotes", 0),
|
||||
}
|
||||
if not detailed:
|
||||
return result
|
||||
|
||||
alias = self.aliases.get(node_id)
|
||||
target_ids = {node_id}
|
||||
if alias:
|
||||
target_ids.add(alias)
|
||||
query_salt = os.urandom(8)
|
||||
recent = [
|
||||
{
|
||||
"voter": _blind_voter(_stored_voter_id(v), query_salt),
|
||||
"vote": v["vote"],
|
||||
"gate": "",
|
||||
"weight": v.get("weight", 1.0),
|
||||
"agent_verify": v.get("agent_verify", False),
|
||||
"age": f"{int((time.time() - v['timestamp']) / 86400)}d ago",
|
||||
}
|
||||
for v in sorted(self.votes, key=lambda x: x["timestamp"], reverse=True)
|
||||
if v["target_id"] in target_ids and v["timestamp"] >= cutoff
|
||||
][:20]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"gates": {},
|
||||
"recent_votes": recent,
|
||||
"node_age_days": round(self.get_node_age_days(node_id), 1),
|
||||
"is_agent": self.is_agent(node_id),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
# ─── DM Threshold ────────────────────────────────────────────────
|
||||
|
||||
def should_accept_message(self, sender_id: str, recipient_threshold: int) -> bool:
|
||||
"""Check if sender meets recipient's reputation threshold for DMs."""
|
||||
if recipient_threshold <= 0:
|
||||
return True
|
||||
sender_rep = self.get_reputation(sender_id).get("overall", 0)
|
||||
return sender_rep >= recipient_threshold
|
||||
|
||||
# ─── Cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Remove votes older than the 2-year decay window."""
|
||||
if VOTE_DECAY_DAYS <= 0:
|
||||
return
|
||||
cutoff = time.time() - (VOTE_DECAY_DAYS * 86400)
|
||||
before = len(self.votes)
|
||||
self.votes = [v for v in self.votes if v["timestamp"] >= cutoff]
|
||||
after = len(self.votes)
|
||||
if before != after:
|
||||
self._scores_dirty = True
|
||||
self._save()
|
||||
logger.info(f"Cleaned up {before - after} expired votes")
|
||||
|
||||
|
||||
# ─── Gate System ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GateManager:
|
||||
"""Self-governing reputation-gated communities.
|
||||
|
||||
Anyone with rep >= 10 can create a gate. Entry requires meeting rep thresholds.
|
||||
Getting downvoted below threshold bars you automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, ledger: ReputationLedger):
|
||||
self.ledger = ledger
|
||||
self.gates: dict[str, dict] = {}
|
||||
self._dirty = False
|
||||
self._save_lock = threading.Lock()
|
||||
self._save_timer: threading.Timer | None = None
|
||||
self._SAVE_INTERVAL = 5.0
|
||||
atexit.register(self._flush)
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
domain_path = DATA_DIR / GATES_DOMAIN / GATES_FILE.name
|
||||
if not domain_path.exists() and GATES_FILE.exists():
|
||||
try:
|
||||
legacy = read_secure_json(GATES_FILE, lambda: {})
|
||||
write_domain_json(GATES_DOMAIN, GATES_FILE.name, legacy)
|
||||
GATES_FILE.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate gates: {e}")
|
||||
try:
|
||||
self.gates = read_domain_json(GATES_DOMAIN, GATES_FILE.name, lambda: {})
|
||||
logger.info(f"Loaded {len(self.gates)} gates")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load gates: {e}")
|
||||
if self._apply_gate_catalog():
|
||||
self._save()
|
||||
|
||||
def _apply_gate_catalog(self) -> bool:
|
||||
"""Seed fixed private launch gates and retire obsolete defaults."""
|
||||
changed = False
|
||||
legacy_public_square = self.gates.get("public-square")
|
||||
if isinstance(legacy_public_square, dict) and not legacy_public_square.get("fixed"):
|
||||
self.gates.pop("public-square", None)
|
||||
changed = True
|
||||
|
||||
for gate_id, seed in DEFAULT_PRIVATE_GATES.items():
|
||||
gate = self.gates.get(gate_id)
|
||||
if not isinstance(gate, dict):
|
||||
self.gates[gate_id] = {
|
||||
"creator_node_id": "!sb_seed",
|
||||
"display_name": seed["display_name"],
|
||||
"description": seed["description"],
|
||||
"welcome": seed["welcome"],
|
||||
"rules": {
|
||||
"min_overall_rep": 0,
|
||||
"min_gate_rep": {},
|
||||
},
|
||||
"created_at": time.time(),
|
||||
"message_count": 0,
|
||||
"fixed": True,
|
||||
"sort_order": seed["sort_order"],
|
||||
}
|
||||
changed = True
|
||||
continue
|
||||
|
||||
for key in ("display_name", "description", "welcome", "sort_order"):
|
||||
if gate.get(key) != seed[key]:
|
||||
gate[key] = seed[key]
|
||||
changed = True
|
||||
if gate.get("fixed") is not True:
|
||||
gate["fixed"] = True
|
||||
changed = True
|
||||
if "rules" not in gate or not isinstance(gate["rules"], dict):
|
||||
gate["rules"] = {"min_overall_rep": 0, "min_gate_rep": {}}
|
||||
changed = True
|
||||
gate["rules"].setdefault("min_overall_rep", 0)
|
||||
gate["rules"].setdefault("min_gate_rep", {})
|
||||
gate.setdefault("message_count", 0)
|
||||
gate.setdefault("created_at", time.time())
|
||||
|
||||
return changed
|
||||
|
||||
def _save(self):
|
||||
"""Mark dirty and schedule a coalesced disk write."""
|
||||
self._dirty = True
|
||||
with self._save_lock:
|
||||
if self._save_timer is None or not self._save_timer.is_alive():
|
||||
self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush)
|
||||
self._save_timer.daemon = True
|
||||
self._save_timer.start()
|
||||
|
||||
def _flush(self):
|
||||
"""Actually write to disk (called by timer or atexit)."""
|
||||
if not self._dirty:
|
||||
return
|
||||
try:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
write_domain_json(GATES_DOMAIN, GATES_FILE.name, self.gates)
|
||||
GATES_FILE.unlink(missing_ok=True)
|
||||
self._dirty = False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save gates: {e}")
|
||||
|
||||
def create_gate(
|
||||
self,
|
||||
creator_id: str,
|
||||
gate_id: str,
|
||||
display_name: str,
|
||||
min_overall_rep: int = 0,
|
||||
min_gate_rep: Optional[dict] = None,
|
||||
description: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
"""Create a new gate. Rep gate disabled until network reaches critical mass."""
|
||||
|
||||
if not ALLOW_DYNAMIC_GATES:
|
||||
return False, "Gate creation is disabled for the fixed private launch catalog"
|
||||
|
||||
gate_id = gate_id.lower().strip()
|
||||
if not gate_id or not gate_id.isalnum() and "-" not in gate_id:
|
||||
return False, "Gate ID must be alphanumeric (hyphens allowed)"
|
||||
if len(gate_id) > 32:
|
||||
return False, "Gate ID too long (max 32 chars)"
|
||||
if gate_id in self.gates:
|
||||
return False, f"Gate '{gate_id}' already exists"
|
||||
|
||||
self.gates[gate_id] = {
|
||||
"creator_node_id": creator_id,
|
||||
"display_name": display_name[:64],
|
||||
"description": description[:240],
|
||||
"rules": {
|
||||
"min_overall_rep": min_overall_rep,
|
||||
"min_gate_rep": min_gate_rep or {},
|
||||
},
|
||||
"created_at": time.time(),
|
||||
"message_count": 0,
|
||||
"fixed": False,
|
||||
"sort_order": 1000,
|
||||
}
|
||||
self._save()
|
||||
logger.info(
|
||||
"Gate created: %s by %s",
|
||||
privacy_log_label(gate_id, label="gate"),
|
||||
privacy_log_label(creator_id, label="node"),
|
||||
)
|
||||
return True, f"Gate '{gate_id}' created"
|
||||
|
||||
def can_enter(self, node_id: str, gate_id: str) -> tuple[bool, str]:
|
||||
"""Check if a node meets the entry rules for a gate."""
|
||||
gate = self.gates.get(gate_id)
|
||||
if not gate:
|
||||
return False, f"Gate '{gate_id}' does not exist"
|
||||
|
||||
rules = gate.get("rules", {})
|
||||
rep = self.ledger.get_reputation(node_id)
|
||||
|
||||
# Check overall rep requirement
|
||||
min_overall = rules.get("min_overall_rep", 0)
|
||||
if rep.get("overall", 0) < min_overall:
|
||||
return False, f"Need {min_overall} overall rep (you have {rep.get('overall', 0)})"
|
||||
|
||||
# Check gate-specific rep requirements
|
||||
for req_gate, req_min in rules.get("min_gate_rep", {}).items():
|
||||
gate_rep = rep.get("gates", {}).get(req_gate, 0)
|
||||
if gate_rep < req_min:
|
||||
return False, f"Need {req_min} rep in '{req_gate}' gate (you have {gate_rep})"
|
||||
|
||||
return True, "Access granted"
|
||||
|
||||
def list_gates(self) -> list[dict]:
|
||||
"""List all gates with metadata."""
|
||||
result = []
|
||||
for gid, gate in self.gates.items():
|
||||
result.append(
|
||||
{
|
||||
"gate_id": gid,
|
||||
"display_name": gate.get("display_name", gid),
|
||||
"description": gate.get("description", ""),
|
||||
"welcome": gate.get("welcome", ""),
|
||||
"rules": gate.get("rules", {}),
|
||||
"created_at": gate.get("created_at", 0),
|
||||
"fixed": bool(gate.get("fixed", False)),
|
||||
"sort_order": int(gate.get("sort_order", 1000) or 1000),
|
||||
}
|
||||
)
|
||||
return sorted(
|
||||
result,
|
||||
key=lambda x: (
|
||||
0 if x.get("fixed") else 1,
|
||||
int(x.get("sort_order", 1000) or 1000),
|
||||
-float(x.get("created_at", 0) or 0),
|
||||
x.get("gate_id", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def get_gate(self, gate_id: str) -> Optional[dict]:
|
||||
"""Get gate details."""
|
||||
gate = self.gates.get(gate_id)
|
||||
if not gate:
|
||||
return None
|
||||
public_gate = {
|
||||
key: value
|
||||
for key, value in gate.items()
|
||||
if key not in {"creator_node_id", "message_count"}
|
||||
}
|
||||
return {
|
||||
"gate_id": gate_id,
|
||||
**public_gate,
|
||||
}
|
||||
|
||||
def record_message(self, gate_id: str):
|
||||
"""Increment message count for a gate."""
|
||||
if gate_id in self.gates:
|
||||
self.gates[gate_id]["message_count"] = self.gates[gate_id].get("message_count", 0) + 1
|
||||
self._save()
|
||||
|
||||
def is_ratified(self, gate_id: str) -> bool:
|
||||
"""Check if a gate is ratified (has permanent chain address).
|
||||
|
||||
Before BOOTSTRAP_THRESHOLD nodes: all gates are ratified (early access).
|
||||
After bootstrap: gates need cumulative member rep >= GATE_RATIFICATION_REP.
|
||||
"""
|
||||
if len(self.ledger.nodes) < BOOTSTRAP_THRESHOLD:
|
||||
return True # Pre-bootstrap: all gates are ratified
|
||||
|
||||
gate = self.gates.get(gate_id)
|
||||
if not gate:
|
||||
return False
|
||||
|
||||
# Sum rep of all nodes that have gate-specific rep in this gate
|
||||
all_reps = self.ledger.get_all_reputations()
|
||||
self.ledger._recompute_scores()
|
||||
cumulative = 0
|
||||
for nid, score_data in self.ledger._scores_cache.items():
|
||||
gate_rep = score_data.get("gates", {}).get(gate_id, 0)
|
||||
if gate_rep > 0:
|
||||
cumulative += gate_rep
|
||||
|
||||
return cumulative >= GATE_RATIFICATION_REP
|
||||
|
||||
def get_ratification_status(self, gate_id: str) -> dict:
|
||||
"""Get gate's ratification progress."""
|
||||
gate = self.gates.get(gate_id)
|
||||
if not gate:
|
||||
return {"ratified": False, "reason": "Gate not found"}
|
||||
|
||||
network_size = len(self.ledger.nodes)
|
||||
if network_size < BOOTSTRAP_THRESHOLD:
|
||||
return {
|
||||
"ratified": True,
|
||||
"reason": f"Pre-bootstrap ({network_size}/{BOOTSTRAP_THRESHOLD} nodes) — all gates ratified",
|
||||
"cumulative_rep": 0,
|
||||
"required_rep": GATE_RATIFICATION_REP,
|
||||
}
|
||||
|
||||
# Compute cumulative gate rep
|
||||
self.ledger._recompute_scores()
|
||||
cumulative = 0
|
||||
contributors = 0
|
||||
for nid, score_data in self.ledger._scores_cache.items():
|
||||
gate_rep = score_data.get("gates", {}).get(gate_id, 0)
|
||||
if gate_rep > 0:
|
||||
cumulative += gate_rep
|
||||
contributors += 1
|
||||
|
||||
ratified = cumulative >= GATE_RATIFICATION_REP
|
||||
return {
|
||||
"ratified": ratified,
|
||||
"cumulative_rep": cumulative,
|
||||
"required_rep": GATE_RATIFICATION_REP,
|
||||
"contributors": contributors,
|
||||
"reason": (
|
||||
"Ratified — permanent chain address"
|
||||
if ratified
|
||||
else f"Need {GATE_RATIFICATION_REP - cumulative} more cumulative rep"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ─── Module-level singletons ─────────────────────────────────────────────
|
||||
|
||||
reputation_ledger = ReputationLedger()
|
||||
gate_manager = GateManager(reputation_ledger)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
"""Central schema registry for mesh protocol events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from services.mesh.mesh_protocol import normalize_payload, PROTOCOL_VERSION, NETWORK_ID
|
||||
|
||||
|
||||
def _safe_int(val, default=0):
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventSchema:
|
||||
event_type: str
|
||||
required_fields: tuple[str, ...]
|
||||
optional_fields: tuple[str, ...]
|
||||
validate: Callable[[dict[str, Any]], tuple[bool, str]]
|
||||
|
||||
def validate_payload(self, payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
return self.validate(payload)
|
||||
|
||||
|
||||
def _require_fields(payload: dict[str, Any], fields: tuple[str, ...]) -> tuple[bool, str]:
|
||||
for key in fields:
|
||||
if key not in payload:
|
||||
return False, f"Missing field: {key}"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_message(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(
|
||||
payload, ("message", "destination", "channel", "priority", "ephemeral")
|
||||
)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if payload.get("priority") not in ("normal", "high", "emergency", "low"):
|
||||
return False, "Invalid priority"
|
||||
if not isinstance(payload.get("ephemeral"), bool):
|
||||
return False, "ephemeral must be boolean"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_gate_message(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("gate", "ciphertext", "nonce", "sender_ref"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if "message" in payload:
|
||||
return False, "plaintext gate message field is not allowed"
|
||||
gate = str(payload.get("gate", "")).strip().lower()
|
||||
if not gate:
|
||||
return False, "gate cannot be empty"
|
||||
if "epoch" in payload:
|
||||
epoch = _safe_int(payload.get("epoch", 0) or 0, 0)
|
||||
if epoch <= 0:
|
||||
return False, "epoch must be a positive integer"
|
||||
elif (
|
||||
not str(payload.get("ciphertext", "")).strip()
|
||||
and not str(payload.get("nonce", "")).strip()
|
||||
and not str(payload.get("sender_ref", "")).strip()
|
||||
):
|
||||
return False, "epoch must be a positive integer"
|
||||
if not str(payload.get("ciphertext", "")).strip():
|
||||
return False, "ciphertext cannot be empty"
|
||||
if not str(payload.get("nonce", "")).strip():
|
||||
return False, "nonce cannot be empty"
|
||||
if not str(payload.get("sender_ref", "")).strip():
|
||||
return False, "sender_ref cannot be empty"
|
||||
payload_format = str(payload.get("format", "mls1") or "mls1").strip().lower()
|
||||
if payload_format != "mls1":
|
||||
return False, "Unsupported gate message format"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_vote(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("target_id", "vote", "gate"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if payload.get("vote") not in (-1, 1):
|
||||
return False, "Invalid vote"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_gate_create(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("gate_id", "display_name", "rules"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if not isinstance(payload.get("rules"), dict):
|
||||
return False, "rules must be an object"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_prediction(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
return _require_fields(payload, ("market_title", "side", "stake_amount"))
|
||||
|
||||
|
||||
def _validate_stake(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
return _require_fields(payload, ("message_id", "poster_id", "side", "amount", "duration_days"))
|
||||
|
||||
|
||||
def _validate_dm_block(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("blocked_id", "action"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if payload.get("action") not in ("block", "unblock"):
|
||||
return False, "Invalid action"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_dm_key(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("dh_pub_key", "dh_algo", "timestamp"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if payload.get("dh_algo") not in ("X25519", "ECDH", "ECDH_P256"):
|
||||
return False, "Invalid dh_algo"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_dm_message(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(
|
||||
payload, ("recipient_id", "delivery_class", "recipient_token", "ciphertext", "msg_id", "timestamp")
|
||||
)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
delivery_class = str(payload.get("delivery_class", "")).lower()
|
||||
if delivery_class not in ("request", "shared"):
|
||||
return False, "Invalid delivery_class"
|
||||
if delivery_class == "shared" and not str(payload.get("recipient_token", "")).strip():
|
||||
return False, "recipient_token required for shared delivery"
|
||||
dm_format = str(payload.get("format", "mls1") or "mls1").strip().lower()
|
||||
if dm_format not in ("mls1", "dm1"):
|
||||
return False, f"Unknown DM format: {dm_format}"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_mailbox_claims(claims: Any) -> tuple[bool, str]:
|
||||
if not isinstance(claims, list) or not claims:
|
||||
return False, "mailbox_claims must be a non-empty list"
|
||||
for claim in claims:
|
||||
if not isinstance(claim, dict):
|
||||
return False, "mailbox_claims entries must be objects"
|
||||
claim_type = str(claim.get("type", "")).lower()
|
||||
if claim_type not in ("self", "requests", "shared"):
|
||||
return False, "Invalid mailbox claim type"
|
||||
if not str(claim.get("token", "")).strip():
|
||||
return False, f"{claim_type} mailbox claims require token"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_dm_poll(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("mailbox_claims", "timestamp", "nonce"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
return _validate_mailbox_claims(payload.get("mailbox_claims"))
|
||||
|
||||
|
||||
def _validate_dm_count(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
return _validate_dm_poll(payload)
|
||||
|
||||
|
||||
def _validate_key_rotate(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(
|
||||
payload,
|
||||
(
|
||||
"old_node_id",
|
||||
"old_public_key",
|
||||
"old_public_key_algo",
|
||||
"new_public_key",
|
||||
"new_public_key_algo",
|
||||
"timestamp",
|
||||
"old_signature",
|
||||
),
|
||||
)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_key_revoke(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(
|
||||
payload,
|
||||
(
|
||||
"revoked_public_key",
|
||||
"revoked_public_key_algo",
|
||||
"revoked_at",
|
||||
"grace_until",
|
||||
"reason",
|
||||
),
|
||||
)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
revoked_at = _safe_int(payload.get("revoked_at", 0) or 0, 0)
|
||||
grace_until = _safe_int(payload.get("grace_until", 0) or 0, 0)
|
||||
if revoked_at <= 0:
|
||||
return False, "revoked_at must be a positive timestamp"
|
||||
if grace_until < revoked_at:
|
||||
return False, "grace_until must be >= revoked_at"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_abuse_report(payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
ok, reason = _require_fields(payload, ("target_id", "reason"))
|
||||
if not ok:
|
||||
return ok, reason
|
||||
if not str(payload.get("reason", "")).strip():
|
||||
return False, "reason cannot be empty"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
SCHEMA_REGISTRY: dict[str, EventSchema] = {
|
||||
"message": EventSchema(
|
||||
event_type="message",
|
||||
required_fields=("message", "destination", "channel", "priority", "ephemeral"),
|
||||
optional_fields=(),
|
||||
validate=_validate_message,
|
||||
),
|
||||
"gate_message": EventSchema(
|
||||
event_type="gate_message",
|
||||
required_fields=("gate", "ciphertext", "nonce", "sender_ref"),
|
||||
optional_fields=("format",),
|
||||
validate=_validate_gate_message,
|
||||
),
|
||||
"vote": EventSchema(
|
||||
event_type="vote",
|
||||
required_fields=("target_id", "vote", "gate"),
|
||||
optional_fields=(),
|
||||
validate=_validate_vote,
|
||||
),
|
||||
"gate_create": EventSchema(
|
||||
event_type="gate_create",
|
||||
required_fields=("gate_id", "display_name", "rules"),
|
||||
optional_fields=(),
|
||||
validate=_validate_gate_create,
|
||||
),
|
||||
"prediction": EventSchema(
|
||||
event_type="prediction",
|
||||
required_fields=("market_title", "side", "stake_amount"),
|
||||
optional_fields=(),
|
||||
validate=_validate_prediction,
|
||||
),
|
||||
"stake": EventSchema(
|
||||
event_type="stake",
|
||||
required_fields=("message_id", "poster_id", "side", "amount", "duration_days"),
|
||||
optional_fields=(),
|
||||
validate=_validate_stake,
|
||||
),
|
||||
"dm_block": EventSchema(
|
||||
event_type="dm_block",
|
||||
required_fields=("blocked_id", "action"),
|
||||
optional_fields=(),
|
||||
validate=_validate_dm_block,
|
||||
),
|
||||
"dm_key": EventSchema(
|
||||
event_type="dm_key",
|
||||
required_fields=("dh_pub_key", "dh_algo", "timestamp"),
|
||||
optional_fields=(),
|
||||
validate=_validate_dm_key,
|
||||
),
|
||||
"dm_message": EventSchema(
|
||||
event_type="dm_message",
|
||||
required_fields=("recipient_id", "delivery_class", "recipient_token", "ciphertext", "msg_id", "timestamp"),
|
||||
optional_fields=(),
|
||||
validate=_validate_dm_message,
|
||||
),
|
||||
"dm_poll": EventSchema(
|
||||
event_type="dm_poll",
|
||||
required_fields=("mailbox_claims", "timestamp", "nonce"),
|
||||
optional_fields=(),
|
||||
validate=_validate_dm_poll,
|
||||
),
|
||||
"dm_count": EventSchema(
|
||||
event_type="dm_count",
|
||||
required_fields=("mailbox_claims", "timestamp", "nonce"),
|
||||
optional_fields=(),
|
||||
validate=_validate_dm_count,
|
||||
),
|
||||
"key_rotate": EventSchema(
|
||||
event_type="key_rotate",
|
||||
required_fields=(
|
||||
"old_node_id",
|
||||
"old_public_key",
|
||||
"old_public_key_algo",
|
||||
"new_public_key",
|
||||
"new_public_key_algo",
|
||||
"timestamp",
|
||||
"old_signature",
|
||||
),
|
||||
optional_fields=(),
|
||||
validate=_validate_key_rotate,
|
||||
),
|
||||
"key_revoke": EventSchema(
|
||||
event_type="key_revoke",
|
||||
required_fields=(
|
||||
"revoked_public_key",
|
||||
"revoked_public_key_algo",
|
||||
"revoked_at",
|
||||
"grace_until",
|
||||
"reason",
|
||||
),
|
||||
optional_fields=(),
|
||||
validate=_validate_key_revoke,
|
||||
),
|
||||
"abuse_report": EventSchema(
|
||||
event_type="abuse_report",
|
||||
required_fields=("target_id", "reason"),
|
||||
optional_fields=("gate", "evidence"),
|
||||
validate=_validate_abuse_report,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PUBLIC_LEDGER_EVENT_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"message",
|
||||
"vote",
|
||||
"gate_create",
|
||||
"gate_message",
|
||||
"prediction",
|
||||
"stake",
|
||||
"key_rotate",
|
||||
"key_revoke",
|
||||
"abuse_report",
|
||||
}
|
||||
)
|
||||
|
||||
_PUBLIC_LEDGER_FORBIDDEN_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"ip",
|
||||
"ip_address",
|
||||
"origin_ip",
|
||||
"source_ip",
|
||||
"client_ip",
|
||||
"host",
|
||||
"hostname",
|
||||
"origin",
|
||||
"originator",
|
||||
"originator_hint",
|
||||
"routing_hint",
|
||||
"route",
|
||||
"route_hint",
|
||||
"route_reason",
|
||||
"routed_via",
|
||||
"transport",
|
||||
"transport_handle",
|
||||
"transport_lock",
|
||||
"recipient_id",
|
||||
"recipient_token",
|
||||
"delivery_class",
|
||||
"mailbox_claims",
|
||||
"dh_pub_key",
|
||||
"sender_token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_schema(event_type: str) -> EventSchema | None:
|
||||
return SCHEMA_REGISTRY.get(event_type)
|
||||
|
||||
|
||||
def validate_event_payload(event_type: str, payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
schema = get_schema(event_type)
|
||||
if not schema:
|
||||
return False, "Unknown event_type"
|
||||
normalized = normalize_payload(event_type, payload)
|
||||
if normalized != payload:
|
||||
return False, "Payload is not normalized"
|
||||
if event_type not in ("message", "gate_message") and "ephemeral" in payload:
|
||||
return False, "ephemeral not allowed for this event type"
|
||||
return schema.validate_payload(payload)
|
||||
|
||||
|
||||
def validate_public_ledger_payload(event_type: str, payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
if event_type not in PUBLIC_LEDGER_EVENT_TYPES:
|
||||
return False, f"{event_type} is not allowed on the public ledger"
|
||||
forbidden = sorted(
|
||||
key
|
||||
for key in payload.keys()
|
||||
if str(key or "").strip().lower() in _PUBLIC_LEDGER_FORBIDDEN_FIELDS
|
||||
)
|
||||
if forbidden:
|
||||
return False, f"public ledger payload contains forbidden fields: {', '.join(forbidden)}"
|
||||
if event_type == "message":
|
||||
destination = str(payload.get("destination", "") or "").strip().lower()
|
||||
if destination and destination != "broadcast":
|
||||
return False, "public ledger message destination must be broadcast"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def validate_protocol_fields(protocol_version: str, network_id: str) -> tuple[bool, str]:
|
||||
if protocol_version != PROTOCOL_VERSION:
|
||||
return False, "Unsupported protocol_version"
|
||||
if network_id != NETWORK_ID:
|
||||
return False, "network_id mismatch"
|
||||
return True, "ok"
|
||||
@@ -0,0 +1,577 @@
|
||||
"""Secure local storage helpers for Wormhole-owned state.
|
||||
|
||||
Windows uses DPAPI to protect local key envelopes. Root secure-json payloads
|
||||
still use a dedicated master key, while domain-scoped payloads now use
|
||||
independent per-domain keys so compromise of one domain key does not
|
||||
automatically collapse every other Wormhole compartment. Non-Windows platforms
|
||||
can fall back to raw local key files only when tests are running or an
|
||||
explicit development/CI opt-in is set until native keyrings are added in the
|
||||
desktop phase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
MASTER_KEY_FILE = DATA_DIR / "wormhole_secure_store.key"
|
||||
|
||||
_ENVELOPE_KIND = "sb_secure_json"
|
||||
_ENVELOPE_VERSION = 1
|
||||
_MASTER_KIND = "sb_secure_master_key"
|
||||
_MASTER_VERSION = 1
|
||||
_DOMAIN_KEY_KIND = "sb_secure_domain_key"
|
||||
_DOMAIN_KEY_VERSION = 1
|
||||
_MASTER_KEY_CACHE: tuple[str, bytes] | None = None
|
||||
_DOMAIN_KEY_CACHE: dict[str, tuple[str, bytes]] = {}
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class SecureStorageError(RuntimeError):
|
||||
"""Raised when secure local storage cannot be read or written safely."""
|
||||
|
||||
|
||||
def _atomic_write_text(target: Path, content: str, encoding: str = "utf-8") -> None:
|
||||
"""Write content atomically via temp file + os.replace()."""
|
||||
parent = target.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
last_exc: Exception | None = None
|
||||
for _ in range(5):
|
||||
try:
|
||||
os.replace(tmp_path, str(target))
|
||||
last_exc = None
|
||||
break
|
||||
except PermissionError as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.02)
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _envelope_aad(path: Path) -> bytes:
|
||||
return f"shadowbroker|secure-json|v{_ENVELOPE_VERSION}|{path.name}".encode("utf-8")
|
||||
|
||||
|
||||
def _master_aad() -> bytes:
|
||||
return f"shadowbroker|master-key|v{_MASTER_VERSION}".encode("utf-8")
|
||||
|
||||
|
||||
def _domain_key_aad(domain: str) -> bytes:
|
||||
return f"shadowbroker|domain-key|v{_DOMAIN_KEY_VERSION}|{domain}".encode("utf-8")
|
||||
|
||||
|
||||
def _storage_root(base_dir: str | Path | None = None) -> Path:
|
||||
return Path(base_dir).resolve() if base_dir is not None else DATA_DIR.resolve()
|
||||
|
||||
|
||||
def _domain_key_dir(base_dir: str | Path | None = None) -> Path:
|
||||
return _storage_root(base_dir) / "_domain_keys"
|
||||
|
||||
|
||||
def _normalize_domain_name(domain: str) -> str:
|
||||
domain_name = str(domain or "").strip().lower()
|
||||
if not domain_name:
|
||||
raise SecureStorageError("domain name required for domain-scoped storage")
|
||||
if not re.fullmatch(r"[a-z0-9_]+", domain_name):
|
||||
raise SecureStorageError(f"invalid domain name: {domain_name!r}")
|
||||
return domain_name
|
||||
|
||||
|
||||
def _domain_aad(domain: str, filename: str) -> bytes:
|
||||
return f"shadowbroker|domain-json|v{_ENVELOPE_VERSION}|{domain}|{filename}".encode("utf-8")
|
||||
|
||||
|
||||
def _master_envelope_for_windows(protected_key: bytes, *, provider: str) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": _MASTER_KIND,
|
||||
"version": _MASTER_VERSION,
|
||||
"provider": provider,
|
||||
"protected_key": _b64(protected_key),
|
||||
}
|
||||
|
||||
|
||||
def _master_envelope_for_fallback(raw_key: bytes) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": _MASTER_KIND,
|
||||
"version": _MASTER_VERSION,
|
||||
"provider": "raw",
|
||||
"key": _b64(raw_key),
|
||||
}
|
||||
|
||||
|
||||
def _domain_key_envelope_for_windows(
|
||||
domain: str,
|
||||
protected_key: bytes,
|
||||
*,
|
||||
provider: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": _DOMAIN_KEY_KIND,
|
||||
"version": _DOMAIN_KEY_VERSION,
|
||||
"provider": provider,
|
||||
"domain": domain,
|
||||
"protected_key": _b64(protected_key),
|
||||
}
|
||||
|
||||
|
||||
def _domain_key_envelope_for_fallback(domain: str, raw_key: bytes) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": _DOMAIN_KEY_KIND,
|
||||
"version": _DOMAIN_KEY_VERSION,
|
||||
"provider": "raw",
|
||||
"domain": domain,
|
||||
"key": _b64(raw_key),
|
||||
}
|
||||
|
||||
|
||||
def _secure_envelope(path: Path, nonce: bytes, ciphertext: bytes) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": _ENVELOPE_KIND,
|
||||
"version": _ENVELOPE_VERSION,
|
||||
"path": path.name,
|
||||
"nonce": _b64(nonce),
|
||||
"ciphertext": _b64(ciphertext),
|
||||
}
|
||||
|
||||
|
||||
def _is_secure_envelope(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and str(value.get("kind", "") or "") == _ENVELOPE_KIND
|
||||
and int(value.get("version", 0) or 0) == _ENVELOPE_VERSION
|
||||
and "nonce" in value
|
||||
and "ciphertext" in value
|
||||
)
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def _raw_fallback_allowed() -> bool:
|
||||
if _is_windows():
|
||||
return False
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
return True
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
if _is_windows():
|
||||
from ctypes import wintypes
|
||||
|
||||
class _DATA_BLOB(ctypes.Structure):
|
||||
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
||||
|
||||
|
||||
_crypt32 = ctypes.windll.crypt32
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
_CRYPTPROTECT_UI_FORBIDDEN = 0x1
|
||||
_CRYPTPROTECT_LOCAL_MACHINE = 0x4
|
||||
|
||||
_crypt32.CryptProtectData.argtypes = [
|
||||
ctypes.POINTER(_DATA_BLOB),
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DATA_BLOB),
|
||||
]
|
||||
_crypt32.CryptProtectData.restype = wintypes.BOOL
|
||||
_crypt32.CryptUnprotectData.argtypes = [
|
||||
ctypes.POINTER(_DATA_BLOB),
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(_DATA_BLOB),
|
||||
]
|
||||
_crypt32.CryptUnprotectData.restype = wintypes.BOOL
|
||||
_kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
_kernel32.LocalFree.restype = ctypes.c_void_p
|
||||
|
||||
|
||||
def _blob_from_bytes(data: bytes) -> tuple[_DATA_BLOB, ctypes.Array[ctypes.c_char]]:
|
||||
buf = ctypes.create_string_buffer(data, len(data))
|
||||
blob = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_byte)))
|
||||
return blob, buf
|
||||
|
||||
|
||||
def _bytes_from_blob(blob: _DATA_BLOB) -> bytes:
|
||||
return ctypes.string_at(blob.pbData, blob.cbData)
|
||||
|
||||
|
||||
def _dpapi_protect(data: bytes, *, machine_scope: bool) -> bytes:
|
||||
in_blob, in_buf = _blob_from_bytes(data)
|
||||
out_blob = _DATA_BLOB()
|
||||
flags = _CRYPTPROTECT_UI_FORBIDDEN
|
||||
if machine_scope:
|
||||
flags |= _CRYPTPROTECT_LOCAL_MACHINE
|
||||
if not _crypt32.CryptProtectData(
|
||||
ctypes.byref(in_blob),
|
||||
"ShadowBroker Wormhole",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
flags,
|
||||
ctypes.byref(out_blob),
|
||||
):
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
_ = in_buf # Keep the backing buffer alive for the API call.
|
||||
return _bytes_from_blob(out_blob)
|
||||
finally:
|
||||
if out_blob.pbData:
|
||||
_kernel32.LocalFree(out_blob.pbData)
|
||||
|
||||
|
||||
def _dpapi_unprotect(data: bytes) -> bytes:
|
||||
in_blob, in_buf = _blob_from_bytes(data)
|
||||
out_blob = _DATA_BLOB()
|
||||
if not _crypt32.CryptUnprotectData(
|
||||
ctypes.byref(in_blob),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
_CRYPTPROTECT_UI_FORBIDDEN,
|
||||
ctypes.byref(out_blob),
|
||||
):
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
_ = in_buf # Keep the backing buffer alive for the API call.
|
||||
return _bytes_from_blob(out_blob)
|
||||
finally:
|
||||
if out_blob.pbData:
|
||||
_kernel32.LocalFree(out_blob.pbData)
|
||||
|
||||
|
||||
else:
|
||||
|
||||
def _dpapi_protect(data: bytes, *, machine_scope: bool) -> bytes:
|
||||
raise SecureStorageError("DPAPI is only available on Windows")
|
||||
|
||||
|
||||
def _dpapi_unprotect(data: bytes) -> bytes:
|
||||
raise SecureStorageError("DPAPI is only available on Windows")
|
||||
|
||||
|
||||
def _load_master_key() -> bytes:
|
||||
global _MASTER_KEY_CACHE
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_key = str(MASTER_KEY_FILE.resolve())
|
||||
if _MASTER_KEY_CACHE and _MASTER_KEY_CACHE[0] == cache_key:
|
||||
return _MASTER_KEY_CACHE[1]
|
||||
if not MASTER_KEY_FILE.exists():
|
||||
raw_key = os.urandom(32)
|
||||
if _is_windows():
|
||||
envelope = _master_envelope_for_windows(
|
||||
_dpapi_protect(raw_key, machine_scope=True),
|
||||
provider="dpapi-machine",
|
||||
)
|
||||
else:
|
||||
if not _raw_fallback_allowed():
|
||||
raise SecureStorageError(
|
||||
"Non-Windows secure storage requires a native keyring or explicit raw fallback opt-in"
|
||||
)
|
||||
envelope = _master_envelope_for_fallback(raw_key)
|
||||
_atomic_write_text(MASTER_KEY_FILE, json.dumps(envelope, indent=2), encoding="utf-8")
|
||||
_MASTER_KEY_CACHE = (cache_key, raw_key)
|
||||
return raw_key
|
||||
|
||||
try:
|
||||
payload = json.loads(MASTER_KEY_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to load secure storage master key: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("kind") != _MASTER_KIND:
|
||||
raise SecureStorageError("Malformed secure storage master key envelope")
|
||||
provider = str(payload.get("provider", "") or "").lower()
|
||||
if provider in {"dpapi", "dpapi-user", "dpapi-machine"}:
|
||||
try:
|
||||
raw_key = _dpapi_unprotect(_unb64(payload.get("protected_key")))
|
||||
_MASTER_KEY_CACHE = (cache_key, raw_key)
|
||||
return raw_key
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to unwrap DPAPI master key: {exc}") from exc
|
||||
if provider == "raw":
|
||||
if not _raw_fallback_allowed():
|
||||
raise SecureStorageError(
|
||||
"Raw secure-storage envelopes are disabled outside debug/test unless explicitly opted in"
|
||||
)
|
||||
raw_key = _unb64(payload.get("key"))
|
||||
_MASTER_KEY_CACHE = (cache_key, raw_key)
|
||||
return raw_key
|
||||
raise SecureStorageError(f"Unsupported secure storage provider: {provider}")
|
||||
|
||||
|
||||
def _domain_key_file(domain: str, *, base_dir: str | Path | None = None) -> Path:
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
return (_domain_key_dir(base_dir) / f"{domain_name}.key").resolve()
|
||||
|
||||
|
||||
def _load_domain_key(
|
||||
domain: str,
|
||||
*,
|
||||
create_if_missing: bool = True,
|
||||
base_dir: str | Path | None = None,
|
||||
) -> bytes:
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
root = _storage_root(base_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
key_file = _domain_key_file(domain_name, base_dir=base_dir)
|
||||
cache_key = str(key_file)
|
||||
cache_slot = f"{root}::{domain_name}"
|
||||
cached = _DOMAIN_KEY_CACHE.get(cache_slot)
|
||||
if cached and cached[0] == cache_key:
|
||||
return cached[1]
|
||||
if not key_file.exists():
|
||||
if not create_if_missing:
|
||||
raise SecureStorageError(f"Domain key not found for {domain_name}")
|
||||
raw_key = os.urandom(32)
|
||||
if _is_windows():
|
||||
envelope = _domain_key_envelope_for_windows(
|
||||
domain_name,
|
||||
_dpapi_protect(raw_key, machine_scope=True),
|
||||
provider="dpapi-machine",
|
||||
)
|
||||
else:
|
||||
if not _raw_fallback_allowed():
|
||||
raise SecureStorageError(
|
||||
"Non-Windows secure storage requires a native keyring or explicit raw fallback opt-in"
|
||||
)
|
||||
envelope = _domain_key_envelope_for_fallback(domain_name, raw_key)
|
||||
_atomic_write_text(key_file, json.dumps(envelope, indent=2), encoding="utf-8")
|
||||
_DOMAIN_KEY_CACHE[cache_slot] = (cache_key, raw_key)
|
||||
return raw_key
|
||||
|
||||
try:
|
||||
payload = json.loads(key_file.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to load domain key for {domain_name}: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("kind") != _DOMAIN_KEY_KIND:
|
||||
raise SecureStorageError(f"Malformed domain key envelope for {domain_name}")
|
||||
if str(payload.get("domain", "") or "").strip().lower() != domain_name:
|
||||
raise SecureStorageError(f"Domain key envelope mismatch for {domain_name}")
|
||||
provider = str(payload.get("provider", "") or "").lower()
|
||||
if provider in {"dpapi", "dpapi-user", "dpapi-machine"}:
|
||||
try:
|
||||
raw_key = _dpapi_unprotect(_unb64(payload.get("protected_key")))
|
||||
_DOMAIN_KEY_CACHE[cache_slot] = (cache_key, raw_key)
|
||||
return raw_key
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to unwrap domain key for {domain_name}: {exc}") from exc
|
||||
if provider == "raw":
|
||||
if not _raw_fallback_allowed():
|
||||
raise SecureStorageError(
|
||||
"Raw secure-storage envelopes are disabled outside debug/test unless explicitly opted in"
|
||||
)
|
||||
raw_key = _unb64(payload.get("key"))
|
||||
_DOMAIN_KEY_CACHE[cache_slot] = (cache_key, raw_key)
|
||||
return raw_key
|
||||
raise SecureStorageError(f"Unsupported domain key provider for {domain_name}: {provider}")
|
||||
|
||||
|
||||
def _derive_legacy_domain_key(domain: str) -> bytes:
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
return hmac.new(
|
||||
_load_master_key(),
|
||||
domain_name.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _domain_file_path(domain: str, filename: str, *, base_dir: str | Path | None = None) -> Path:
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
file_name = str(filename or "").strip()
|
||||
if not file_name:
|
||||
raise SecureStorageError("filename required for domain-scoped storage")
|
||||
if not re.fullmatch(r"[a-z0-9_.]+", file_name):
|
||||
raise SecureStorageError(f"invalid filename: {file_name!r}")
|
||||
root = _storage_root(base_dir)
|
||||
resolved = (root / domain_name / file_name).resolve()
|
||||
if not str(resolved).startswith(str(root)):
|
||||
raise SecureStorageError("domain storage path traversal rejected")
|
||||
return resolved
|
||||
|
||||
|
||||
def write_secure_json(path: str | Path, payload: Any) -> None:
|
||||
file_path = Path(path)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
master_key = _load_master_key()
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = AESGCM(master_key).encrypt(nonce, _stable_json(payload), _envelope_aad(file_path))
|
||||
envelope = _secure_envelope(file_path, nonce, ciphertext)
|
||||
_atomic_write_text(file_path, json.dumps(envelope, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def read_secure_json(path: str | Path, default_factory: Callable[[], T]) -> T:
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
return default_factory()
|
||||
|
||||
try:
|
||||
raw = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to parse secure JSON {file_path.name}: {exc}") from exc
|
||||
|
||||
if _is_secure_envelope(raw):
|
||||
master_key = _load_master_key()
|
||||
try:
|
||||
plaintext = AESGCM(master_key).decrypt(
|
||||
_unb64(raw.get("nonce")),
|
||||
_unb64(raw.get("ciphertext")),
|
||||
_envelope_aad(file_path),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to decrypt secure JSON {file_path.name}: {exc}") from exc
|
||||
try:
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(
|
||||
f"Failed to decode secure JSON payload {file_path.name}: {exc}"
|
||||
) from exc
|
||||
|
||||
# Legacy plaintext JSON: migrate in place on first successful read.
|
||||
migrated = raw if isinstance(raw, (dict, list)) else default_factory()
|
||||
write_secure_json(file_path, migrated)
|
||||
return migrated
|
||||
|
||||
|
||||
def write_domain_json(
|
||||
domain: str,
|
||||
filename: str,
|
||||
payload: Any,
|
||||
*,
|
||||
base_dir: str | Path | None = None,
|
||||
) -> Path:
|
||||
file_path = _domain_file_path(domain, filename, base_dir=base_dir)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
nonce = os.urandom(12)
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
ciphertext = AESGCM(_load_domain_key(domain_name, base_dir=base_dir)).encrypt(
|
||||
nonce,
|
||||
_stable_json(payload),
|
||||
_domain_aad(domain_name, file_path.name),
|
||||
)
|
||||
envelope = _secure_envelope(file_path, nonce, ciphertext)
|
||||
_atomic_write_text(file_path, json.dumps(envelope, indent=2), encoding="utf-8")
|
||||
return file_path
|
||||
|
||||
|
||||
def read_domain_json(
|
||||
domain: str,
|
||||
filename: str,
|
||||
default_factory: Callable[[], T],
|
||||
*,
|
||||
base_dir: str | Path | None = None,
|
||||
) -> T:
|
||||
file_path = _domain_file_path(domain, filename, base_dir=base_dir)
|
||||
domain_name = _normalize_domain_name(domain)
|
||||
if not file_path.exists():
|
||||
return default_factory()
|
||||
try:
|
||||
raw = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(f"Failed to parse domain JSON {file_path.name}: {exc}") from exc
|
||||
|
||||
if _is_secure_envelope(raw):
|
||||
aad = _domain_aad(domain_name, file_path.name)
|
||||
plaintext: bytes | None = None
|
||||
used_legacy_key = False
|
||||
used_master_key = False
|
||||
try:
|
||||
current_key = _load_domain_key(domain_name, create_if_missing=False, base_dir=base_dir)
|
||||
except SecureStorageError:
|
||||
current_key = None
|
||||
if current_key is not None:
|
||||
try:
|
||||
plaintext = AESGCM(current_key).decrypt(
|
||||
_unb64(raw.get("nonce")),
|
||||
_unb64(raw.get("ciphertext")),
|
||||
aad,
|
||||
)
|
||||
except Exception:
|
||||
plaintext = None
|
||||
if plaintext is None:
|
||||
try:
|
||||
plaintext = AESGCM(_derive_legacy_domain_key(domain_name)).decrypt(
|
||||
_unb64(raw.get("nonce")),
|
||||
_unb64(raw.get("ciphertext")),
|
||||
aad,
|
||||
)
|
||||
used_legacy_key = True
|
||||
except Exception as exc:
|
||||
try:
|
||||
plaintext = AESGCM(_load_master_key()).decrypt(
|
||||
_unb64(raw.get("nonce")),
|
||||
_unb64(raw.get("ciphertext")),
|
||||
_envelope_aad(file_path),
|
||||
)
|
||||
used_master_key = True
|
||||
except Exception:
|
||||
raise SecureStorageError(
|
||||
f"Failed to decrypt domain JSON {file_path.name}: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
decoded = json.loads(plaintext.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise SecureStorageError(
|
||||
f"Failed to decode domain JSON payload {file_path.name}: {exc}"
|
||||
) from exc
|
||||
if used_legacy_key or used_master_key:
|
||||
write_domain_json(domain_name, file_path.name, decoded, base_dir=base_dir)
|
||||
return decoded
|
||||
|
||||
migrated = raw if isinstance(raw, (dict, list)) else default_factory()
|
||||
write_domain_json(domain_name, file_path.name, migrated, base_dir=base_dir)
|
||||
return migrated
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Wormhole-owned DM contact and alias graph state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from services.mesh.mesh_secure_storage import read_secure_json, write_secure_json
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
CONTACTS_FILE = DATA_DIR / "wormhole_dm_contacts.json"
|
||||
|
||||
|
||||
def _default_contact() -> dict[str, Any]:
|
||||
return {
|
||||
"alias": "",
|
||||
"blocked": False,
|
||||
"dhPubKey": "",
|
||||
"dhAlgo": "",
|
||||
"sharedAlias": "",
|
||||
"previousSharedAliases": [],
|
||||
"pendingSharedAlias": "",
|
||||
"sharedAliasGraceUntil": 0,
|
||||
"sharedAliasRotatedAt": 0,
|
||||
"verify_inband": False,
|
||||
"verify_registry": False,
|
||||
"verified": False,
|
||||
"verify_mismatch": False,
|
||||
"verified_at": 0,
|
||||
"remotePrekeyFingerprint": "",
|
||||
"remotePrekeyObservedFingerprint": "",
|
||||
"remotePrekeyPinnedAt": 0,
|
||||
"remotePrekeyLastSeenAt": 0,
|
||||
"remotePrekeySequence": 0,
|
||||
"remotePrekeySignedAt": 0,
|
||||
"remotePrekeyMismatch": False,
|
||||
"witness_count": 0,
|
||||
"witness_checked_at": 0,
|
||||
"vouch_count": 0,
|
||||
"vouch_checked_at": 0,
|
||||
"updated_at": 0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_contact(value: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _default_contact()
|
||||
current.update(value or {})
|
||||
current["alias"] = str(current.get("alias", "") or "")
|
||||
current["blocked"] = bool(current.get("blocked"))
|
||||
current["dhPubKey"] = str(current.get("dhPubKey", "") or "")
|
||||
current["dhAlgo"] = str(current.get("dhAlgo", "") or "")
|
||||
current["sharedAlias"] = str(current.get("sharedAlias", "") or "")
|
||||
current["previousSharedAliases"] = [
|
||||
str(item or "") for item in list(current.get("previousSharedAliases") or []) if str(item or "").strip()
|
||||
][-8:]
|
||||
current["pendingSharedAlias"] = str(current.get("pendingSharedAlias", "") or "")
|
||||
current["remotePrekeyFingerprint"] = str(current.get("remotePrekeyFingerprint", "") or "")
|
||||
current["remotePrekeyObservedFingerprint"] = str(current.get("remotePrekeyObservedFingerprint", "") or "")
|
||||
for key in (
|
||||
"sharedAliasGraceUntil",
|
||||
"sharedAliasRotatedAt",
|
||||
"verified_at",
|
||||
"remotePrekeyPinnedAt",
|
||||
"remotePrekeyLastSeenAt",
|
||||
"remotePrekeySequence",
|
||||
"remotePrekeySignedAt",
|
||||
"witness_count",
|
||||
"witness_checked_at",
|
||||
"vouch_count",
|
||||
"vouch_checked_at",
|
||||
"updated_at",
|
||||
):
|
||||
current[key] = int(current.get(key, 0) or 0)
|
||||
for key in (
|
||||
"verify_inband",
|
||||
"verify_registry",
|
||||
"verified",
|
||||
"verify_mismatch",
|
||||
"remotePrekeyMismatch",
|
||||
):
|
||||
current[key] = bool(current.get(key))
|
||||
return current
|
||||
|
||||
|
||||
def _merge_alias_history(*aliases: str, limit: int = 8) -> list[str]:
|
||||
unique: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for alias in aliases:
|
||||
value = str(alias or "").strip()
|
||||
if not value or value in unique:
|
||||
continue
|
||||
unique.add(value)
|
||||
ordered.append(value)
|
||||
if len(ordered) >= limit:
|
||||
break
|
||||
return ordered
|
||||
|
||||
|
||||
def _promote_pending_alias_if_due(contact: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
current = _normalize_contact(contact)
|
||||
pending = str(current.get("pendingSharedAlias", "") or "").strip()
|
||||
grace_until = int(current.get("sharedAliasGraceUntil", 0) or 0)
|
||||
if not pending or grace_until <= 0 or grace_until > int(time.time() * 1000):
|
||||
return current, False
|
||||
active = str(current.get("sharedAlias", "") or "").strip()
|
||||
promoted = dict(current)
|
||||
promoted["sharedAlias"] = pending or active
|
||||
promoted["pendingSharedAlias"] = ""
|
||||
promoted["sharedAliasGraceUntil"] = 0
|
||||
promoted["sharedAliasRotatedAt"] = int(time.time() * 1000)
|
||||
promoted["previousSharedAliases"] = _merge_alias_history(
|
||||
active,
|
||||
*list(current.get("previousSharedAliases") or []),
|
||||
)
|
||||
return _normalize_contact(promoted), True
|
||||
|
||||
|
||||
def _read_contacts() -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
raw = read_secure_json(CONTACTS_FILE, lambda: {})
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Contacts file could not be decrypted — starting with empty contacts"
|
||||
)
|
||||
CONTACTS_FILE.unlink(missing_ok=True)
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
contacts: dict[str, dict[str, Any]] = {}
|
||||
changed = False
|
||||
for peer_id, value in raw.items():
|
||||
key = str(peer_id or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
normalized, promoted = _promote_pending_alias_if_due(value if isinstance(value, dict) else {})
|
||||
contacts[key] = normalized
|
||||
changed = changed or promoted
|
||||
if changed:
|
||||
_write_contacts(contacts)
|
||||
return contacts
|
||||
|
||||
|
||||
def _write_contacts(contacts: dict[str, dict[str, Any]]) -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
str(peer_id): _normalize_contact(contact)
|
||||
for peer_id, contact in contacts.items()
|
||||
if str(peer_id or "").strip()
|
||||
}
|
||||
write_secure_json(CONTACTS_FILE, payload)
|
||||
|
||||
|
||||
def list_wormhole_dm_contacts() -> dict[str, dict[str, Any]]:
|
||||
return _read_contacts()
|
||||
|
||||
|
||||
def upsert_wormhole_dm_contact(peer_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
if not peer_id:
|
||||
raise ValueError("peer_id required")
|
||||
contacts = _read_contacts()
|
||||
merged = _normalize_contact({**contacts.get(peer_id, _default_contact()), **dict(updates or {})})
|
||||
merged["updated_at"] = int(time.time())
|
||||
contacts[peer_id] = merged
|
||||
_write_contacts(contacts)
|
||||
return merged
|
||||
|
||||
|
||||
def delete_wormhole_dm_contact(peer_id: str) -> bool:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
if not peer_id:
|
||||
return False
|
||||
contacts = _read_contacts()
|
||||
if peer_id not in contacts:
|
||||
return False
|
||||
del contacts[peer_id]
|
||||
_write_contacts(contacts)
|
||||
return True
|
||||
|
||||
|
||||
def observe_remote_prekey_identity(
|
||||
peer_id: str,
|
||||
*,
|
||||
fingerprint: str,
|
||||
sequence: int = 0,
|
||||
signed_at: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
peer_key = str(peer_id or "").strip()
|
||||
candidate = str(fingerprint or "").strip().lower()
|
||||
if not peer_key:
|
||||
raise ValueError("peer_id required")
|
||||
if not candidate:
|
||||
raise ValueError("fingerprint required")
|
||||
|
||||
contacts = _read_contacts()
|
||||
current = _normalize_contact(contacts.get(peer_key))
|
||||
now = int(time.time())
|
||||
pinned = str(current.get("remotePrekeyFingerprint", "") or "").strip().lower()
|
||||
|
||||
current["remotePrekeyObservedFingerprint"] = candidate
|
||||
current["remotePrekeyLastSeenAt"] = now
|
||||
current["remotePrekeySequence"] = int(sequence or 0)
|
||||
current["remotePrekeySignedAt"] = int(signed_at or 0)
|
||||
|
||||
trust_changed = False
|
||||
if not pinned:
|
||||
current["remotePrekeyFingerprint"] = candidate
|
||||
current["remotePrekeyPinnedAt"] = now
|
||||
current["remotePrekeyMismatch"] = False
|
||||
pinned = candidate
|
||||
else:
|
||||
trust_changed = pinned != candidate
|
||||
current["remotePrekeyMismatch"] = trust_changed
|
||||
|
||||
current["updated_at"] = int(time.time())
|
||||
contacts[peer_key] = _normalize_contact(current)
|
||||
_write_contacts(contacts)
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_key,
|
||||
"trust_changed": trust_changed,
|
||||
"contact": contacts[peer_key],
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Wormhole-owned dead-drop token derivation helpers.
|
||||
|
||||
These helpers move mailbox token derivation off the browser when Wormhole is the
|
||||
secure trust anchor. The browser supplies only peer identifiers and peer DH
|
||||
public keys; Wormhole derives the shared secret locally and returns mailbox
|
||||
tokens for the current and previous epochs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import bootstrap_wormhole_identity, read_wormhole_identity
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts, upsert_wormhole_dm_contact
|
||||
from services.wormhole_settings import read_wormhole_settings
|
||||
|
||||
DEFAULT_DM_EPOCH_SECONDS = 6 * 60 * 60
|
||||
HIGH_PRIVACY_DM_EPOCH_SECONDS = 2 * 60 * 60
|
||||
SAS_PREFIXES = [
|
||||
"amber",
|
||||
"apex",
|
||||
"atlas",
|
||||
"birch",
|
||||
"cinder",
|
||||
"cobalt",
|
||||
"delta",
|
||||
"ember",
|
||||
"falcon",
|
||||
"frost",
|
||||
"glint",
|
||||
"harbor",
|
||||
"juno",
|
||||
"kepler",
|
||||
"lumen",
|
||||
"nova",
|
||||
]
|
||||
SAS_SUFFIXES = [
|
||||
"anchor",
|
||||
"arrow",
|
||||
"bloom",
|
||||
"cabin",
|
||||
"cedar",
|
||||
"cipher",
|
||||
"comet",
|
||||
"field",
|
||||
"grove",
|
||||
"harvest",
|
||||
"meadow",
|
||||
"mesa",
|
||||
"orbit",
|
||||
"signal",
|
||||
"summit",
|
||||
"thunder",
|
||||
]
|
||||
SAS_WORDS = [f"{prefix}-{suffix}" for prefix in SAS_PREFIXES for suffix in SAS_SUFFIXES]
|
||||
DM_CONSENT_PREFIX = "DM_CONSENT:"
|
||||
PAIRWISE_ALIAS_PREFIX = "dmx_"
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def build_contact_offer(*, dh_pub_key: str, dh_algo: str, geo_hint: str = "") -> str:
|
||||
return (
|
||||
f"{DM_CONSENT_PREFIX}"
|
||||
+ json.dumps(
|
||||
{
|
||||
"kind": "contact_offer",
|
||||
"dh_pub_key": str(dh_pub_key or ""),
|
||||
"dh_algo": str(dh_algo or ""),
|
||||
"geo_hint": str(geo_hint or ""),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_contact_accept(*, shared_alias: str) -> str:
|
||||
return (
|
||||
f"{DM_CONSENT_PREFIX}"
|
||||
+ json.dumps(
|
||||
{
|
||||
"kind": "contact_accept",
|
||||
"shared_alias": str(shared_alias or ""),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_contact_deny(*, reason: str = "") -> str:
|
||||
return (
|
||||
f"{DM_CONSENT_PREFIX}"
|
||||
+ json.dumps(
|
||||
{
|
||||
"kind": "contact_deny",
|
||||
"reason": str(reason or ""),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_contact_consent(message: str) -> dict[str, Any] | None:
|
||||
text = str(message or "").strip()
|
||||
if not text.startswith(DM_CONSENT_PREFIX):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(text[len(DM_CONSENT_PREFIX) :])
|
||||
except Exception:
|
||||
return None
|
||||
kind = str(payload.get("kind", "") or "").strip().lower()
|
||||
if kind == "contact_offer":
|
||||
dh_pub_key = str(payload.get("dh_pub_key", "") or "").strip()
|
||||
if not dh_pub_key:
|
||||
return None
|
||||
return {
|
||||
"kind": kind,
|
||||
"dh_pub_key": dh_pub_key,
|
||||
"dh_algo": str(payload.get("dh_algo", "") or "").strip() or "X25519",
|
||||
"geo_hint": str(payload.get("geo_hint", "") or "").strip(),
|
||||
}
|
||||
if kind == "contact_accept":
|
||||
shared_alias = str(payload.get("shared_alias", "") or "").strip()
|
||||
if not shared_alias:
|
||||
return None
|
||||
return {"kind": kind, "shared_alias": shared_alias}
|
||||
if kind == "contact_deny":
|
||||
return {
|
||||
"kind": kind,
|
||||
"reason": str(payload.get("reason", "") or "").strip(),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _new_pairwise_alias() -> str:
|
||||
return f"{PAIRWISE_ALIAS_PREFIX}{secrets.token_hex(12)}"
|
||||
|
||||
|
||||
def _merge_alias_history(*aliases: str, limit: int = 8) -> list[str]:
|
||||
unique: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for alias in aliases:
|
||||
value = str(alias or "").strip()
|
||||
if not value or value in unique:
|
||||
continue
|
||||
unique.add(value)
|
||||
ordered.append(value)
|
||||
if len(ordered) >= limit:
|
||||
break
|
||||
return ordered
|
||||
|
||||
|
||||
def issue_pairwise_dm_alias(*, peer_id: str, peer_dh_pub: str = "") -> dict[str, Any]:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
peer_dh_pub = str(peer_dh_pub or "").strip()
|
||||
if not peer_id:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
get_dm_identity,
|
||||
)
|
||||
|
||||
bootstrap_wormhole_persona_state()
|
||||
dm_identity = get_dm_identity()
|
||||
current = dict(list_wormhole_dm_contacts().get(peer_id) or {})
|
||||
previous_alias = str(current.get("sharedAlias", "") or "").strip()
|
||||
shared_alias = _new_pairwise_alias()
|
||||
while shared_alias == previous_alias:
|
||||
shared_alias = _new_pairwise_alias()
|
||||
|
||||
rotated_at_ms = int(time.time() * 1000)
|
||||
contact_updates: dict[str, Any] = {
|
||||
"sharedAlias": shared_alias,
|
||||
"pendingSharedAlias": "",
|
||||
"sharedAliasGraceUntil": 0,
|
||||
"sharedAliasRotatedAt": rotated_at_ms,
|
||||
"previousSharedAliases": _merge_alias_history(
|
||||
previous_alias,
|
||||
*list(current.get("previousSharedAliases") or []),
|
||||
),
|
||||
}
|
||||
if peer_dh_pub:
|
||||
contact_updates["dhPubKey"] = peer_dh_pub
|
||||
elif str(current.get("dhPubKey", "") or "").strip():
|
||||
contact_updates["dhPubKey"] = str(current.get("dhPubKey", "") or "").strip()
|
||||
if str(current.get("dhAlgo", "") or "").strip():
|
||||
contact_updates["dhAlgo"] = str(current.get("dhAlgo", "") or "").strip()
|
||||
|
||||
contact = upsert_wormhole_dm_contact(peer_id, contact_updates)
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_id,
|
||||
"shared_alias": shared_alias,
|
||||
"replaced_alias": previous_alias,
|
||||
"identity_scope": "dm_alias",
|
||||
"dm_identity_id": str(dm_identity.get("node_id", "") or ""),
|
||||
"contact": contact,
|
||||
}
|
||||
|
||||
|
||||
def rotate_pairwise_dm_alias(
|
||||
*,
|
||||
peer_id: str,
|
||||
peer_dh_pub: str = "",
|
||||
grace_ms: int = 45_000,
|
||||
) -> dict[str, Any]:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
peer_dh_pub = str(peer_dh_pub or "").strip()
|
||||
if not peer_id:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
get_dm_identity,
|
||||
)
|
||||
|
||||
bootstrap_wormhole_persona_state()
|
||||
dm_identity = get_dm_identity()
|
||||
current = dict(list_wormhole_dm_contacts().get(peer_id) or {})
|
||||
active_alias = str(current.get("sharedAlias", "") or "").strip()
|
||||
if not active_alias:
|
||||
return issue_pairwise_dm_alias(peer_id=peer_id, peer_dh_pub=peer_dh_pub)
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
pending_alias = str(current.get("pendingSharedAlias", "") or "").strip()
|
||||
grace_until = int(current.get("sharedAliasGraceUntil", 0) or 0)
|
||||
if pending_alias and grace_until > now_ms:
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_id,
|
||||
"active_alias": active_alias,
|
||||
"pending_alias": pending_alias,
|
||||
"grace_until": grace_until,
|
||||
"identity_scope": "dm_alias",
|
||||
"dm_identity_id": str(dm_identity.get("node_id", "") or ""),
|
||||
"contact": current,
|
||||
"rotated": False,
|
||||
}
|
||||
|
||||
next_alias = _new_pairwise_alias()
|
||||
reserved = {
|
||||
active_alias,
|
||||
pending_alias,
|
||||
*[str(item or "").strip() for item in list(current.get("previousSharedAliases") or [])],
|
||||
}
|
||||
while next_alias in reserved:
|
||||
next_alias = _new_pairwise_alias()
|
||||
|
||||
clamped_grace_ms = max(5_000, min(int(grace_ms or 45_000), 5 * 60 * 1000))
|
||||
next_grace_until = now_ms + clamped_grace_ms
|
||||
contact_updates: dict[str, Any] = {
|
||||
"pendingSharedAlias": next_alias,
|
||||
"sharedAliasGraceUntil": next_grace_until,
|
||||
"sharedAliasRotatedAt": now_ms,
|
||||
"previousSharedAliases": _merge_alias_history(
|
||||
active_alias,
|
||||
pending_alias,
|
||||
*list(current.get("previousSharedAliases") or []),
|
||||
),
|
||||
}
|
||||
if peer_dh_pub:
|
||||
contact_updates["dhPubKey"] = peer_dh_pub
|
||||
elif str(current.get("dhPubKey", "") or "").strip():
|
||||
contact_updates["dhPubKey"] = str(current.get("dhPubKey", "") or "").strip()
|
||||
if str(current.get("dhAlgo", "") or "").strip():
|
||||
contact_updates["dhAlgo"] = str(current.get("dhAlgo", "") or "").strip()
|
||||
|
||||
contact = upsert_wormhole_dm_contact(peer_id, contact_updates)
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_id,
|
||||
"active_alias": active_alias,
|
||||
"pending_alias": next_alias,
|
||||
"grace_until": next_grace_until,
|
||||
"identity_scope": "dm_alias",
|
||||
"dm_identity_id": str(dm_identity.get("node_id", "") or ""),
|
||||
"contact": contact,
|
||||
"rotated": True,
|
||||
}
|
||||
|
||||
|
||||
def mailbox_epoch_seconds() -> int:
|
||||
try:
|
||||
settings = read_wormhole_settings()
|
||||
if str(settings.get("privacy_profile", "default") or "default").lower() == "high":
|
||||
return HIGH_PRIVACY_DM_EPOCH_SECONDS
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_DM_EPOCH_SECONDS
|
||||
|
||||
|
||||
def current_mailbox_epoch(ts_seconds: int | None = None) -> int:
|
||||
now = int(ts_seconds) if ts_seconds is not None else int(time.time())
|
||||
return now // mailbox_epoch_seconds()
|
||||
|
||||
|
||||
def _derive_shared_secret(my_private_b64: str, peer_public_b64: str) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(my_private_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(peer_public_b64))
|
||||
return priv.exchange(pub)
|
||||
|
||||
|
||||
def _token_for(secret: bytes, peer_id: str, my_node_id: str, epoch: int) -> str:
|
||||
ids = "|".join(sorted([str(my_node_id or ""), str(peer_id or "")]))
|
||||
message = f"sb_dd|v1|{int(epoch)}|{ids}".encode("utf-8")
|
||||
return hmac.new(secret, message, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _sas_words_from_digest(digest: bytes, count: int) -> list[str]:
|
||||
out: list[str] = []
|
||||
acc = 0
|
||||
acc_bits = 0
|
||||
for byte in digest:
|
||||
acc = (acc << 8) | byte
|
||||
acc_bits += 8
|
||||
while acc_bits >= 8 and len(out) < count:
|
||||
idx = (acc >> (acc_bits - 8)) & 0xFF
|
||||
out.append(SAS_WORDS[idx])
|
||||
acc_bits -= 8
|
||||
if len(out) >= count:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def derive_dead_drop_token_pair(*, peer_id: str, peer_dh_pub: str) -> dict[str, Any]:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
peer_dh_pub = str(peer_dh_pub or "").strip()
|
||||
if not peer_id or not peer_dh_pub:
|
||||
return {"ok": False, "detail": "peer_id and peer_dh_pub required"}
|
||||
|
||||
identity = read_wormhole_identity()
|
||||
if not identity.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
identity = read_wormhole_identity()
|
||||
|
||||
my_private = str(identity.get("dh_private_key", "") or "")
|
||||
my_node_id = str(identity.get("node_id", "") or "")
|
||||
if not my_private or not my_node_id:
|
||||
return {"ok": False, "detail": "Wormhole DH identity unavailable"}
|
||||
|
||||
try:
|
||||
secret = _derive_shared_secret(my_private, peer_dh_pub)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "dead_drop_secret_failed"}
|
||||
|
||||
epoch = current_mailbox_epoch()
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_id,
|
||||
"epoch": epoch,
|
||||
"current": _token_for(secret, peer_id, my_node_id, epoch),
|
||||
"previous": _token_for(secret, peer_id, my_node_id, epoch - 1),
|
||||
}
|
||||
|
||||
|
||||
def derive_dead_drop_tokens_for_contacts(*, contacts: list[dict[str, Any]], limit: int = 24) -> dict[str, Any]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in contacts[: max(1, min(int(limit or 24), 64))]:
|
||||
peer_id = str((item or {}).get("peer_id", "") or "").strip()
|
||||
peer_dh_pub = str((item or {}).get("peer_dh_pub", "") or "").strip()
|
||||
if not peer_id or not peer_dh_pub:
|
||||
continue
|
||||
pair = derive_dead_drop_token_pair(peer_id=peer_id, peer_dh_pub=peer_dh_pub)
|
||||
if pair.get("ok"):
|
||||
results.append(
|
||||
{
|
||||
"peer_id": peer_id,
|
||||
"current": str(pair.get("current", "") or ""),
|
||||
"previous": str(pair.get("previous", "") or ""),
|
||||
"epoch": int(pair.get("epoch", 0) or 0),
|
||||
}
|
||||
)
|
||||
return {"ok": True, "tokens": results}
|
||||
|
||||
|
||||
def derive_sas_phrase(*, peer_id: str, peer_dh_pub: str, words: int = 8) -> dict[str, Any]:
|
||||
peer_id = str(peer_id or "").strip()
|
||||
peer_dh_pub = str(peer_dh_pub or "").strip()
|
||||
word_count = max(2, min(int(words or 8), 16))
|
||||
if not peer_id or not peer_dh_pub:
|
||||
return {"ok": False, "detail": "peer_id and peer_dh_pub required"}
|
||||
|
||||
identity = read_wormhole_identity()
|
||||
if not identity.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
identity = read_wormhole_identity()
|
||||
|
||||
my_private = str(identity.get("dh_private_key", "") or "")
|
||||
my_node_id = str(identity.get("node_id", "") or "")
|
||||
if not my_private or not my_node_id:
|
||||
return {"ok": False, "detail": "Wormhole DH identity unavailable"}
|
||||
|
||||
try:
|
||||
secret = _derive_shared_secret(my_private, peer_dh_pub)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "sas_secret_failed"}
|
||||
|
||||
ids = "|".join(sorted([my_node_id, peer_id]))
|
||||
digest = hmac.new(secret, f"sb_sas|v1|{ids}".encode("utf-8"), hashlib.sha256).digest()
|
||||
phrase = " ".join(_sas_words_from_digest(digest, word_count))
|
||||
return {"ok": True, "peer_id": peer_id, "phrase": phrase, "words": word_count}
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Wormhole-managed DM identity wrappers.
|
||||
|
||||
This module preserves the legacy DM identity API while sourcing its state from
|
||||
the Wormhole persona manager. Public transport identity stays separate, and DM
|
||||
operations now use the dedicated DM alias compartment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
ensure_dm_mailbox_client_secret,
|
||||
get_dm_identity,
|
||||
read_dm_identity,
|
||||
read_wormhole_persona_state,
|
||||
sign_dm_wormhole_event,
|
||||
sign_dm_wormhole_message,
|
||||
write_dm_identity,
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(val, default=0) -> int:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _default_identity() -> dict[str, Any]:
|
||||
return {
|
||||
"bootstrapped": False,
|
||||
"bootstrapped_at": 0,
|
||||
"updated_at": 0,
|
||||
"scope": "dm_alias",
|
||||
"label": "dm-alias",
|
||||
"node_id": "",
|
||||
"public_key": "",
|
||||
"public_key_algo": "Ed25519",
|
||||
"private_key": "",
|
||||
"sequence": 0,
|
||||
"dh_pub_key": "",
|
||||
"dh_algo": "X25519",
|
||||
"dh_private_key": "",
|
||||
"last_dh_timestamp": 0,
|
||||
"bundle_fingerprint": "",
|
||||
"bundle_sequence": 0,
|
||||
"bundle_registered_at": 0,
|
||||
"signed_prekey_id": 0,
|
||||
"signed_prekey_pub": "",
|
||||
"signed_prekey_priv": "",
|
||||
"signed_prekey_signature": "",
|
||||
"signed_prekey_generated_at": 0,
|
||||
"signed_prekey_history": [],
|
||||
"one_time_prekeys": [],
|
||||
"prekey_bundle_registered_at": 0,
|
||||
"prekey_republish_threshold": 0,
|
||||
"prekey_republish_target": 0,
|
||||
"prekey_next_republish_after": 0,
|
||||
}
|
||||
|
||||
|
||||
def _public_view(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"bootstrapped": bool(data.get("bootstrapped")),
|
||||
"bootstrapped_at": _safe_int(data.get("bootstrapped_at", 0) or 0),
|
||||
"scope": str(data.get("scope", "dm_alias") or "dm_alias"),
|
||||
"label": str(data.get("label", "dm-alias") or "dm-alias"),
|
||||
"node_id": str(data.get("node_id", "") or ""),
|
||||
"public_key": str(data.get("public_key", "") or ""),
|
||||
"public_key_algo": str(data.get("public_key_algo", "Ed25519") or "Ed25519"),
|
||||
"sequence": _safe_int(data.get("sequence", 0) or 0),
|
||||
"dh_pub_key": str(data.get("dh_pub_key", "") or ""),
|
||||
"dh_algo": str(data.get("dh_algo", "X25519") or "X25519"),
|
||||
"last_dh_timestamp": _safe_int(data.get("last_dh_timestamp", 0) or 0),
|
||||
"bundle_fingerprint": str(data.get("bundle_fingerprint", "") or ""),
|
||||
"bundle_sequence": _safe_int(data.get("bundle_sequence", 0) or 0),
|
||||
"bundle_registered_at": _safe_int(data.get("bundle_registered_at", 0) or 0),
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def read_wormhole_identity() -> dict[str, Any]:
|
||||
bootstrap_wormhole_persona_state()
|
||||
persona_state = read_wormhole_persona_state()
|
||||
data = {**_default_identity(), **read_dm_identity()}
|
||||
data["bootstrapped"] = True
|
||||
data["bootstrapped_at"] = _safe_int(persona_state.get("bootstrapped_at", 0) or 0)
|
||||
return data
|
||||
|
||||
|
||||
def _write_identity(data: dict[str, Any]) -> dict[str, Any]:
|
||||
current = read_wormhole_identity()
|
||||
merged = {**current, **dict(data or {})}
|
||||
merged["scope"] = "dm_alias"
|
||||
merged["label"] = str(merged.get("label", "dm-alias") or "dm-alias")
|
||||
merged["updated_at"] = int(time.time())
|
||||
saved = write_dm_identity(merged)
|
||||
saved["bootstrapped"] = True
|
||||
return {**_default_identity(), **saved}
|
||||
|
||||
|
||||
def bootstrap_wormhole_identity(force: bool = False) -> dict[str, Any]:
|
||||
bootstrap_wormhole_persona_state(force=force)
|
||||
data = read_wormhole_identity()
|
||||
if force:
|
||||
data["bundle_fingerprint"] = ""
|
||||
data["bundle_sequence"] = 0
|
||||
data["bundle_registered_at"] = 0
|
||||
data["signed_prekey_id"] = 0
|
||||
data["signed_prekey_pub"] = ""
|
||||
data["signed_prekey_priv"] = ""
|
||||
data["signed_prekey_signature"] = ""
|
||||
data["signed_prekey_generated_at"] = 0
|
||||
data["signed_prekey_history"] = []
|
||||
data["one_time_prekeys"] = []
|
||||
data["prekey_bundle_registered_at"] = 0
|
||||
data["prekey_republish_threshold"] = 0
|
||||
data["prekey_republish_target"] = 0
|
||||
data["prekey_next_republish_after"] = 0
|
||||
data = _write_identity(data)
|
||||
return _public_view(data)
|
||||
|
||||
|
||||
def get_wormhole_identity() -> dict[str, Any]:
|
||||
return get_dm_identity()
|
||||
|
||||
|
||||
def sign_wormhole_event(
|
||||
*,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
sequence: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return sign_dm_wormhole_event(event_type=event_type, payload=payload, sequence=sequence)
|
||||
|
||||
|
||||
def sign_wormhole_message(message: str) -> dict[str, Any]:
|
||||
return sign_dm_wormhole_message(message)
|
||||
|
||||
|
||||
def _bundle_fingerprint(data: dict[str, Any]) -> str:
|
||||
raw = "|".join(
|
||||
[
|
||||
str(data.get("dh_pub_key", "")),
|
||||
str(data.get("dh_algo", "X25519")),
|
||||
str(data.get("public_key", "")),
|
||||
str(data.get("public_key_algo", "Ed25519")),
|
||||
PROTOCOL_VERSION,
|
||||
]
|
||||
)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def register_wormhole_dm_key(force: bool = False) -> dict[str, Any]:
|
||||
data = read_wormhole_identity()
|
||||
|
||||
timestamp = int(time.time())
|
||||
fingerprint = _bundle_fingerprint(data)
|
||||
if not force and fingerprint and fingerprint == data.get("bundle_fingerprint"):
|
||||
return {
|
||||
"ok": True,
|
||||
**_public_view(data),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"dh_pub_key": str(data.get("dh_pub_key", "")),
|
||||
"dh_algo": str(data.get("dh_algo", "X25519")),
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
signed = sign_wormhole_event(event_type="dm_key", payload=payload)
|
||||
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
accepted, detail, metadata = dm_relay.register_dh_key(
|
||||
signed["node_id"],
|
||||
payload["dh_pub_key"],
|
||||
payload["dh_algo"],
|
||||
payload["timestamp"],
|
||||
signed["signature"],
|
||||
signed["public_key"],
|
||||
signed["public_key_algo"],
|
||||
signed["protocol_version"],
|
||||
signed["sequence"],
|
||||
)
|
||||
if not accepted:
|
||||
return {"ok": False, "detail": detail}
|
||||
|
||||
data = read_wormhole_identity()
|
||||
data["bundle_fingerprint"] = metadata.get("bundle_fingerprint", fingerprint) if metadata else fingerprint
|
||||
data["bundle_sequence"] = _safe_int(
|
||||
metadata.get("accepted_sequence", signed["sequence"]) if metadata else signed["sequence"],
|
||||
_safe_int(signed.get("sequence", 0), 0),
|
||||
)
|
||||
data["bundle_registered_at"] = timestamp
|
||||
data["last_dh_timestamp"] = timestamp
|
||||
saved = _write_identity(data)
|
||||
return {
|
||||
"ok": True,
|
||||
**_public_view(saved),
|
||||
**(metadata or {}),
|
||||
}
|
||||
|
||||
|
||||
def get_dm_mailbox_client_secret(*, generate: bool = True) -> str:
|
||||
return ensure_dm_mailbox_client_secret(generate=generate)
|
||||
|
||||
|
||||
def derive_dm_mailbox_token(
|
||||
dm_alias_id: str | None = None,
|
||||
*,
|
||||
generate_secret: bool = True,
|
||||
) -> str:
|
||||
data = read_wormhole_identity()
|
||||
alias_id = str(dm_alias_id or data.get("node_id", "") or "").strip()
|
||||
if not alias_id:
|
||||
return ""
|
||||
secret_b64 = get_dm_mailbox_client_secret(generate=generate_secret)
|
||||
if not secret_b64:
|
||||
return ""
|
||||
try:
|
||||
secret = base64.b64decode(secret_b64.encode("ascii"))
|
||||
except Exception:
|
||||
return ""
|
||||
return hmac.new(secret, alias_id.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
"""Wormhole-managed prekey bundles and X3DH-style bootstrap helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
||||
|
||||
from services.mesh.mesh_crypto import derive_node_id
|
||||
from services.mesh.mesh_wormhole_identity import (
|
||||
_write_identity,
|
||||
bootstrap_wormhole_identity,
|
||||
read_wormhole_identity,
|
||||
sign_wormhole_event,
|
||||
sign_wormhole_message,
|
||||
)
|
||||
|
||||
PREKEY_TARGET = 8
|
||||
PREKEY_MIN_THRESHOLD = 3
|
||||
PREKEY_MAX_THRESHOLD = 5
|
||||
PREKEY_MIN_TARGET = 7
|
||||
PREKEY_MAX_TARGET = 9
|
||||
PREKEY_MIN_REPUBLISH_DELAY_S = 45
|
||||
PREKEY_MAX_REPUBLISH_DELAY_S = 120
|
||||
PREKEY_REPUBLISH_THRESHOLD_RANGE = (PREKEY_MIN_THRESHOLD, PREKEY_MAX_THRESHOLD)
|
||||
PREKEY_REPUBLISH_TARGET_RANGE = (PREKEY_MIN_TARGET, PREKEY_MAX_TARGET)
|
||||
PREKEY_REPUBLISH_DELAY_RANGE_S = (PREKEY_MIN_REPUBLISH_DELAY_S, PREKEY_MAX_REPUBLISH_DELAY_S)
|
||||
SIGNED_PREKEY_ROTATE_AFTER_S = 24 * 60 * 60
|
||||
SIGNED_PREKEY_GRACE_S = 3 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _safe_int(val, default=0) -> int:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _x25519_pair() -> dict[str, str]:
|
||||
priv = x25519.X25519PrivateKey.generate()
|
||||
priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||
pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return {"public_key": _b64(pub_raw), "private_key": _b64(priv_raw)}
|
||||
|
||||
|
||||
def _derive(priv_b64: str, pub_b64: str) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(priv_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(pub_b64))
|
||||
return priv.exchange(pub)
|
||||
|
||||
|
||||
def _hkdf(ikm: bytes, info: str, length: int = 32) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=b"\xff" * 32,
|
||||
info=info.encode("utf-8"),
|
||||
).derive(ikm)
|
||||
|
||||
|
||||
def _bundle_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
one_time_prekeys = [
|
||||
{
|
||||
"prekey_id": _safe_int(item.get("prekey_id", 0) or 0),
|
||||
"public_key": str(item.get("public_key", "") or ""),
|
||||
}
|
||||
for item in list(data.get("one_time_prekeys") or [])
|
||||
if item.get("public_key")
|
||||
]
|
||||
return {
|
||||
"identity_dh_pub_key": str(data.get("dh_pub_key", "") or ""),
|
||||
"dh_algo": str(data.get("dh_algo", "X25519") or "X25519"),
|
||||
"signed_prekey_id": _safe_int(data.get("signed_prekey_id", 0) or 0),
|
||||
"signed_prekey_pub": str(data.get("signed_prekey_pub", "") or ""),
|
||||
"signed_prekey_signature": str(data.get("signed_prekey_signature", "") or ""),
|
||||
"signed_prekey_timestamp": _safe_int(data.get("signed_prekey_generated_at", 0) or 0),
|
||||
"signed_at": _safe_int(data.get("prekey_bundle_signed_at", 0) or 0),
|
||||
"bundle_signature": str(data.get("prekey_bundle_signature", "") or ""),
|
||||
"mls_key_package": str(data.get("mls_key_package", "") or ""),
|
||||
"one_time_prekeys": one_time_prekeys,
|
||||
"one_time_prekey_count": len(one_time_prekeys),
|
||||
}
|
||||
|
||||
|
||||
def _bundle_signature_payload(data: dict[str, Any]) -> str:
|
||||
# OTK binding: One-time key hashes are included in the bundle signature
|
||||
# as of Sprint 12 (S12-3). Relay substitution of OTKs will now break
|
||||
# the bundle signature and be rejected by verify_prekey_bundle().
|
||||
otk_hashes = sorted(
|
||||
hashlib.sha256(str(item.get("public_key", "")).encode("utf-8")).hexdigest()
|
||||
for item in (data.get("one_time_prekeys") or [])
|
||||
)
|
||||
return _stable_json(
|
||||
{
|
||||
"identity_dh_pub_key": str(data.get("identity_dh_pub_key", "") or ""),
|
||||
"dh_algo": str(data.get("dh_algo", "X25519") or "X25519"),
|
||||
"signed_prekey_id": _safe_int(data.get("signed_prekey_id", 0) or 0),
|
||||
"signed_prekey_pub": str(data.get("signed_prekey_pub", "") or ""),
|
||||
"signed_prekey_signature": str(data.get("signed_prekey_signature", "") or ""),
|
||||
"signed_at": _safe_int(data.get("signed_at", 0) or 0),
|
||||
"mls_key_package": str(data.get("mls_key_package", "") or ""),
|
||||
"one_time_prekey_hashes": otk_hashes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _max_prekey_bundle_age_s() -> int:
|
||||
return SIGNED_PREKEY_ROTATE_AFTER_S + SIGNED_PREKEY_GRACE_S
|
||||
|
||||
|
||||
def trust_fingerprint_for_bundle_record(record: dict[str, Any]) -> str:
|
||||
bundle = dict(record.get("bundle") or record or {})
|
||||
material = {
|
||||
"agent_id": str(record.get("agent_id", "") or ""),
|
||||
"identity_dh_pub_key": str(bundle.get("identity_dh_pub_key", "") or ""),
|
||||
"dh_algo": str(bundle.get("dh_algo", "X25519") or "X25519"),
|
||||
"public_key": str(record.get("public_key", "") or ""),
|
||||
"public_key_algo": str(record.get("public_key_algo", "") or ""),
|
||||
"protocol_version": str(record.get("protocol_version", "") or ""),
|
||||
}
|
||||
return hashlib.sha256(_stable_json(material).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _attach_bundle_signature(bundle: dict[str, Any], *, signed_at: int | None = None) -> dict[str, Any]:
|
||||
# KNOWN LIMITATION: Bundle signature is self-signed by the identity key it contains.
|
||||
# This proves possession of the private key and detects post-registration tampering,
|
||||
# but cannot prevent initial impersonation (no external PKI). Mitigated by reputation
|
||||
# system in Phase 9 (Oracle Rep). See threat-model.md for full analysis.
|
||||
payload = dict(bundle or {})
|
||||
payload["signed_at"] = int(signed_at if signed_at is not None else time.time())
|
||||
signed = sign_wormhole_message(_bundle_signature_payload(payload))
|
||||
payload["bundle_signature"] = str(signed.get("signature", "") or "")
|
||||
return payload
|
||||
|
||||
|
||||
def _verify_bundle_signature(bundle: dict[str, Any], public_key: str) -> tuple[bool, str]:
|
||||
try:
|
||||
signing_pub = ed25519.Ed25519PublicKey.from_public_bytes(_unb64(public_key))
|
||||
signing_pub.verify(
|
||||
bytes.fromhex(str(bundle.get("bundle_signature", "") or "")),
|
||||
_bundle_signature_payload(bundle).encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False, "Prekey bundle signature invalid"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _validate_bundle_record(record: dict[str, Any]) -> tuple[bool, str]:
|
||||
bundle = dict(record.get("bundle") or {})
|
||||
now = time.time()
|
||||
signed_at = _safe_int(bundle.get("signed_at", 0) or 0)
|
||||
if signed_at <= 0:
|
||||
return False, "Prekey bundle missing signed_at"
|
||||
if signed_at > now + 299:
|
||||
return False, "Prekey bundle signed_at is in the future"
|
||||
if not str(bundle.get("bundle_signature", "") or "").strip():
|
||||
return False, "Prekey bundle missing bundle_signature"
|
||||
public_key = str(record.get("public_key", "") or "")
|
||||
if not public_key:
|
||||
return False, "Prekey bundle missing signing key"
|
||||
ok, reason = _verify_bundle_signature(bundle, public_key)
|
||||
if not ok:
|
||||
return False, reason
|
||||
if (now - signed_at) > _max_prekey_bundle_age_s():
|
||||
return False, "Prekey bundle is stale"
|
||||
if str(record.get("agent_id", "") or "").strip():
|
||||
derived = derive_node_id(public_key)
|
||||
if derived != str(record.get("agent_id", "") or "").strip():
|
||||
return False, "Prekey bundle public key binding mismatch"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _jittered_republish_policy(data: dict[str, Any], *, reset: bool = False) -> tuple[int, int]:
|
||||
threshold = _safe_int(data.get("prekey_republish_threshold", 0) or 0)
|
||||
target = _safe_int(data.get("prekey_republish_target", 0) or 0)
|
||||
min_threshold, max_threshold = PREKEY_REPUBLISH_THRESHOLD_RANGE
|
||||
min_target, max_target = PREKEY_REPUBLISH_TARGET_RANGE
|
||||
if reset or threshold < min_threshold or threshold > max_threshold:
|
||||
threshold = random.randint(min_threshold, max_threshold)
|
||||
data["prekey_republish_threshold"] = threshold
|
||||
if reset or target < min_target or target > max_target:
|
||||
target = random.randint(min_target, max_target)
|
||||
data["prekey_republish_target"] = target
|
||||
return threshold, target
|
||||
|
||||
|
||||
def _schedule_next_republish_window(data: dict[str, Any]) -> None:
|
||||
min_delay_s, max_delay_s = PREKEY_REPUBLISH_DELAY_RANGE_S
|
||||
data["prekey_next_republish_after"] = int(
|
||||
time.time() + random.randint(min_delay_s, max_delay_s)
|
||||
)
|
||||
|
||||
|
||||
def _archive_current_signed_prekey(data: dict[str, Any], retired_at: int) -> None:
|
||||
current_id = _safe_int(data.get("signed_prekey_id", 0) or 0)
|
||||
current_pub = str(data.get("signed_prekey_pub", "") or "")
|
||||
current_priv = str(data.get("signed_prekey_priv", "") or "")
|
||||
current_sig = str(data.get("signed_prekey_signature", "") or "")
|
||||
current_generated_at = _safe_int(data.get("signed_prekey_generated_at", 0) or 0)
|
||||
if not current_id or not current_pub or not current_priv:
|
||||
return
|
||||
history = list(data.get("signed_prekey_history") or [])
|
||||
history.append(
|
||||
{
|
||||
"signed_prekey_id": current_id,
|
||||
"signed_prekey_pub": current_pub,
|
||||
"signed_prekey_priv": current_priv,
|
||||
"signed_prekey_signature": current_sig,
|
||||
"signed_prekey_generated_at": current_generated_at,
|
||||
"retired_at": retired_at,
|
||||
}
|
||||
)
|
||||
cutoff = retired_at - SIGNED_PREKEY_GRACE_S
|
||||
data["signed_prekey_history"] = [
|
||||
item
|
||||
for item in history[-4:]
|
||||
if _safe_int(item.get("retired_at", retired_at) or retired_at) >= cutoff
|
||||
]
|
||||
|
||||
|
||||
def _find_signed_prekey_private(data: dict[str, Any], spk_id: int) -> str:
|
||||
if _safe_int(data.get("signed_prekey_id", 0) or 0) == spk_id:
|
||||
return str(data.get("signed_prekey_priv", "") or "")
|
||||
for item in list(data.get("signed_prekey_history") or []):
|
||||
if _safe_int(item.get("signed_prekey_id", 0) or 0) == spk_id:
|
||||
return str(item.get("signed_prekey_priv", "") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def ensure_wormhole_prekeys(force_signed_prekey: bool = False, replenish_target: int = PREKEY_TARGET) -> dict[str, Any]:
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
data = read_wormhole_identity()
|
||||
|
||||
changed = False
|
||||
now = int(time.time())
|
||||
_, jitter_target = _jittered_republish_policy(data)
|
||||
replenish_target = max(1, _safe_int(replenish_target or jitter_target, 1))
|
||||
|
||||
spk_generated_at = _safe_int(data.get("signed_prekey_generated_at", 0) or 0)
|
||||
spk_too_old = bool(spk_generated_at and (now - spk_generated_at) >= SIGNED_PREKEY_ROTATE_AFTER_S)
|
||||
if force_signed_prekey or spk_too_old or not data.get("signed_prekey_pub") or not data.get("signed_prekey_priv"):
|
||||
_archive_current_signed_prekey(data, now)
|
||||
pair = _x25519_pair()
|
||||
spk_id = _safe_int(data.get("signed_prekey_id", 0) or 0) + 1
|
||||
signed_prekey_payload = {
|
||||
"signed_prekey_id": spk_id,
|
||||
"signed_prekey_pub": pair["public_key"],
|
||||
"signed_prekey_timestamp": now,
|
||||
}
|
||||
signed = sign_wormhole_event(
|
||||
event_type="dm_signed_prekey",
|
||||
payload=signed_prekey_payload,
|
||||
)
|
||||
data["signed_prekey_id"] = spk_id
|
||||
data["signed_prekey_pub"] = pair["public_key"]
|
||||
data["signed_prekey_priv"] = pair["private_key"]
|
||||
data["signed_prekey_signature"] = signed["signature"]
|
||||
data["signed_prekey_generated_at"] = now
|
||||
changed = True
|
||||
|
||||
existing_otks = list(data.get("one_time_prekeys") or [])
|
||||
next_id = max([_safe_int(item.get("prekey_id", 0) or 0) for item in existing_otks] + [0])
|
||||
while len(existing_otks) < max(1, replenish_target):
|
||||
next_id += 1
|
||||
pair = _x25519_pair()
|
||||
existing_otks.append(
|
||||
{
|
||||
"prekey_id": next_id,
|
||||
"public_key": pair["public_key"],
|
||||
"private_key": pair["private_key"],
|
||||
"created_at": now,
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
data["one_time_prekeys"] = existing_otks
|
||||
_jittered_republish_policy(data)
|
||||
|
||||
if changed:
|
||||
_write_identity(data)
|
||||
return _bundle_payload(data)
|
||||
|
||||
|
||||
def register_wormhole_prekey_bundle(force_signed_prekey: bool = False) -> dict[str, Any]:
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
data = read_wormhole_identity()
|
||||
|
||||
_, jitter_target = _jittered_republish_policy(data, reset=force_signed_prekey)
|
||||
if force_signed_prekey:
|
||||
_schedule_next_republish_window(data)
|
||||
_write_identity(data)
|
||||
data = read_wormhole_identity()
|
||||
|
||||
bundle = ensure_wormhole_prekeys(force_signed_prekey=force_signed_prekey, replenish_target=jitter_target)
|
||||
from services.mesh.mesh_dm_mls import export_dm_key_package_for_alias
|
||||
|
||||
mls_key_package = export_dm_key_package_for_alias(str(data.get("node_id", "") or ""))
|
||||
if not mls_key_package.get("ok"):
|
||||
return {"ok": False, "detail": str(mls_key_package.get("detail", "") or "mls key package unavailable")}
|
||||
bundle["mls_key_package"] = str(mls_key_package.get("mls_key_package", "") or "")
|
||||
bundle = _attach_bundle_signature(bundle)
|
||||
signed = sign_wormhole_event(
|
||||
event_type="dm_prekey_bundle",
|
||||
payload=bundle,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
accepted, detail, metadata = dm_relay.register_prekey_bundle(
|
||||
signed["node_id"],
|
||||
bundle,
|
||||
signed["signature"],
|
||||
signed["public_key"],
|
||||
signed["public_key_algo"],
|
||||
signed["protocol_version"],
|
||||
signed["sequence"],
|
||||
)
|
||||
if not accepted:
|
||||
return {"ok": False, "detail": detail}
|
||||
refreshed = read_wormhole_identity()
|
||||
refreshed["prekey_bundle_registered_at"] = int(time.time())
|
||||
refreshed["prekey_bundle_signed_at"] = _safe_int(bundle.get("signed_at", 0) or 0)
|
||||
refreshed["prekey_bundle_signature"] = str(bundle.get("bundle_signature", "") or "")
|
||||
_schedule_next_republish_window(refreshed)
|
||||
_jittered_republish_policy(refreshed, reset=True)
|
||||
_write_identity(refreshed)
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_id": signed["node_id"],
|
||||
"bundle": bundle,
|
||||
"signature": signed["signature"],
|
||||
"public_key": signed["public_key"],
|
||||
"public_key_algo": signed["public_key_algo"],
|
||||
"protocol_version": signed["protocol_version"],
|
||||
"sequence": signed["sequence"],
|
||||
**(metadata or {}),
|
||||
}
|
||||
|
||||
|
||||
def fetch_dm_prekey_bundle(agent_id: str) -> dict[str, Any]:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
stored = dm_relay.get_prekey_bundle(agent_id)
|
||||
if not stored:
|
||||
return {"ok": False, "detail": "Prekey bundle not found"}
|
||||
validated_record = {**dict(stored), "agent_id": str(agent_id or "").strip()}
|
||||
ok, reason = _validate_bundle_record(validated_record)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": reason}
|
||||
bundle = dict(stored.get("bundle") or {})
|
||||
bundle["one_time_prekeys"] = []
|
||||
bundle["one_time_prekey_count"] = _safe_int(bundle.get("one_time_prekey_count", 0) or 0)
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_id": agent_id,
|
||||
**bundle,
|
||||
"signature": str(stored.get("signature", "") or ""),
|
||||
"public_key": str(stored.get("public_key", "") or ""),
|
||||
"public_key_algo": str(stored.get("public_key_algo", "") or ""),
|
||||
"protocol_version": str(stored.get("protocol_version", "") or ""),
|
||||
"sequence": _safe_int(stored.get("sequence", 0) or 0),
|
||||
"trust_fingerprint": trust_fingerprint_for_bundle_record(validated_record),
|
||||
}
|
||||
|
||||
|
||||
def _consume_local_one_time_prekey(prekey_id: int) -> int:
|
||||
if prekey_id <= 0:
|
||||
data = read_wormhole_identity()
|
||||
return len(list(data.get("one_time_prekeys") or []))
|
||||
data = read_wormhole_identity()
|
||||
existing = list(data.get("one_time_prekeys") or [])
|
||||
filtered = [
|
||||
item for item in existing if _safe_int(item.get("prekey_id", 0) or 0) != _safe_int(prekey_id)
|
||||
]
|
||||
if len(filtered) == len(existing):
|
||||
return len(existing)
|
||||
data["one_time_prekeys"] = filtered
|
||||
_write_identity(data)
|
||||
return len(filtered)
|
||||
|
||||
|
||||
def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
stored = dm_relay.get_prekey_bundle(peer_id)
|
||||
if not stored:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
validated_record = {**dict(stored), "agent_id": str(peer_id or "").strip()}
|
||||
ok, reason = _validate_bundle_record(validated_record)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": reason}
|
||||
peer_bundle_stored = dm_relay.consume_one_time_prekey(peer_id)
|
||||
if not peer_bundle_stored:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
peer_bundle = dict(peer_bundle_stored.get("bundle") or {})
|
||||
peer_static = str(peer_bundle.get("identity_dh_pub_key", "") or "")
|
||||
peer_spk = str(peer_bundle.get("signed_prekey_pub", "") or "")
|
||||
peer_spk_id = _safe_int(peer_bundle.get("signed_prekey_id", 0) or 0)
|
||||
peer_otk = dict(peer_bundle_stored.get("claimed_one_time_prekey") or {})
|
||||
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
data = read_wormhole_identity()
|
||||
my_static_priv = str(data.get("dh_private_key", "") or "")
|
||||
my_static_pub = str(data.get("dh_pub_key", "") or "")
|
||||
if not my_static_priv or not my_static_pub or not peer_static or not peer_spk:
|
||||
return {"ok": False, "detail": "Missing static or signed prekey material"}
|
||||
|
||||
eph = _x25519_pair()
|
||||
dh_parts = [
|
||||
_derive(my_static_priv, peer_spk),
|
||||
_derive(eph["private_key"], peer_static),
|
||||
_derive(eph["private_key"], peer_spk),
|
||||
]
|
||||
otk_id = 0
|
||||
if peer_otk and peer_otk.get("public_key"):
|
||||
dh_parts.append(_derive(eph["private_key"], str(peer_otk.get("public_key"))))
|
||||
otk_id = _safe_int(peer_otk.get("prekey_id", 0) or 0)
|
||||
secret = _hkdf(b"".join(dh_parts), "SB-X3DH", 32)
|
||||
header = {
|
||||
"v": 1,
|
||||
"alg": "X25519",
|
||||
"ik_pub": my_static_pub,
|
||||
"ek_pub": eph["public_key"],
|
||||
"spk_id": peer_spk_id,
|
||||
"otk_id": otk_id,
|
||||
}
|
||||
aad = _stable_json(header).encode("utf-8")
|
||||
iv = os.urandom(12)
|
||||
ciphertext = AESGCM(secret).encrypt(iv, plaintext.encode("utf-8"), aad)
|
||||
envelope = {
|
||||
"h": header,
|
||||
"ct": _b64(iv + ciphertext),
|
||||
}
|
||||
wrapped = _b64(_stable_json(envelope).encode("utf-8"))
|
||||
return {"ok": True, "result": f"x3dh1:{wrapped}"}
|
||||
|
||||
|
||||
def bootstrap_decrypt_from_sender(sender_id: str, ciphertext: str) -> dict[str, Any]:
|
||||
if not ciphertext.startswith("x3dh1:"):
|
||||
return {"ok": False, "detail": "legacy"}
|
||||
try:
|
||||
raw = ciphertext[len("x3dh1:") :]
|
||||
envelope = json.loads(_unb64(raw).decode("utf-8"))
|
||||
header = dict(envelope.get("h") or {})
|
||||
combined = _unb64(str(envelope.get("ct") or ""))
|
||||
my_data = read_wormhole_identity()
|
||||
if not my_data.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
my_data = read_wormhole_identity()
|
||||
|
||||
sender_static_pub = str(header.get("ik_pub", "") or "")
|
||||
sender_eph_pub = str(header.get("ek_pub", "") or "")
|
||||
spk_id = _safe_int(header.get("spk_id", 0) or 0)
|
||||
otk_id = _safe_int(header.get("otk_id", 0) or 0)
|
||||
if not sender_static_pub or not sender_eph_pub:
|
||||
return {"ok": False, "detail": "Missing sender bootstrap keys"}
|
||||
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
sender_dh = dm_relay.get_dh_key(sender_id)
|
||||
if sender_dh and sender_dh.get("dh_pub_key") and str(sender_dh.get("dh_pub_key")) != sender_static_pub:
|
||||
return {"ok": False, "detail": "Sender static DH key mismatch"}
|
||||
|
||||
signed_prekey_priv = _find_signed_prekey_private(my_data, spk_id)
|
||||
my_static_priv = str(my_data.get("dh_private_key", "") or "")
|
||||
if not signed_prekey_priv or not my_static_priv:
|
||||
return {"ok": False, "detail": "Missing local bootstrap private keys"}
|
||||
|
||||
dh_parts = [
|
||||
_derive(signed_prekey_priv, sender_static_pub),
|
||||
_derive(my_static_priv, sender_eph_pub),
|
||||
_derive(signed_prekey_priv, sender_eph_pub),
|
||||
]
|
||||
if otk_id:
|
||||
otk_match = next(
|
||||
(
|
||||
item
|
||||
for item in list(my_data.get("one_time_prekeys") or [])
|
||||
if _safe_int(item.get("prekey_id", 0) or 0) == otk_id and item.get("private_key")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not otk_match:
|
||||
return {"ok": False, "detail": "One-time prekey mismatch"}
|
||||
dh_parts.append(_derive(str(otk_match.get("private_key", "")), sender_eph_pub))
|
||||
|
||||
secret = _hkdf(b"".join(dh_parts), "SB-X3DH", 32)
|
||||
aad = _stable_json(header).encode("utf-8")
|
||||
iv = combined[:12]
|
||||
ct = combined[12:]
|
||||
plaintext = AESGCM(secret).decrypt(iv, ct, aad).decode("utf-8")
|
||||
if otk_id:
|
||||
remaining_otks = _consume_local_one_time_prekey(otk_id)
|
||||
my_data = read_wormhole_identity()
|
||||
threshold, target = _jittered_republish_policy(my_data)
|
||||
next_republish_after = _safe_int(my_data.get("prekey_next_republish_after", 0) or 0)
|
||||
now_ts = int(time.time())
|
||||
should_republish = remaining_otks <= 0
|
||||
if not should_republish and remaining_otks <= threshold and now_ts >= next_republish_after:
|
||||
should_republish = True
|
||||
if should_republish:
|
||||
register_wormhole_prekey_bundle()
|
||||
else:
|
||||
_write_identity(my_data)
|
||||
return {"ok": True, "result": plaintext}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "bootstrap_decrypt_failed"}
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Wormhole-backed DM ratchet state and crypto.
|
||||
|
||||
This is the first DM custody move out of the browser. When Wormhole is active,
|
||||
the frontend no longer persists ratchet/session state in IndexedDB; instead the
|
||||
agent owns the session records and performs ratchet encrypt/decrypt operations
|
||||
locally on behalf of the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, hmac
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, PublicFormat, NoEncryption
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import bootstrap_wormhole_identity, read_wormhole_identity
|
||||
from services.mesh.mesh_secure_storage import read_secure_json, write_secure_json
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
STATE_FILE = DATA_DIR / "wormhole_dm_ratchet.json"
|
||||
STATE_LOCK = threading.RLock()
|
||||
|
||||
MAX_SKIP = 32
|
||||
PAD_BUCKET = 1024
|
||||
PAD_STEP = 512
|
||||
PAD_MAX = 4096
|
||||
PAD_MAGIC = "SBP1"
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def _zero_bytes(length: int) -> bytes:
|
||||
return bytes([0] * length)
|
||||
|
||||
|
||||
def _stable_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _header_aad(header: dict[str, Any]) -> bytes:
|
||||
return _stable_json(header).encode("utf-8")
|
||||
|
||||
|
||||
def _build_padded_payload(plaintext: str) -> bytes:
|
||||
data = plaintext.encode("utf-8")
|
||||
length = len(data)
|
||||
target = PAD_BUCKET
|
||||
if length + 6 > target:
|
||||
target = ((length + 6 + PAD_STEP - 1) // PAD_STEP) * PAD_STEP
|
||||
if target > PAD_MAX:
|
||||
target = ((length + 6 + PAD_STEP - 1) // PAD_STEP) * PAD_STEP
|
||||
out = bytearray(target)
|
||||
out[0:4] = PAD_MAGIC.encode("utf-8")
|
||||
out[4] = (length >> 8) & 0xFF
|
||||
out[5] = length & 0xFF
|
||||
out[6 : 6 + length] = data
|
||||
if target > length + 6:
|
||||
out[6 + length :] = os.urandom(target - (6 + length))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _unpad_payload(data: bytes) -> str:
|
||||
if len(data) < 6:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
magic = data[:4].decode("utf-8", errors="ignore")
|
||||
if magic != PAD_MAGIC:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
length = (data[4] << 8) + data[5]
|
||||
if length <= 0 or 6 + length > len(data):
|
||||
return data.decode("utf-8", errors="replace")
|
||||
return data[6 : 6 + length].decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _load_all_states() -> dict[str, dict[str, Any]]:
|
||||
with STATE_LOCK:
|
||||
try:
|
||||
raw = read_secure_json(STATE_FILE, lambda: {})
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Wormhole ratchet state could not be decrypted — starting fresh"
|
||||
)
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {str(k): dict(v) for k, v in raw.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def _save_all_states(states: dict[str, dict[str, Any]]) -> None:
|
||||
with STATE_LOCK:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
write_secure_json(STATE_FILE, states)
|
||||
|
||||
|
||||
def _get_state(peer_id: str) -> dict[str, Any] | None:
|
||||
return _load_all_states().get(peer_id)
|
||||
|
||||
|
||||
def _set_state(peer_id: str, state: dict[str, Any]) -> None:
|
||||
states = _load_all_states()
|
||||
states[peer_id] = state
|
||||
_save_all_states(states)
|
||||
|
||||
|
||||
def reset_wormhole_dm_ratchet(peer_id: str | None = None) -> dict[str, Any]:
|
||||
if peer_id:
|
||||
states = _load_all_states()
|
||||
states.pop(peer_id, None)
|
||||
_save_all_states(states)
|
||||
else:
|
||||
_save_all_states({})
|
||||
return {"ok": True, "peer_id": peer_id or "", "cleared_all": not bool(peer_id)}
|
||||
|
||||
|
||||
def _generate_ratchet_key_pair() -> dict[str, str]:
|
||||
priv = x25519.X25519PrivateKey.generate()
|
||||
priv_raw = priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||
pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return {
|
||||
"pub": _b64(pub_raw),
|
||||
"priv": _b64(priv_raw),
|
||||
"algo": "X25519",
|
||||
}
|
||||
|
||||
|
||||
def _derive_dh_secret(priv_b64: str, their_pub_b64: str) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(priv_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(their_pub_b64))
|
||||
return priv.exchange(pub)
|
||||
|
||||
|
||||
def _wormhole_long_term_dh_priv_b64() -> str:
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("bootstrapped") or not data.get("dh_private_key"):
|
||||
bootstrap_wormhole_identity()
|
||||
data = read_wormhole_identity()
|
||||
return str(data.get("dh_private_key", "") or "")
|
||||
|
||||
|
||||
def _hkdf(ikm: bytes, salt: bytes, info: str, length: int) -> bytes:
|
||||
derived = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=salt,
|
||||
info=info.encode("utf-8"),
|
||||
).derive(ikm)
|
||||
return bytes(derived)
|
||||
|
||||
|
||||
def _kdf_rk(rk: bytes, dh_out: bytes) -> tuple[bytes, bytes]:
|
||||
salt = rk if rk else _zero_bytes(32)
|
||||
out = _hkdf(dh_out, salt, "SB-DR-RK", 64)
|
||||
return out[:32], out[32:64]
|
||||
|
||||
|
||||
def _hmac_sha256(key_bytes: bytes, data: bytes) -> bytes:
|
||||
mac = hmac.HMAC(key_bytes, hashes.SHA256())
|
||||
mac.update(data)
|
||||
return bytes(mac.finalize())
|
||||
|
||||
|
||||
def _kdf_ck(ck: bytes) -> tuple[bytes, bytes]:
|
||||
mk = _hmac_sha256(ck, b"\x01")
|
||||
next_ck = _hmac_sha256(ck, b"\x02")
|
||||
return next_ck, mk
|
||||
|
||||
|
||||
def _aes_gcm_encrypt(mk: bytes, plaintext: str, aad: bytes) -> str:
|
||||
iv = os.urandom(12)
|
||||
aes = AESGCM(mk)
|
||||
encoded = _build_padded_payload(plaintext)
|
||||
ciphertext = aes.encrypt(iv, encoded, aad)
|
||||
return _b64(iv + ciphertext)
|
||||
|
||||
|
||||
def _aes_gcm_decrypt(mk: bytes, ciphertext_b64: str, aad: bytes) -> str:
|
||||
combined = _unb64(ciphertext_b64)
|
||||
iv = combined[:12]
|
||||
ciphertext = combined[12:]
|
||||
aes = AESGCM(mk)
|
||||
plaintext = aes.decrypt(iv, ciphertext, aad)
|
||||
return _unpad_payload(bytes(plaintext))
|
||||
|
||||
|
||||
def _skip_message_keys(state: dict[str, Any], until: int) -> None:
|
||||
if not state.get("ckr"):
|
||||
return
|
||||
skipped = dict(state.get("skipped") or {})
|
||||
while int(state.get("nr", 0) or 0) < until:
|
||||
next_ck, mk = _kdf_ck(_unb64(str(state["ckr"])))
|
||||
key_id = f"{state.get('dhRemote', '')}:{int(state.get('nr', 0) or 0)}"
|
||||
if len(skipped) < MAX_SKIP:
|
||||
skipped[key_id] = _b64(mk)
|
||||
state["ckr"] = _b64(next_ck)
|
||||
state["nr"] = int(state.get("nr", 0) or 0) + 1
|
||||
state["skipped"] = skipped
|
||||
|
||||
|
||||
def _dh_ratchet(state: dict[str, Any], remote_dh: str, pn: int) -> dict[str, Any]:
|
||||
_skip_message_keys(state, pn)
|
||||
state["pn"] = int(state.get("ns", 0) or 0)
|
||||
state["ns"] = 0
|
||||
state["nr"] = 0
|
||||
state["dhRemote"] = remote_dh
|
||||
|
||||
rk_bytes = _unb64(str(state.get("rk", "") or "")) or _zero_bytes(32)
|
||||
dh_out_1 = _derive_dh_secret(str(state.get("dhSelfPriv", "")), str(state.get("dhRemote", "")))
|
||||
rk_1, ck_r = _kdf_rk(rk_bytes, dh_out_1)
|
||||
state["rk"] = _b64(rk_1)
|
||||
state["ckr"] = _b64(ck_r)
|
||||
|
||||
fresh = _generate_ratchet_key_pair()
|
||||
state["dhSelfPub"] = fresh["pub"]
|
||||
state["dhSelfPriv"] = fresh["priv"]
|
||||
dh_out_2 = _derive_dh_secret(str(state.get("dhSelfPriv", "")), str(state.get("dhRemote", "")))
|
||||
rk_2, ck_s = _kdf_rk(_unb64(str(state.get("rk", ""))), dh_out_2)
|
||||
state["rk"] = _b64(rk_2)
|
||||
state["cks"] = _b64(ck_s)
|
||||
state["algo"] = "X25519"
|
||||
state["updated"] = int(time.time() * 1000)
|
||||
return state
|
||||
|
||||
|
||||
def _init_sender_state(peer_id: str, their_dh_pub: str) -> dict[str, Any]:
|
||||
fresh = _generate_ratchet_key_pair()
|
||||
dh_out = _derive_dh_secret(fresh["priv"], their_dh_pub)
|
||||
rk, ck = _kdf_rk(_zero_bytes(32), dh_out)
|
||||
return {
|
||||
"algo": "X25519",
|
||||
"rk": _b64(rk),
|
||||
"cks": _b64(ck),
|
||||
"ckr": "",
|
||||
"dhSelfPub": fresh["pub"],
|
||||
"dhSelfPriv": fresh["priv"],
|
||||
"dhRemote": their_dh_pub,
|
||||
"ns": 0,
|
||||
"nr": 0,
|
||||
"pn": 0,
|
||||
"skipped": {},
|
||||
"updated": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def _init_receiver_state(peer_id: str, sender_dh_pub: str) -> dict[str, Any]:
|
||||
long_term_priv = _wormhole_long_term_dh_priv_b64()
|
||||
if not long_term_priv:
|
||||
raise ValueError("missing_long_term_key")
|
||||
dh_out = _derive_dh_secret(long_term_priv, sender_dh_pub)
|
||||
rk, ck = _kdf_rk(_zero_bytes(32), dh_out)
|
||||
fresh = _generate_ratchet_key_pair()
|
||||
return {
|
||||
"algo": "X25519",
|
||||
"rk": _b64(rk),
|
||||
"cks": "",
|
||||
"ckr": _b64(ck),
|
||||
"dhSelfPub": fresh["pub"],
|
||||
"dhSelfPriv": fresh["priv"],
|
||||
"dhRemote": sender_dh_pub,
|
||||
"ns": 0,
|
||||
"nr": 0,
|
||||
"pn": 0,
|
||||
"skipped": {},
|
||||
"updated": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_send_chain(state: dict[str, Any]) -> dict[str, Any]:
|
||||
if state.get("cks"):
|
||||
return state
|
||||
rk_bytes = _unb64(str(state.get("rk", "") or "")) or _zero_bytes(32)
|
||||
dh_out = _derive_dh_secret(str(state.get("dhSelfPriv", "")), str(state.get("dhRemote", "")))
|
||||
rk, ck = _kdf_rk(rk_bytes, dh_out)
|
||||
state["rk"] = _b64(rk)
|
||||
state["cks"] = _b64(ck)
|
||||
state["updated"] = int(time.time() * 1000)
|
||||
return state
|
||||
|
||||
|
||||
def encrypt_wormhole_dm(peer_id: str, peer_dh_pub: str, plaintext: str) -> dict[str, Any]:
|
||||
if not peer_id or not peer_dh_pub:
|
||||
return {"ok": False, "detail": "peer_id and peer_dh_pub are required"}
|
||||
state = _get_state(peer_id)
|
||||
if not state:
|
||||
state = _init_sender_state(peer_id, peer_dh_pub)
|
||||
state = _ensure_send_chain(state)
|
||||
next_ck, mk = _kdf_ck(_unb64(str(state.get("cks", ""))))
|
||||
n = int(state.get("ns", 0) or 0)
|
||||
state["ns"] = n + 1
|
||||
state["cks"] = _b64(next_ck)
|
||||
header = {
|
||||
"v": 2,
|
||||
"dh": str(state.get("dhSelfPub", "")),
|
||||
"pn": int(state.get("pn", 0) or 0),
|
||||
"n": n,
|
||||
"alg": "X25519",
|
||||
}
|
||||
ct = _aes_gcm_encrypt(mk, plaintext, _header_aad(header))
|
||||
wrapped = _b64(_stable_json({"h": header, "ct": ct}).encode("utf-8"))
|
||||
state["updated"] = int(time.time() * 1000)
|
||||
_set_state(peer_id, state)
|
||||
return {"ok": True, "result": f"dr2:{wrapped}"}
|
||||
|
||||
|
||||
def decrypt_wormhole_dm(peer_id: str, ciphertext: str) -> dict[str, Any]:
|
||||
if not ciphertext.startswith("dr2:"):
|
||||
return {"ok": False, "detail": "legacy"}
|
||||
try:
|
||||
raw = ciphertext[4:]
|
||||
payload = json.loads(_unb64(raw).decode("utf-8"))
|
||||
header = dict(payload.get("h") or {})
|
||||
ct = str(payload.get("ct") or "")
|
||||
remote_dh = str(header.get("dh") or "")
|
||||
pn = int(header.get("pn", 0) or 0)
|
||||
n = int(header.get("n", 0) or 0)
|
||||
|
||||
state = _get_state(peer_id)
|
||||
if not state:
|
||||
state = _init_receiver_state(peer_id, remote_dh)
|
||||
|
||||
if remote_dh and remote_dh != str(state.get("dhRemote", "")):
|
||||
state = _dh_ratchet(state, remote_dh, pn)
|
||||
|
||||
skipped = dict(state.get("skipped") or {})
|
||||
skip_key = f"{remote_dh}:{n}"
|
||||
if skip_key in skipped:
|
||||
mk = _unb64(skipped.pop(skip_key))
|
||||
state["skipped"] = skipped
|
||||
_set_state(peer_id, state)
|
||||
return {"ok": True, "result": _aes_gcm_decrypt(mk, ct, _header_aad(header))}
|
||||
|
||||
_skip_message_keys(state, n)
|
||||
if not state.get("ckr"):
|
||||
return {"ok": False, "detail": "no_receive_chain"}
|
||||
next_ck, mk = _kdf_ck(_unb64(str(state.get("ckr", ""))))
|
||||
state["ckr"] = _b64(next_ck)
|
||||
state["nr"] = int(state.get("nr", 0) or 0) + 1
|
||||
state["updated"] = int(time.time() * 1000)
|
||||
_set_state(peer_id, state)
|
||||
return {"ok": True, "result": _aes_gcm_decrypt(mk, ct, _header_aad(header))}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "ratchet_decrypt_failed"}
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Wormhole-owned sender seal helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
||||
|
||||
from services.mesh.mesh_crypto import (
|
||||
parse_public_key_algo,
|
||||
verify_node_binding,
|
||||
verify_signature,
|
||||
)
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_identity import (
|
||||
bootstrap_wormhole_identity,
|
||||
read_wormhole_identity,
|
||||
sign_wormhole_message,
|
||||
)
|
||||
from services.wormhole_settings import read_wormhole_settings
|
||||
|
||||
|
||||
def _unb64(data: str | bytes | None) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return base64.b64decode(data)
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def _derive_aes_key(my_private_b64: str, peer_public_b64: str) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(my_private_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(peer_public_b64))
|
||||
secret = priv.exchange(pub)
|
||||
# For compatibility with the browser path, use the raw 32-byte X25519 secret directly
|
||||
# as the AES-256-GCM key material.
|
||||
return secret
|
||||
|
||||
|
||||
def _seal_salt(recipient_id: str, msg_id: str, extra: str = "") -> bytes:
|
||||
material = f"SB-SEAL-SALT|{recipient_id}|{msg_id}|{PROTOCOL_VERSION}|{extra}".encode("utf-8")
|
||||
digest = hashes.Hash(hashes.SHA256())
|
||||
digest.update(material)
|
||||
return digest.finalize()
|
||||
|
||||
|
||||
def _derive_seal_key_v2(my_private_b64: str, peer_public_b64: str, recipient_id: str, msg_id: str) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(my_private_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(peer_public_b64))
|
||||
secret = priv.exchange(pub)
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_seal_salt(recipient_id, msg_id),
|
||||
info=b"SB-SENDER-SEAL-V2",
|
||||
).derive(secret)
|
||||
|
||||
|
||||
def _x25519_pair() -> tuple[str, str]:
|
||||
priv = x25519.X25519PrivateKey.generate()
|
||||
priv_raw = priv.private_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PrivateFormat.Raw,
|
||||
encryption_algorithm=NoEncryption(),
|
||||
)
|
||||
pub_raw = priv.public_key().public_bytes(
|
||||
encoding=Encoding.Raw,
|
||||
format=PublicFormat.Raw,
|
||||
)
|
||||
return _b64(priv_raw), _b64(pub_raw)
|
||||
|
||||
|
||||
def _derive_seal_key_v3(
|
||||
my_private_b64: str,
|
||||
peer_public_b64: str,
|
||||
recipient_id: str,
|
||||
msg_id: str,
|
||||
ephemeral_pub_b64: str,
|
||||
) -> bytes:
|
||||
priv = x25519.X25519PrivateKey.from_private_bytes(_unb64(my_private_b64))
|
||||
pub = x25519.X25519PublicKey.from_public_bytes(_unb64(peer_public_b64))
|
||||
secret = priv.exchange(pub)
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_seal_salt(recipient_id, msg_id, ephemeral_pub_b64),
|
||||
info=b"SB-SENDER-SEAL-V3",
|
||||
).derive(secret)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _seal_payload_version(sender_seal: str) -> tuple[str, str, str]:
|
||||
value = str(sender_seal or "").strip()
|
||||
if value.startswith("v3:"):
|
||||
_, ephemeral_pub, encoded = value.split(":", 2)
|
||||
return "v3", ephemeral_pub, encoded
|
||||
if value.startswith("v2:"):
|
||||
return "v2", "", value[3:]
|
||||
return "legacy", "", value
|
||||
|
||||
|
||||
def _legacy_seal_allowed() -> bool:
|
||||
try:
|
||||
settings = read_wormhole_settings()
|
||||
if bool(settings.get("enabled")) or bool(settings.get("anonymous_mode")):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def build_sender_seal(
|
||||
*,
|
||||
recipient_id: str,
|
||||
recipient_dh_pub: str,
|
||||
msg_id: str,
|
||||
timestamp: int,
|
||||
) -> dict[str, Any]:
|
||||
recipient_id = str(recipient_id or "").strip()
|
||||
recipient_dh_pub = str(recipient_dh_pub or "").strip()
|
||||
msg_id = str(msg_id or "").strip()
|
||||
timestamp = int(timestamp or 0)
|
||||
if not recipient_id or not recipient_dh_pub or not msg_id or timestamp <= 0:
|
||||
return {"ok": False, "detail": "recipient_id, recipient_dh_pub, msg_id, and timestamp required"}
|
||||
|
||||
identity = read_wormhole_identity()
|
||||
if not identity.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
identity = read_wormhole_identity()
|
||||
my_private = str(identity.get("dh_private_key", "") or "")
|
||||
if not my_private:
|
||||
return {"ok": False, "detail": "Missing Wormhole DH private key"}
|
||||
|
||||
try:
|
||||
ephemeral_private, ephemeral_public = _x25519_pair()
|
||||
signed = sign_wormhole_message(
|
||||
f"seal|v3|{msg_id}|{timestamp}|{recipient_id}|{ephemeral_public}"
|
||||
)
|
||||
if not verify_node_binding(
|
||||
str(signed.get("node_id", "") or ""),
|
||||
str(signed.get("public_key", "") or ""),
|
||||
):
|
||||
return {"ok": False, "detail": "Sender seal node binding failed"}
|
||||
key = _derive_seal_key_v3(
|
||||
ephemeral_private,
|
||||
recipient_dh_pub,
|
||||
recipient_id,
|
||||
msg_id,
|
||||
ephemeral_public,
|
||||
)
|
||||
plaintext = json.dumps(
|
||||
{
|
||||
"seal_version": "v3",
|
||||
"ephemeral_pub_key": ephemeral_public,
|
||||
"sender_id": str(signed.get("node_id", "") or ""),
|
||||
"public_key": str(signed.get("public_key", "") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo", "") or ""),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"signature": str(signed.get("signature", "") or ""),
|
||||
"protocol_version": str(signed.get("protocol_version", "") or ""),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
iv = _b64(os.urandom(12))
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "sender_seal_build_failed"}
|
||||
|
||||
iv_bytes = _unb64(iv)
|
||||
ciphertext = AESGCM(key).encrypt(iv_bytes, plaintext, None)
|
||||
combined = iv_bytes + ciphertext
|
||||
return {
|
||||
"ok": True,
|
||||
"sender_seal": f"v3:{ephemeral_public}:{_b64(combined)}",
|
||||
"sender_id": str(signed.get("node_id", "") or ""),
|
||||
"public_key": str(signed.get("public_key", "") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo", "") or ""),
|
||||
"protocol_version": str(signed.get("protocol_version", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def open_sender_seal(
|
||||
*,
|
||||
sender_seal: str,
|
||||
candidate_dh_pub: str,
|
||||
recipient_id: str,
|
||||
expected_msg_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if not sender_seal or not candidate_dh_pub or not recipient_id or not expected_msg_id:
|
||||
return {"ok": False, "detail": "Missing sender_seal, candidate_dh_pub, recipient_id, or expected_msg_id"}
|
||||
|
||||
identity = read_wormhole_identity()
|
||||
if not identity.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
identity = read_wormhole_identity()
|
||||
my_private = str(identity.get("dh_private_key", "") or "")
|
||||
if not my_private:
|
||||
return {"ok": False, "detail": "Missing Wormhole DH private key"}
|
||||
|
||||
try:
|
||||
seal_version, ephemeral_pub, encoded = _seal_payload_version(sender_seal)
|
||||
if seal_version == "v3":
|
||||
key = _derive_seal_key_v3(my_private, ephemeral_pub, recipient_id, expected_msg_id, ephemeral_pub)
|
||||
elif seal_version == "v2":
|
||||
key = _derive_seal_key_v2(my_private, candidate_dh_pub, recipient_id, expected_msg_id)
|
||||
else:
|
||||
if not _legacy_seal_allowed():
|
||||
return {"ok": False, "detail": "Legacy sender seals are disabled in hardened modes"}
|
||||
key = _derive_aes_key(my_private, candidate_dh_pub)
|
||||
combined = _unb64(encoded)
|
||||
iv = combined[:12]
|
||||
ciphertext = combined[12:]
|
||||
plaintext = AESGCM(key).decrypt(iv, ciphertext, None).decode("utf-8")
|
||||
seal = json.loads(plaintext)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "sender_seal_decrypt_failed"}
|
||||
|
||||
sender_id = str(seal.get("sender_id", "") or "")
|
||||
public_key = str(seal.get("public_key", "") or "")
|
||||
public_key_algo = str(seal.get("public_key_algo", "") or "")
|
||||
msg_id = str(seal.get("msg_id", "") or "")
|
||||
timestamp = int(seal.get("timestamp", 0) or 0)
|
||||
signature = str(seal.get("signature", "") or "")
|
||||
if not sender_id or not public_key or not public_key_algo or not msg_id or not signature:
|
||||
return {"ok": False, "detail": "Malformed sender seal"}
|
||||
if msg_id != expected_msg_id:
|
||||
return {"ok": False, "detail": "Sender seal message mismatch"}
|
||||
if seal_version == "v3" and str(seal.get("ephemeral_pub_key", "") or "") != ephemeral_pub:
|
||||
return {"ok": False, "detail": "Sender seal ephemeral key mismatch"}
|
||||
|
||||
if not verify_node_binding(sender_id, public_key):
|
||||
return {"ok": True, "sender_id": sender_id, "seal_verified": False}
|
||||
|
||||
algo = parse_public_key_algo(public_key_algo)
|
||||
if not algo:
|
||||
return {"ok": True, "sender_id": sender_id, "seal_verified": False}
|
||||
|
||||
if seal_version == "v3":
|
||||
message = f"seal|v3|{msg_id}|{timestamp}|{recipient_id}|{ephemeral_pub}"
|
||||
else:
|
||||
message = f"seal|{msg_id}|{timestamp}|{recipient_id}"
|
||||
verified = verify_signature(
|
||||
public_key_b64=public_key,
|
||||
public_key_algo=algo,
|
||||
signature_hex=signature,
|
||||
payload=message,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"sender_id": sender_id,
|
||||
"seal_verified": bool(verified),
|
||||
"public_key": public_key,
|
||||
"public_key_algo": public_key_algo,
|
||||
"timestamp": timestamp,
|
||||
"msg_id": msg_id,
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Short-lived Wormhole sender tokens for DM metadata reduction.
|
||||
|
||||
These tokens let the client send a sealed DM without placing the long-term
|
||||
sender id and public key directly into the DM send request body. The token is
|
||||
single-use, recipient-bound, and kept in memory only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import bootstrap_wormhole_identity, read_wormhole_identity
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
|
||||
_SENDER_TOKEN_TTL_S = 5 * 60
|
||||
_sender_tokens: TTLCache[str, dict[str, Any]] = TTLCache(maxsize=2048, ttl=_SENDER_TOKEN_TTL_S)
|
||||
|
||||
|
||||
def _sender_token_hash(token: str) -> str:
|
||||
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _token_binding_hash(recipient_token: str) -> str:
|
||||
return hashlib.sha256((recipient_token or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def issue_wormhole_dm_sender_token(
|
||||
*,
|
||||
recipient_id: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str = "",
|
||||
ttl_seconds: int = _SENDER_TOKEN_TTL_S,
|
||||
) -> dict[str, Any]:
|
||||
recipient_id = str(recipient_id or "").strip()
|
||||
delivery_class = str(delivery_class or "").strip().lower()
|
||||
if delivery_class not in ("request", "shared"):
|
||||
return {"ok": False, "detail": "Invalid delivery_class"}
|
||||
if not recipient_id:
|
||||
return {"ok": False, "detail": "recipient_id required"}
|
||||
if delivery_class == "shared" and not recipient_token:
|
||||
return {"ok": False, "detail": "recipient_token required for shared delivery"}
|
||||
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("bootstrapped"):
|
||||
bootstrap_wormhole_identity()
|
||||
data = read_wormhole_identity()
|
||||
if not data.get("node_id") or not data.get("public_key"):
|
||||
return {"ok": False, "detail": "Wormhole identity unavailable"}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
expires_at = now + max(30, min(int(ttl_seconds or _SENDER_TOKEN_TTL_S), _SENDER_TOKEN_TTL_S))
|
||||
_sender_tokens[token] = {
|
||||
"sender_id": str(data.get("node_id", "")),
|
||||
"public_key": str(data.get("public_key", "")),
|
||||
"public_key_algo": str(data.get("public_key_algo", "Ed25519") or "Ed25519"),
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"recipient_id": recipient_id,
|
||||
"delivery_class": delivery_class,
|
||||
"recipient_token_hash": _token_binding_hash(recipient_token),
|
||||
"issued_at": now,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"sender_token": token,
|
||||
"expires_at": expires_at,
|
||||
"delivery_class": delivery_class,
|
||||
}
|
||||
|
||||
|
||||
def issue_wormhole_dm_sender_tokens(
|
||||
*,
|
||||
recipient_id: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str = "",
|
||||
count: int = 3,
|
||||
ttl_seconds: int = _SENDER_TOKEN_TTL_S,
|
||||
) -> dict[str, Any]:
|
||||
token_count = max(1, min(int(count or 1), 4))
|
||||
tokens: list[dict[str, Any]] = []
|
||||
for _ in range(token_count):
|
||||
issued = issue_wormhole_dm_sender_token(
|
||||
recipient_id=recipient_id,
|
||||
delivery_class=delivery_class,
|
||||
recipient_token=recipient_token,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
if not issued.get("ok"):
|
||||
return issued
|
||||
tokens.append(
|
||||
{
|
||||
"sender_token": str(issued.get("sender_token", "")),
|
||||
"expires_at": int(issued.get("expires_at", 0) or 0),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"delivery_class": delivery_class,
|
||||
"tokens": tokens,
|
||||
}
|
||||
|
||||
|
||||
def consume_wormhole_dm_sender_token(
|
||||
*,
|
||||
sender_token: str,
|
||||
recipient_id: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str = "",
|
||||
) -> dict[str, Any]:
|
||||
token = str(sender_token or "").strip()
|
||||
if not token:
|
||||
return {"ok": False, "detail": "sender_token required"}
|
||||
token_hash = _sender_token_hash(token)
|
||||
record = _sender_tokens.pop(token, None)
|
||||
if not record:
|
||||
return {"ok": False, "detail": "sender_token invalid or expired"}
|
||||
bound_recipient_id = str(record.get("recipient_id", "") or "")
|
||||
normalized_recipient_id = str(recipient_id or "").strip()
|
||||
if normalized_recipient_id and bound_recipient_id != normalized_recipient_id:
|
||||
return {"ok": False, "detail": "sender_token recipient mismatch"}
|
||||
if str(record.get("delivery_class", "")) != str(delivery_class or "").strip().lower():
|
||||
return {"ok": False, "detail": "sender_token delivery_class mismatch"}
|
||||
if str(record.get("recipient_token_hash", "")) != _token_binding_hash(recipient_token):
|
||||
return {"ok": False, "detail": "sender_token mailbox binding mismatch"}
|
||||
expires_at = int(record.get("expires_at", 0) or 0)
|
||||
if expires_at and expires_at < int(time.time()):
|
||||
return {"ok": False, "detail": "sender_token expired"}
|
||||
return {"ok": True, "sender_token_hash": token_hash, **record}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Helpers for Meshtastic MQTT roots, topic parsing, and subscriptions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
# Official/default region roots we actively watch on the public broker.
|
||||
DEFAULT_ROOTS: tuple[str, ...] = (
|
||||
"US",
|
||||
"EU_868",
|
||||
"EU_433",
|
||||
"CN",
|
||||
"JP",
|
||||
"KR",
|
||||
"TW",
|
||||
"RU",
|
||||
"IN",
|
||||
"ANZ",
|
||||
"ANZ_433",
|
||||
"NZ_865",
|
||||
"TH",
|
||||
"UA_868",
|
||||
"UA_433",
|
||||
"MY_433",
|
||||
"MY_919",
|
||||
"SG_923",
|
||||
"LORA_24",
|
||||
)
|
||||
|
||||
# Legacy/community roots still seen in the wild on public/community brokers.
|
||||
COMMUNITY_ROOTS: tuple[str, ...] = (
|
||||
"EU",
|
||||
"AU",
|
||||
"UA",
|
||||
"BR",
|
||||
"AF",
|
||||
"ME",
|
||||
"SEA",
|
||||
"SA",
|
||||
"PL",
|
||||
)
|
||||
|
||||
_ROOT_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_+\-]+$")
|
||||
_TOPIC_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_+\-#]+$")
|
||||
|
||||
|
||||
def _dedupe(values: Iterable[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
out.append(value)
|
||||
seen.add(value)
|
||||
return out
|
||||
|
||||
|
||||
def _split_config_values(raw: str) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
normalized = raw.replace("\n", ",").replace(";", ",")
|
||||
return [item.strip() for item in normalized.split(",") if item.strip()]
|
||||
|
||||
|
||||
def normalize_root(value: str) -> str | None:
|
||||
"""Normalize a Meshtastic root like `PL` or `US/rob/snd`."""
|
||||
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("msh/"):
|
||||
raw = raw[4:]
|
||||
raw = raw.strip("/")
|
||||
if raw.endswith("/#"):
|
||||
raw = raw[:-2].rstrip("/")
|
||||
if not raw:
|
||||
return None
|
||||
parts = [part for part in raw.split("/") if part]
|
||||
if not parts:
|
||||
return None
|
||||
if any(part in {"+", "#"} for part in parts):
|
||||
return None
|
||||
if any(not _ROOT_SEGMENT_RE.match(part) for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def normalize_topic_filter(value: str) -> str | None:
|
||||
"""Normalize a full MQTT subscription filter."""
|
||||
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if not raw.startswith("msh/"):
|
||||
root = normalize_root(raw)
|
||||
return f"msh/{root}/#" if root else None
|
||||
raw = raw.strip("/")
|
||||
parts = [part for part in raw.split("/") if part]
|
||||
if not parts or parts[0] != "msh":
|
||||
return None
|
||||
if any(part != "+" and not _TOPIC_SEGMENT_RE.match(part) for part in parts[1:]):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def build_subscription_topics(
|
||||
extra_roots: str = "",
|
||||
extra_topics: str = "",
|
||||
include_defaults: bool = True,
|
||||
) -> list[str]:
|
||||
roots: list[str] = []
|
||||
if include_defaults:
|
||||
roots.extend(DEFAULT_ROOTS)
|
||||
roots.extend(COMMUNITY_ROOTS)
|
||||
roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root)
|
||||
|
||||
topics = [f"msh/{root}/#" for root in _dedupe(roots)]
|
||||
topics.extend(
|
||||
topic
|
||||
for topic in (
|
||||
normalize_topic_filter(item) for item in _split_config_values(extra_topics)
|
||||
)
|
||||
if topic
|
||||
)
|
||||
return _dedupe(topics)
|
||||
|
||||
|
||||
def known_roots(extra_roots: str = "", include_defaults: bool = True) -> list[str]:
|
||||
topics = build_subscription_topics(extra_roots=extra_roots, include_defaults=include_defaults)
|
||||
roots: list[str] = []
|
||||
for topic in topics:
|
||||
if not topic.startswith("msh/") or not topic.endswith("/#"):
|
||||
continue
|
||||
root = normalize_root(topic[4:-2])
|
||||
if root:
|
||||
roots.append(root)
|
||||
return _dedupe(roots)
|
||||
|
||||
|
||||
def parse_topic_metadata(topic: str) -> dict[str, str]:
|
||||
"""Extract region/root/channel metadata from a Meshtastic MQTT topic."""
|
||||
|
||||
parts = [part for part in str(topic or "").strip("/").split("/") if part]
|
||||
if not parts or parts[0] != "msh":
|
||||
return {"region": "?", "root": "?", "channel": "LongFast", "mode": "", "version": ""}
|
||||
|
||||
mode_idx = -1
|
||||
for idx in range(1, len(parts)):
|
||||
if parts[idx] in {"e", "c", "json"}:
|
||||
mode_idx = idx
|
||||
break
|
||||
|
||||
version = ""
|
||||
root_parts = parts[1:]
|
||||
channel = "LongFast"
|
||||
mode = ""
|
||||
if mode_idx != -1:
|
||||
mode = parts[mode_idx]
|
||||
maybe_version_idx = mode_idx - 1
|
||||
if maybe_version_idx >= 1 and parts[maybe_version_idx].isdigit():
|
||||
version = parts[maybe_version_idx]
|
||||
root_parts = parts[1:maybe_version_idx]
|
||||
else:
|
||||
root_parts = parts[1:mode_idx]
|
||||
if len(parts) > mode_idx + 1:
|
||||
channel = parts[mode_idx + 1]
|
||||
|
||||
root = "/".join(root_parts) if root_parts else "?"
|
||||
region = root_parts[0] if root_parts else "?"
|
||||
return {
|
||||
"region": region,
|
||||
"root": root,
|
||||
"channel": channel or "LongFast",
|
||||
"mode": mode,
|
||||
"version": version,
|
||||
}
|
||||
@@ -34,6 +34,11 @@ _CIRCUIT_BREAKER_TTL = 120 # 2 minutes
|
||||
# Lock protecting _domain_fail_cache and _circuit_breaker mutations
|
||||
_cb_lock = threading.Lock()
|
||||
|
||||
|
||||
class UpstreamCircuitBreakerError(OSError):
|
||||
"""Raised when a domain recently failed hard and is temporarily skipped."""
|
||||
|
||||
|
||||
class _DummyResponse:
|
||||
"""Minimal response object matching requests.Response interface."""
|
||||
def __init__(self, status_code, text):
|
||||
@@ -67,7 +72,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
|
||||
# Circuit breaker: if domain failed completely <2min ago, fail fast
|
||||
with _cb_lock:
|
||||
if domain in _circuit_breaker and (time.time() - _circuit_breaker[domain]) < _CIRCUIT_BREAKER_TTL:
|
||||
raise Exception(f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)")
|
||||
raise UpstreamCircuitBreakerError(
|
||||
f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)"
|
||||
)
|
||||
|
||||
# Check if this domain recently failed with requests — skip straight to curl
|
||||
with _cb_lock:
|
||||
@@ -81,6 +88,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
|
||||
res = _session.post(url, json=json_data, timeout=req_timeout, headers=default_headers)
|
||||
else:
|
||||
res = _session.get(url, timeout=req_timeout, headers=default_headers)
|
||||
if res.status_code == 429:
|
||||
logger.warning(f"Upstream rate limit hit for {url}; not bypassing with curl.")
|
||||
return res
|
||||
res.raise_for_status()
|
||||
# Clear failure caches on success
|
||||
with _cb_lock:
|
||||
@@ -106,9 +116,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
|
||||
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
|
||||
res = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout + 5,
|
||||
input=stdin_data
|
||||
input=stdin_data, encoding="utf-8", errors="replace"
|
||||
)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
if res.returncode == 0 and (res.stdout or "").strip():
|
||||
# Parse HTTP status code from -w output (last line)
|
||||
lines = res.stdout.rstrip().rsplit("\n", 1)
|
||||
body = lines[0] if len(lines) > 1 else res.stdout
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
News feed configuration — manages the user-customisable RSS feed list.
|
||||
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -9,7 +10,18 @@ from pathlib import Path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
|
||||
MAX_FEEDS = 25
|
||||
MAX_FEEDS = 50
|
||||
_FEED_URL_REPLACEMENTS = {
|
||||
"https://www.channelnewsasia.com/rssfeed/8395986": "https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml",
|
||||
}
|
||||
_DEAD_FEED_URLS = {
|
||||
"https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"https://focustaiwan.tw/rss",
|
||||
"https://english.kyodonews.net/rss/news.xml",
|
||||
"https://www.stripes.com/feeds/pacific.rss",
|
||||
"https://asia.nikkei.com/rss",
|
||||
"https://www.taipeitimes.com/xml/pda.rss",
|
||||
}
|
||||
|
||||
DEFAULT_FEEDS = [
|
||||
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
|
||||
@@ -17,23 +29,40 @@ DEFAULT_FEEDS = [
|
||||
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
|
||||
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
|
||||
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
|
||||
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
||||
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
||||
{"name": "CNA", "url": "https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml", "weight": 3},
|
||||
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
|
||||
{"name": "FocusTaiwan", "url": "https://focustaiwan.tw/rss", "weight": 5},
|
||||
{"name": "Kyodo", "url": "https://english.kyodonews.net/rss/news.xml", "weight": 4},
|
||||
{"name": "SCMP", "url": "https://www.scmp.com/rss/91/feed", "weight": 4},
|
||||
{"name": "The Diplomat", "url": "https://thediplomat.com/feed/", "weight": 4},
|
||||
{"name": "Stars and Stripes", "url": "https://www.stripes.com/feeds/pacific.rss", "weight": 4},
|
||||
{"name": "Yonhap", "url": "https://en.yna.co.kr/RSS/news.xml", "weight": 4},
|
||||
{"name": "Nikkei Asia", "url": "https://asia.nikkei.com/rss", "weight": 3},
|
||||
{"name": "Taipei Times", "url": "https://www.taipeitimes.com/xml/pda.rss", "weight": 4},
|
||||
{"name": "Asia Times", "url": "https://asiatimes.com/feed/", "weight": 3},
|
||||
{"name": "Defense News", "url": "https://www.defensenews.com/arc/outboundfeeds/rss/", "weight": 3},
|
||||
{"name": "Japan Times", "url": "https://www.japantimes.co.jp/feed/", "weight": 3},
|
||||
{"name": "CSM", "url": "https://www.csmonitor.com/rss/world", "weight": 4},
|
||||
{"name": "PBS NewsHour", "url": "https://www.pbs.org/newshour/feeds/rss/world", "weight": 4},
|
||||
{"name": "France 24", "url": "https://www.france24.com/en/rss", "weight": 4},
|
||||
{"name": "DW", "url": "https://rss.dw.com/xml/rss-en-world", "weight": 4},
|
||||
]
|
||||
|
||||
|
||||
def _normalise_feeds(feeds: list[dict]) -> list[dict]:
|
||||
cleaned: list[dict] = []
|
||||
for feed in feeds:
|
||||
if not isinstance(feed, dict):
|
||||
continue
|
||||
item = dict(feed)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
continue
|
||||
if url in _FEED_URL_REPLACEMENTS:
|
||||
item["url"] = _FEED_URL_REPLACEMENTS[url]
|
||||
url = item["url"]
|
||||
if url in _DEAD_FEED_URLS:
|
||||
logger.warning("Dropping dead RSS feed URL from configuration: %s", url)
|
||||
continue
|
||||
cleaned.append(item)
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_feeds() -> list[dict]:
|
||||
"""Load feeds from config file, falling back to defaults."""
|
||||
try:
|
||||
@@ -41,7 +70,10 @@ def get_feeds() -> list[dict]:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
feeds = data.get("feeds", []) if isinstance(data, dict) else data
|
||||
if isinstance(feeds, list) and len(feeds) > 0:
|
||||
return feeds
|
||||
normalised = _normalise_feeds(feeds)
|
||||
if normalised != feeds:
|
||||
save_feeds(normalised)
|
||||
return normalised
|
||||
except (IOError, OSError, json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(f"Failed to read news feed config: {e}")
|
||||
return list(DEFAULT_FEEDS)
|
||||
@@ -51,6 +83,7 @@ def save_feeds(feeds: list[dict]) -> bool:
|
||||
"""Validate and save feeds to config file. Returns True on success."""
|
||||
if not isinstance(feeds, list):
|
||||
return False
|
||||
feeds = _normalise_feeds(feeds)
|
||||
if len(feeds) > MAX_FEEDS:
|
||||
return False
|
||||
# Validate each feed entry
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
NODE_FILE = DATA_DIR / "node.json"
|
||||
_cache: dict | None = None
|
||||
_cache_ts: float = 0.0
|
||||
_CACHE_TTL = 5.0
|
||||
_DEFAULTS = {
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def read_node_settings() -> dict:
|
||||
global _cache, _cache_ts
|
||||
now = time.monotonic()
|
||||
if _cache is not None and (now - _cache_ts) < _CACHE_TTL:
|
||||
return _cache
|
||||
if not NODE_FILE.exists():
|
||||
result = {**_DEFAULTS, "updated_at": 0}
|
||||
else:
|
||||
try:
|
||||
data = json.loads(NODE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
result = {**_DEFAULTS, "updated_at": 0}
|
||||
else:
|
||||
result = {
|
||||
"enabled": bool(data.get("enabled", _DEFAULTS["enabled"])),
|
||||
"updated_at": _safe_int(data.get("updated_at", 0) or 0),
|
||||
}
|
||||
_cache = result
|
||||
_cache_ts = now
|
||||
return result
|
||||
|
||||
|
||||
def write_node_settings(*, enabled: bool | None = None) -> dict:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
existing = read_node_settings()
|
||||
payload = {
|
||||
"enabled": bool(existing.get("enabled", _DEFAULTS["enabled"])) if enabled is None else bool(enabled),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
NODE_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
global _cache, _cache_ts
|
||||
_cache = payload
|
||||
_cache_ts = time.monotonic()
|
||||
return payload
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Oracle Service — deterministic intelligence ranking for news items.
|
||||
|
||||
Enriches news items with:
|
||||
- oracle_score: risk_score weighted by source confidence (0–10)
|
||||
- sentiment: VADER compound score (-1.0 to +1.0)
|
||||
- prediction_odds: matched prediction market probabilities (or None)
|
||||
- machine_assessment: structured human-readable analysis string
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_analyzer = None
|
||||
|
||||
|
||||
def _get_analyzer():
|
||||
global _analyzer
|
||||
if _analyzer is None:
|
||||
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||
_analyzer = SentimentIntensityAnalyzer()
|
||||
return _analyzer
|
||||
|
||||
|
||||
def compute_sentiment(headline: str) -> float:
|
||||
"""VADER compound sentiment score for a headline. Range: -1.0 to +1.0."""
|
||||
if not headline:
|
||||
return 0.0
|
||||
return _get_analyzer().polarity_scores(headline)["compound"]
|
||||
|
||||
|
||||
def compute_oracle_score(risk_score: int, source_weight: float) -> float:
|
||||
"""Weighted oracle score: risk_score scaled by source confidence.
|
||||
|
||||
source_weight is 1–5 (from feed config). Normalised to 0.2–1.0 multiplier.
|
||||
Result range: 0.0–10.0.
|
||||
"""
|
||||
multiplier = source_weight / 5.0 # 1→0.2, 5→1.0
|
||||
return round(risk_score * multiplier, 1)
|
||||
|
||||
|
||||
_STOP_WORDS = frozenset({
|
||||
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
||||
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
|
||||
"being", "have", "has", "had", "do", "does", "did", "will", "would",
|
||||
"could", "should", "may", "might", "shall", "can", "this", "that",
|
||||
"these", "those", "it", "its", "if", "not", "no", "so", "as", "up",
|
||||
"out", "about", "into", "over", "after", "before", "between", "under",
|
||||
"than", "then", "more", "most", "other", "some", "such", "only", "own",
|
||||
"same", "also", "just", "how", "what", "which", "who", "whom", "when",
|
||||
"where", "why", "all", "each", "every", "both", "few", "many", "much",
|
||||
"any", "very", "too", "here", "there", "now", "new", "says", "said",
|
||||
"-", "--", "—", "vs", "vs.", "&", "he", "she", "they", "we", "you",
|
||||
"his", "her", "my", "our", "your", "their", "him", "us", "them",
|
||||
})
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Lowercase, strip punctuation, remove stop words."""
|
||||
import re
|
||||
words = re.findall(r"[a-z0-9]+(?:'[a-z]+)?", text.lower())
|
||||
return {w for w in words if w not in _STOP_WORDS and len(w) > 1}
|
||||
|
||||
|
||||
def _match_prediction_markets(title: str, markets: list[dict]) -> dict | None:
|
||||
"""Find best-matching prediction market for a news headline.
|
||||
|
||||
Uses Jaccard similarity on meaningful tokens (stop words removed).
|
||||
Requires at least 2 meaningful keyword overlaps AND Jaccard >= 0.15.
|
||||
"""
|
||||
if not markets or not title:
|
||||
return None
|
||||
|
||||
title_words = _tokenize(title)
|
||||
if len(title_words) < 2:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for market in markets:
|
||||
market_title = market.get("title", "")
|
||||
market_words = _tokenize(market_title)
|
||||
if len(market_words) < 2:
|
||||
continue
|
||||
|
||||
intersection = title_words & market_words
|
||||
if len(intersection) < 2:
|
||||
continue
|
||||
|
||||
union = title_words | market_words
|
||||
jaccard = len(intersection) / len(union) if union else 0.0
|
||||
|
||||
if jaccard > best_score and jaccard >= 0.15:
|
||||
best_score = jaccard
|
||||
best_match = market
|
||||
|
||||
if not best_match:
|
||||
return None
|
||||
|
||||
return {
|
||||
"title": best_match.get("title", ""),
|
||||
"polymarket_pct": best_match.get("polymarket_pct"),
|
||||
"kalshi_pct": best_match.get("kalshi_pct"),
|
||||
"consensus_pct": best_match.get("consensus_pct"),
|
||||
"match_score": round(best_score, 2),
|
||||
}
|
||||
|
||||
|
||||
def _build_assessment(oracle_score: float, sentiment: float, prediction: dict | None) -> str:
|
||||
"""Build structured machine_assessment string."""
|
||||
parts = []
|
||||
|
||||
# Oracle tier
|
||||
if oracle_score >= 7:
|
||||
tier = "CRITICAL"
|
||||
elif oracle_score >= 4:
|
||||
tier = "ELEVATED"
|
||||
else:
|
||||
tier = "ROUTINE"
|
||||
parts.append(f"ORACLE: {oracle_score}/10 [{tier}]")
|
||||
|
||||
# Sentiment
|
||||
if sentiment >= 0.05:
|
||||
sdir = "POSITIVE"
|
||||
elif sentiment <= -0.05:
|
||||
sdir = "NEGATIVE"
|
||||
else:
|
||||
sdir = "NEUTRAL"
|
||||
parts.append(f"SENTIMENT: {sentiment:+.2f} [{sdir}]")
|
||||
|
||||
# Prediction market
|
||||
if prediction:
|
||||
consensus = prediction.get("consensus_pct")
|
||||
if consensus is not None:
|
||||
parts.append(f"MKT CONSENSUS: {consensus}%")
|
||||
poly = prediction.get("polymarket_pct")
|
||||
kalshi = prediction.get("kalshi_pct")
|
||||
sources = []
|
||||
if poly is not None:
|
||||
sources.append(f"Polymarket {poly}%")
|
||||
if kalshi is not None:
|
||||
sources.append(f"Kalshi {kalshi}%")
|
||||
if sources:
|
||||
parts.append(f" Sources: {' | '.join(sources)}")
|
||||
|
||||
return " // ".join(parts[:3]) + ("\n" + parts[3] if len(parts) > 3 else "")
|
||||
|
||||
|
||||
def enrich_news_items(
|
||||
news_items: list[dict], source_weights: dict[str, float], markets: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""Enrich news items with oracle scores, sentiment, and prediction market odds.
|
||||
|
||||
Args:
|
||||
news_items: list of news item dicts (modified in-place)
|
||||
source_weights: {source_name: weight} from feed config (1–5 scale)
|
||||
markets: merged prediction market events list (or None)
|
||||
|
||||
Returns:
|
||||
The same list, enriched with oracle_score, sentiment, prediction_odds, machine_assessment.
|
||||
"""
|
||||
if markets is None:
|
||||
markets = []
|
||||
|
||||
for item in news_items:
|
||||
title = item.get("title", "")
|
||||
source = item.get("source", "")
|
||||
risk_score = item.get("risk_score", 1)
|
||||
weight = source_weights.get(source, 3) # default weight 3 (mid-range)
|
||||
|
||||
sentiment = compute_sentiment(title)
|
||||
oracle_score = compute_oracle_score(risk_score, weight)
|
||||
prediction = _match_prediction_markets(title, markets)
|
||||
|
||||
item["sentiment"] = sentiment
|
||||
item["oracle_score"] = oracle_score
|
||||
item["prediction_odds"] = prediction
|
||||
item["machine_assessment"] = _build_assessment(oracle_score, sentiment, prediction)
|
||||
|
||||
return news_items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global threat level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_THREAT_TIERS = [
|
||||
(80, "SEVERE", "#ef4444"), # red
|
||||
(60, "HIGH", "#f97316"), # orange
|
||||
(40, "ELEVATED", "#eab308"), # yellow
|
||||
(20, "GUARDED", "#3b82f6"), # blue
|
||||
(0, "GREEN", "#22c55e"), # green
|
||||
]
|
||||
|
||||
|
||||
def compute_global_threat_level(
|
||||
news_items: list[dict],
|
||||
markets: list[dict] | None = None,
|
||||
military_flights: list[dict] | None = None,
|
||||
gps_jamming: list[dict] | None = None,
|
||||
ships: list[dict] | None = None,
|
||||
correlations: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Fuse news sentiment, prediction-market conflict odds, event frequency,
|
||||
military activity, GPS jamming, and cross-layer correlations into a single
|
||||
0-100 threat score.
|
||||
|
||||
Formula (weights sum to 1.0):
|
||||
0.25 × negative_sentiment_intensity
|
||||
0.25 × conflict_market_avg_probability
|
||||
0.10 × high_risk_event_ratio
|
||||
0.10 × max_oracle_score (normalised to 0-100)
|
||||
0.10 × military_activity_anomaly
|
||||
0.10 × gps_jamming_indicator
|
||||
0.10 × correlation_alerts
|
||||
"""
|
||||
if not news_items:
|
||||
return {"score": 0, "level": "GREEN", "color": "#22c55e", "drivers": []}
|
||||
|
||||
# --- Component 1: negative sentiment intensity (0-100) ---
|
||||
neg_scores = [abs(it.get("sentiment", 0)) for it in news_items if (it.get("sentiment") or 0) <= -0.05]
|
||||
neg_intensity = (sum(neg_scores) / len(news_items)) * 100 if news_items else 0
|
||||
neg_intensity = min(100, neg_intensity * 2.5) # scale up — avg abs sentiment rarely > 0.4
|
||||
|
||||
# --- Component 2: conflict market avg probability (0-100) ---
|
||||
conflict_probs: list[float] = []
|
||||
for m in (markets or []):
|
||||
if m.get("category") == "CONFLICT":
|
||||
pct = m.get("consensus_pct") or m.get("polymarket_pct") or m.get("kalshi_pct")
|
||||
if pct is not None:
|
||||
conflict_probs.append(float(pct))
|
||||
conflict_avg = sum(conflict_probs) / len(conflict_probs) if conflict_probs else 0
|
||||
|
||||
# --- Component 3: high-risk event ratio (0-100) ---
|
||||
high_risk = sum(1 for it in news_items if (it.get("risk_score") or 0) >= 7)
|
||||
event_ratio = (high_risk / len(news_items)) * 100 if news_items else 0
|
||||
|
||||
# --- Component 4: max oracle score (0-100) ---
|
||||
max_oracle = max((it.get("oracle_score") or 0) for it in news_items)
|
||||
max_oracle_pct = max_oracle * 10 # 0-10 → 0-100
|
||||
|
||||
# --- Component 5: military activity anomaly (0-100) ---
|
||||
mil_count = len(military_flights or [])
|
||||
# Baseline: ~20-50 military flights is normal. Spike above 80 is anomalous.
|
||||
mil_anomaly = min(100, max(0, (mil_count - 30) * 2)) if mil_count > 30 else 0
|
||||
|
||||
# --- Component 6: GPS jamming indicator (0-100) ---
|
||||
jam_zones = gps_jamming or []
|
||||
high_jam = sum(1 for z in jam_zones if z.get("severity") == "high")
|
||||
med_jam = sum(1 for z in jam_zones if z.get("severity") == "medium")
|
||||
jam_score = min(100, high_jam * 25 + med_jam * 10)
|
||||
|
||||
# --- Component 7: cross-layer correlation alerts (0-100) ---
|
||||
corr_list: list[dict] = correlations if correlations else []
|
||||
corr_points = sum(
|
||||
15 if a.get("severity") == "high" else 8 if a.get("severity") == "medium" else 3
|
||||
for a in corr_list
|
||||
)
|
||||
corr_score = min(100, corr_points)
|
||||
|
||||
# --- Weighted fusion ---
|
||||
score = (
|
||||
0.25 * neg_intensity
|
||||
+ 0.25 * conflict_avg
|
||||
+ 0.10 * event_ratio
|
||||
+ 0.10 * max_oracle_pct
|
||||
+ 0.10 * mil_anomaly
|
||||
+ 0.10 * jam_score
|
||||
+ 0.10 * corr_score
|
||||
)
|
||||
score = max(0, min(100, round(score)))
|
||||
|
||||
# --- Tier ---
|
||||
level, color = "GREEN", "#22c55e"
|
||||
for threshold, name, c in _THREAT_TIERS:
|
||||
if score >= threshold:
|
||||
level, color = name, c
|
||||
break
|
||||
|
||||
# --- Drivers (top reasons for current level) ---
|
||||
drivers: list[str] = []
|
||||
if high_risk:
|
||||
drivers.append(f"{high_risk} CRITICAL-tier news item{'s' if high_risk != 1 else ''}")
|
||||
if conflict_avg >= 30:
|
||||
drivers.append(f"CONFLICT markets avg {conflict_avg:.0f}%")
|
||||
if neg_intensity >= 40:
|
||||
drivers.append(f"Negative sentiment intensity {neg_intensity:.0f}/100")
|
||||
if max_oracle >= 7:
|
||||
drivers.append(f"Max oracle score {max_oracle}/10")
|
||||
if mil_anomaly >= 30:
|
||||
drivers.append(f"Military flight spike: {mil_count} tracked")
|
||||
if jam_score >= 25:
|
||||
drivers.append(f"GPS jamming: {high_jam} HIGH + {med_jam} MED zones")
|
||||
if corr_score >= 15:
|
||||
corr_high = sum(1 for a in corr_list if a.get("severity") == "high")
|
||||
corr_med = sum(1 for a in corr_list if a.get("severity") == "medium")
|
||||
drivers.append(f"Cross-layer correlations: {corr_high} HIGH + {corr_med} MED")
|
||||
if not drivers:
|
||||
drivers.append("Baseline — no significant threat indicators")
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"level": level,
|
||||
"color": color,
|
||||
"drivers": drivers[:4],
|
||||
}
|
||||
|
||||
|
||||
def detect_breaking_events(news_items: list[dict]) -> None:
|
||||
"""Mark news items as 'breaking' when multiple credible sources converge.
|
||||
|
||||
Criteria: cluster_count >= 3 AND risk_score >= 7.
|
||||
Modifies items in-place by setting ``breaking = True``.
|
||||
"""
|
||||
for item in news_items:
|
||||
cluster = item.get("cluster_count", 1)
|
||||
risk = item.get("risk_score", 0)
|
||||
if cluster >= 3 and risk >= 7:
|
||||
item["breaking"] = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Region oracle intel (for map entity tooltips)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_region_cache: dict[str, tuple[float, dict]] = {} # "lat,lng" -> (timestamp, result)
|
||||
_REGION_CACHE_TTL = 60 # seconds
|
||||
_REGION_RADIUS_DEG = 5.0 # ~500km at equator
|
||||
|
||||
|
||||
def get_region_oracle_intel(lat: float, lng: float, news_items: list[dict]) -> dict:
|
||||
"""Get oracle intelligence summary for a geographic region.
|
||||
|
||||
Finds news items within ~5 degrees, returns top oracle_score item,
|
||||
average sentiment, and best market match. Cached on 0.5-degree grid.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Grid-snap for cache key (0.5 degree grid)
|
||||
grid_lat = round(lat * 2) / 2
|
||||
grid_lng = round(lng * 2) / 2
|
||||
cache_key = f"{grid_lat},{grid_lng}"
|
||||
|
||||
now = time.time()
|
||||
if cache_key in _region_cache:
|
||||
ts, cached_result = _region_cache[cache_key]
|
||||
if now - ts < _REGION_CACHE_TTL:
|
||||
return cached_result
|
||||
|
||||
# Find nearby news items
|
||||
nearby = []
|
||||
for item in news_items:
|
||||
coords = item.get("coords")
|
||||
if not coords or len(coords) < 2:
|
||||
continue
|
||||
ilat, ilng = coords[0], coords[1]
|
||||
if abs(ilat - lat) <= _REGION_RADIUS_DEG and abs(ilng - lng) <= _REGION_RADIUS_DEG:
|
||||
nearby.append(item)
|
||||
|
||||
if not nearby:
|
||||
result = {"found": False}
|
||||
_region_cache[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
# Top oracle score item
|
||||
top = max(nearby, key=lambda x: x.get("oracle_score", 0))
|
||||
avg_sentiment = sum(it.get("sentiment", 0) for it in nearby) / len(nearby)
|
||||
|
||||
# Best market match from nearby items
|
||||
best_market = None
|
||||
for it in nearby:
|
||||
po = it.get("prediction_odds")
|
||||
if po and po.get("consensus_pct") is not None:
|
||||
if best_market is None or (po.get("consensus_pct") or 0) > (best_market.get("consensus_pct") or 0):
|
||||
best_market = po
|
||||
|
||||
# Oracle tier
|
||||
oracle_score = top.get("oracle_score", 0)
|
||||
tier = "CRITICAL" if oracle_score >= 7 else "ELEVATED" if oracle_score >= 4 else "ROUTINE"
|
||||
|
||||
result = {
|
||||
"found": True,
|
||||
"top_headline": top.get("title", ""),
|
||||
"oracle_score": oracle_score,
|
||||
"tier": tier,
|
||||
"avg_sentiment": round(avg_sentiment, 2),
|
||||
"nearby_count": len(nearby),
|
||||
"market": {
|
||||
"title": best_market.get("title", ""),
|
||||
"consensus_pct": best_market.get("consensus_pct"),
|
||||
} if best_market else None,
|
||||
}
|
||||
_region_cache[cache_key] = (now, result)
|
||||
return result
|
||||
@@ -0,0 +1,440 @@
|
||||
"""ctypes bridge for the Rust privacy-core crate.
|
||||
|
||||
This module follows the architecture docs in extra/docs-internal:
|
||||
- Python orchestrates
|
||||
- Rust owns private protocol state
|
||||
- Python sees opaque integer handles and serialized ciphertext only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class PrivacyCoreError(RuntimeError):
|
||||
"""Raised when the Rust privacy-core returns an error."""
|
||||
|
||||
|
||||
class PrivacyCoreUnavailable(PrivacyCoreError):
|
||||
"""Raised when the shared library cannot be found or loaded."""
|
||||
|
||||
|
||||
class _ByteBuffer(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("data", ctypes.POINTER(ctypes.c_uint8)),
|
||||
("len", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
|
||||
class PrivacyCoreClient:
|
||||
"""Handle-based interface to the local Rust privacy-core."""
|
||||
|
||||
def __init__(self, library: ctypes.CDLL, library_path: Path) -> None:
|
||||
self._library = library
|
||||
self.library_path = library_path
|
||||
self._configure_functions()
|
||||
|
||||
@classmethod
|
||||
def load(cls, library_path: str | os.PathLike[str] | None = None) -> "PrivacyCoreClient":
|
||||
resolved = cls._resolve_library_path(library_path)
|
||||
try:
|
||||
library = ctypes.CDLL(str(resolved))
|
||||
except OSError as exc:
|
||||
raise PrivacyCoreUnavailable(f"failed to load privacy-core library: {resolved}") from exc
|
||||
return cls(library, resolved)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_library_path(library_path: str | os.PathLike[str] | None) -> Path:
|
||||
if library_path:
|
||||
resolved = Path(library_path).expanduser().resolve()
|
||||
if not resolved.exists():
|
||||
raise PrivacyCoreUnavailable(f"privacy-core library not found: {resolved}")
|
||||
return resolved
|
||||
|
||||
env_override = os.environ.get("PRIVACY_CORE_LIB")
|
||||
if env_override:
|
||||
resolved = Path(env_override).expanduser().resolve()
|
||||
if not resolved.exists():
|
||||
raise PrivacyCoreUnavailable(f"privacy-core library not found: {resolved}")
|
||||
return resolved
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
candidates = []
|
||||
for profile in ("debug", "release"):
|
||||
target_dir = repo_root / "privacy-core" / "target" / profile
|
||||
candidates.extend(
|
||||
[
|
||||
target_dir / "privacy_core.dll",
|
||||
target_dir / "libprivacy_core.so",
|
||||
target_dir / "libprivacy_core.dylib",
|
||||
]
|
||||
)
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
searched = "\n".join(str(candidate) for candidate in candidates)
|
||||
raise PrivacyCoreUnavailable(
|
||||
"privacy-core shared library not found. Looked in:\n" f"{searched}"
|
||||
)
|
||||
|
||||
def _configure_functions(self) -> None:
|
||||
self._library.privacy_core_version.argtypes = []
|
||||
self._library.privacy_core_version.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_last_error_message.argtypes = []
|
||||
self._library.privacy_core_last_error_message.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_free_buffer.argtypes = [_ByteBuffer]
|
||||
self._library.privacy_core_free_buffer.restype = None
|
||||
|
||||
self._library.privacy_core_create_identity.argtypes = []
|
||||
self._library.privacy_core_create_identity.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_export_key_package.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_export_key_package.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_import_key_package.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_import_key_package.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_create_group.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_create_group.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_add_member.argtypes = [ctypes.c_uint64, ctypes.c_uint64]
|
||||
self._library.privacy_core_add_member.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_remove_member.argtypes = [ctypes.c_uint64, ctypes.c_uint32]
|
||||
self._library.privacy_core_remove_member.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_encrypt_group_message.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_encrypt_group_message.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_decrypt_group_message.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_decrypt_group_message.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_export_public_bundle.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_export_public_bundle.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_handle_stats.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_handle_stats.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_commit_message_bytes.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_commit_message_bytes.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_commit_welcome_message_bytes.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_commit_welcome_message_bytes.restype = _ByteBuffer
|
||||
|
||||
self._library.privacy_core_commit_joined_group_handle.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_commit_joined_group_handle.restype = ctypes.c_uint64
|
||||
|
||||
self._library.privacy_core_create_dm_session.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.c_uint64,
|
||||
]
|
||||
self._library.privacy_core_create_dm_session.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_dm_encrypt.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_dm_encrypt.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_dm_decrypt.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_dm_decrypt.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_dm_session_welcome.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_dm_session_welcome.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_join_dm_session.argtypes = [
|
||||
ctypes.c_uint64,
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self._library.privacy_core_join_dm_session.restype = ctypes.c_int64
|
||||
|
||||
self._library.privacy_core_release_dm_session.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_release_dm_session.restype = ctypes.c_int32
|
||||
|
||||
self._library.privacy_core_release_identity.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_release_identity.restype = ctypes.c_bool
|
||||
|
||||
self._library.privacy_core_release_key_package.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_release_key_package.restype = ctypes.c_bool
|
||||
|
||||
self._library.privacy_core_release_group.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_release_group.restype = ctypes.c_bool
|
||||
|
||||
self._library.privacy_core_release_commit.argtypes = [ctypes.c_uint64]
|
||||
self._library.privacy_core_release_commit.restype = ctypes.c_bool
|
||||
|
||||
self._library.privacy_core_reset_all_state.argtypes = []
|
||||
self._library.privacy_core_reset_all_state.restype = ctypes.c_bool
|
||||
|
||||
def version(self) -> str:
|
||||
return self._consume_string(self._library.privacy_core_version())
|
||||
|
||||
def create_identity(self) -> int:
|
||||
return self._ensure_handle(self._library.privacy_core_create_identity(), "create_identity")
|
||||
|
||||
def export_key_package(self, identity_handle: int) -> bytes:
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_export_key_package(ctypes.c_uint64(identity_handle)),
|
||||
"export_key_package",
|
||||
)
|
||||
|
||||
def import_key_package(self, data: bytes) -> int:
|
||||
buffer = self._as_ubyte_buffer(data)
|
||||
handle = self._library.privacy_core_import_key_package(buffer, len(data))
|
||||
return self._ensure_handle(handle, "import_key_package")
|
||||
|
||||
def create_group(self, identity_handle: int) -> int:
|
||||
handle = self._library.privacy_core_create_group(ctypes.c_uint64(identity_handle))
|
||||
return self._ensure_handle(handle, "create_group")
|
||||
|
||||
def add_member(self, group_handle: int, key_package_handle: int) -> int:
|
||||
handle = self._library.privacy_core_add_member(
|
||||
ctypes.c_uint64(group_handle),
|
||||
ctypes.c_uint64(key_package_handle),
|
||||
)
|
||||
return self._ensure_handle(handle, "add_member")
|
||||
|
||||
def remove_member(self, group_handle: int, member_ref: int) -> int:
|
||||
handle = self._library.privacy_core_remove_member(
|
||||
ctypes.c_uint64(group_handle),
|
||||
ctypes.c_uint32(member_ref),
|
||||
)
|
||||
return self._ensure_handle(handle, "remove_member")
|
||||
|
||||
def encrypt_group_message(self, group_handle: int, plaintext: bytes) -> bytes:
|
||||
buffer = self._as_ubyte_buffer(plaintext)
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_encrypt_group_message(
|
||||
ctypes.c_uint64(group_handle),
|
||||
buffer,
|
||||
len(plaintext),
|
||||
),
|
||||
"encrypt_group_message",
|
||||
)
|
||||
|
||||
def decrypt_group_message(self, group_handle: int, ciphertext: bytes) -> bytes:
|
||||
buffer = self._as_ubyte_buffer(ciphertext)
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_decrypt_group_message(
|
||||
ctypes.c_uint64(group_handle),
|
||||
buffer,
|
||||
len(ciphertext),
|
||||
),
|
||||
"decrypt_group_message",
|
||||
)
|
||||
|
||||
def export_public_bundle(self, identity_handle: int) -> bytes:
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_export_public_bundle(ctypes.c_uint64(identity_handle)),
|
||||
"export_public_bundle",
|
||||
)
|
||||
|
||||
def handle_stats(self) -> dict:
|
||||
payload = self._call_i64_bytes_op(
|
||||
"handle_stats",
|
||||
lambda out_buf, out_cap: self._library.privacy_core_handle_stats(out_buf, out_cap),
|
||||
)
|
||||
try:
|
||||
return json.loads(payload.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise PrivacyCoreError(f"handle_stats failed: invalid JSON: {exc}") from exc
|
||||
|
||||
def commit_message_bytes(self, commit_handle: int) -> bytes:
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_commit_message_bytes(ctypes.c_uint64(commit_handle)),
|
||||
"commit_message_bytes",
|
||||
)
|
||||
|
||||
def commit_welcome_message_bytes(self, commit_handle: int, index: int = 0) -> bytes:
|
||||
return self._consume_bytes(
|
||||
self._library.privacy_core_commit_welcome_message_bytes(
|
||||
ctypes.c_uint64(commit_handle),
|
||||
ctypes.c_size_t(index),
|
||||
),
|
||||
"commit_welcome_message_bytes",
|
||||
)
|
||||
|
||||
def commit_joined_group_handle(self, commit_handle: int, index: int = 0) -> int:
|
||||
handle = self._library.privacy_core_commit_joined_group_handle(
|
||||
ctypes.c_uint64(commit_handle),
|
||||
ctypes.c_size_t(index),
|
||||
)
|
||||
return self._ensure_handle(handle, "commit_joined_group_handle")
|
||||
|
||||
def create_dm_session(self, initiator_identity: int, responder_key_package: int) -> int:
|
||||
handle = self._library.privacy_core_create_dm_session(
|
||||
ctypes.c_uint64(initiator_identity),
|
||||
ctypes.c_uint64(responder_key_package),
|
||||
)
|
||||
if handle > 0:
|
||||
return int(handle)
|
||||
raise self._error_for("create_dm_session")
|
||||
|
||||
def dm_encrypt(self, session_handle: int, plaintext: bytes) -> bytes:
|
||||
buffer = self._as_ubyte_buffer(plaintext)
|
||||
return self._call_i64_bytes_op(
|
||||
"dm_encrypt",
|
||||
lambda out_buf, out_cap: self._library.privacy_core_dm_encrypt(
|
||||
ctypes.c_uint64(session_handle),
|
||||
buffer,
|
||||
len(plaintext),
|
||||
out_buf,
|
||||
out_cap,
|
||||
),
|
||||
)
|
||||
|
||||
def dm_decrypt(self, session_handle: int, ciphertext: bytes) -> bytes:
|
||||
buffer = self._as_ubyte_buffer(ciphertext)
|
||||
return self._call_i64_bytes_op(
|
||||
"dm_decrypt",
|
||||
lambda out_buf, out_cap: self._library.privacy_core_dm_decrypt(
|
||||
ctypes.c_uint64(session_handle),
|
||||
buffer,
|
||||
len(ciphertext),
|
||||
out_buf,
|
||||
out_cap,
|
||||
),
|
||||
)
|
||||
|
||||
def dm_session_welcome(self, session_handle: int) -> bytes:
|
||||
return self._call_i64_bytes_op(
|
||||
"dm_session_welcome",
|
||||
lambda out_buf, out_cap: self._library.privacy_core_dm_session_welcome(
|
||||
ctypes.c_uint64(session_handle),
|
||||
out_buf,
|
||||
out_cap,
|
||||
),
|
||||
)
|
||||
|
||||
def join_dm_session(self, responder_identity: int, welcome: bytes) -> int:
|
||||
buffer = self._as_ubyte_buffer(welcome)
|
||||
handle = self._library.privacy_core_join_dm_session(
|
||||
ctypes.c_uint64(responder_identity),
|
||||
buffer,
|
||||
len(welcome),
|
||||
)
|
||||
if handle > 0:
|
||||
return int(handle)
|
||||
raise self._error_for("join_dm_session")
|
||||
|
||||
def release_dm_session(self, handle: int) -> bool:
|
||||
return bool(self._library.privacy_core_release_dm_session(ctypes.c_uint64(handle)))
|
||||
|
||||
def release_identity(self, handle: int) -> bool:
|
||||
return bool(self._library.privacy_core_release_identity(ctypes.c_uint64(handle)))
|
||||
|
||||
def release_key_package(self, handle: int) -> bool:
|
||||
return bool(self._library.privacy_core_release_key_package(ctypes.c_uint64(handle)))
|
||||
|
||||
def release_group(self, handle: int) -> bool:
|
||||
return bool(self._library.privacy_core_release_group(ctypes.c_uint64(handle)))
|
||||
|
||||
def release_commit(self, handle: int) -> bool:
|
||||
return bool(self._library.privacy_core_release_commit(ctypes.c_uint64(handle)))
|
||||
|
||||
def reset_all_state(self) -> bool:
|
||||
return bool(self._library.privacy_core_reset_all_state())
|
||||
|
||||
def _consume_string(self, buffer: _ByteBuffer) -> str:
|
||||
payload = self._consume_buffer(buffer)
|
||||
return payload.decode("utf-8")
|
||||
|
||||
def _consume_bytes(self, buffer: _ByteBuffer, operation: str) -> bytes:
|
||||
payload = self._consume_buffer(buffer)
|
||||
if payload:
|
||||
return payload
|
||||
raise self._error_for(operation)
|
||||
|
||||
def _consume_buffer(self, buffer: _ByteBuffer) -> bytes:
|
||||
try:
|
||||
if not buffer.data or buffer.len == 0:
|
||||
return b""
|
||||
return bytes(ctypes.string_at(buffer.data, buffer.len))
|
||||
finally:
|
||||
self._library.privacy_core_free_buffer(buffer)
|
||||
|
||||
def _ensure_handle(self, handle: int, operation: str) -> int:
|
||||
if handle:
|
||||
return int(handle)
|
||||
raise self._error_for(operation)
|
||||
|
||||
def _call_i64_bytes_op(self, operation: str, invoker) -> bytes:
|
||||
required = int(invoker(None, 0))
|
||||
if required < 0:
|
||||
raise self._error_for(operation)
|
||||
if required == 0:
|
||||
return b""
|
||||
output = (ctypes.c_uint8 * required)()
|
||||
written = int(invoker(output, required))
|
||||
if written < 0:
|
||||
raise self._error_for(operation)
|
||||
return bytes(output[:written])
|
||||
|
||||
def _error_for(self, operation: str) -> PrivacyCoreError:
|
||||
message = self._last_error()
|
||||
if message:
|
||||
return PrivacyCoreError(f"{operation} failed: {message}")
|
||||
return PrivacyCoreError(f"{operation} failed without an error message")
|
||||
|
||||
def _last_error(self) -> str:
|
||||
return self._consume_string(self._library.privacy_core_last_error_message())
|
||||
|
||||
@staticmethod
|
||||
def _as_ubyte_buffer(data: bytes | bytearray | memoryview) -> ctypes.Array[ctypes.c_uint8]:
|
||||
if not isinstance(data, (bytes, bytearray, memoryview)):
|
||||
raise TypeError("privacy-core byte arguments must be bytes-like")
|
||||
raw = bytes(data)
|
||||
return (ctypes.c_uint8 * len(raw)).from_buffer_copy(raw)
|
||||
|
||||
|
||||
def candidate_library_paths() -> Iterable[Path]:
|
||||
"""Expose the default search order for diagnostics/tests."""
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
for profile in ("debug", "release"):
|
||||
target_dir = repo_root / "privacy-core" / "target" / profile
|
||||
yield target_dir / "privacy_core.dll"
|
||||
yield target_dir / "libprivacy_core.so"
|
||||
yield target_dir / "libprivacy_core.dylib"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
PSK Reporter fetcher — pulls recent digital mode signal reports (FT8, WSPR, etc.)
|
||||
from the global PSK Reporter network. No API key required.
|
||||
|
||||
Docs: https://pskreporter.info/pskdev.html
|
||||
"""
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import requests
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache
|
||||
|
||||
_ENDPOINT = "https://retrieve.pskreporter.info/query"
|
||||
|
||||
|
||||
def maidenhead_to_latlon(locator: str) -> tuple[float, float] | None:
|
||||
"""Convert a 4-or-6 character Maidenhead grid locator to (lat, lon)."""
|
||||
loc = locator.strip().upper()
|
||||
if len(loc) < 4:
|
||||
return None
|
||||
try:
|
||||
lon = (ord(loc[0]) - ord("A")) * 20 - 180
|
||||
lat = (ord(loc[1]) - ord("A")) * 10 - 90
|
||||
lon += int(loc[2]) * 2
|
||||
lat += int(loc[3])
|
||||
if len(loc) >= 6:
|
||||
lon += (ord(loc[4]) - ord("A")) * (2 / 24)
|
||||
lat += (ord(loc[5]) - ord("A")) * (1 / 24)
|
||||
# center of sub-square
|
||||
lon += 1 / 24
|
||||
lat += 1 / 48
|
||||
else:
|
||||
# center of grid square
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
if abs(lat) > 90 or abs(lon) > 180:
|
||||
return None
|
||||
return round(lat, 4), round(lon, 4)
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@cached(_cache)
|
||||
def fetch_psk_reporter_spots() -> list[dict]:
|
||||
"""Fetch recent FT8 reception reports from PSK Reporter."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
_ENDPOINT,
|
||||
params={
|
||||
"mode": "FT8",
|
||||
"flowStartSeconds": -900, # last 15 minutes
|
||||
"rronly": 1, # reception reports only
|
||||
"noactive": 1, # exclude active monitor noise
|
||||
"rptlimit": 5000, # cap payload size
|
||||
},
|
||||
timeout=30,
|
||||
headers={"Accept": "application/xml"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.content)
|
||||
|
||||
# PSK Reporter XML uses namespaces
|
||||
ns = ""
|
||||
if root.tag.startswith("{"):
|
||||
ns = root.tag.split("}")[0] + "}"
|
||||
|
||||
spots: list[dict] = []
|
||||
for rec in root.iter(f"{ns}receptionReport"):
|
||||
receiver_loc = rec.get("receiverLocator", "")
|
||||
sender_loc = rec.get("senderLocator", "")
|
||||
|
||||
# Prefer receiver location (where the signal was heard)
|
||||
loc_str = receiver_loc or sender_loc
|
||||
if not loc_str:
|
||||
continue
|
||||
coords = maidenhead_to_latlon(loc_str)
|
||||
if coords is None:
|
||||
continue
|
||||
lat, lon = coords
|
||||
|
||||
try:
|
||||
freq = int(rec.get("frequency", "0"))
|
||||
except (ValueError, TypeError):
|
||||
freq = 0
|
||||
|
||||
try:
|
||||
snr = int(rec.get("sNR", "0"))
|
||||
except (ValueError, TypeError):
|
||||
snr = 0
|
||||
|
||||
spots.append({
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"sender": (rec.get("senderCallsign") or "")[:20],
|
||||
"receiver": (rec.get("receiverCallsign") or "")[:20],
|
||||
"frequency": freq,
|
||||
"mode": (rec.get("mode") or "FT8")[:10],
|
||||
"snr": snr,
|
||||
"time": rec.get("flowStartSeconds", ""),
|
||||
})
|
||||
|
||||
logger.info("PSK Reporter: fetched %d spots", len(spots))
|
||||
return spots
|
||||
|
||||
except (requests.RequestException, ET.ParseError, Exception) as e:
|
||||
logger.error("PSK Reporter fetch error: %s", e)
|
||||
return []
|
||||
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
# Cache the top feeds for 5 minutes so we don't hammer Broadcastify
|
||||
radio_cache = TTLCache(maxsize=1, ttl=300)
|
||||
|
||||
|
||||
@cached(radio_cache)
|
||||
def get_top_broadcastify_feeds():
|
||||
"""
|
||||
@@ -18,130 +19,184 @@ def get_top_broadcastify_feeds():
|
||||
"""
|
||||
logger.info("Scraping Broadcastify Top Feeds (Cache Miss)")
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
res = requests.get("https://www.broadcastify.com/listen/top", headers=headers, timeout=10)
|
||||
if res.status_code != 200:
|
||||
logger.error(f"Broadcastify Scrape Failed: HTTP {res.status_code}")
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(res.text, 'html.parser')
|
||||
|
||||
table = soup.find('table', {'class': 'btable'})
|
||||
|
||||
soup = BeautifulSoup(res.text, "html.parser")
|
||||
|
||||
table = soup.find("table", {"class": "btable"})
|
||||
if not table:
|
||||
logger.error("Could not find feeds table on Broadcastify.")
|
||||
return []
|
||||
|
||||
|
||||
feeds = []
|
||||
rows = table.find_all('tr')[1:] # Skip header row
|
||||
|
||||
rows = table.find_all("tr")[1:] # Skip header row
|
||||
|
||||
for row in rows:
|
||||
cols = row.find_all('td')
|
||||
cols = row.find_all("td")
|
||||
if len(cols) >= 5:
|
||||
# Top layout: [Listeners, Feed ID (hidden), Location, Feed Name, Category, Genre]
|
||||
listeners_str = cols[0].text.strip().replace(',', '')
|
||||
listeners_str = cols[0].text.strip().replace(",", "")
|
||||
listeners = int(listeners_str) if listeners_str.isdigit() else 0
|
||||
|
||||
link_tag = cols[2].find('a')
|
||||
|
||||
link_tag = cols[2].find("a")
|
||||
if not link_tag:
|
||||
continue
|
||||
|
||||
href = link_tag.get('href', '')
|
||||
feed_id = href.split('/')[-1] if '/listen/feed/' in href else None
|
||||
|
||||
|
||||
href = link_tag.get("href", "")
|
||||
feed_id = href.split("/")[-1] if "/listen/feed/" in href else None
|
||||
|
||||
if not feed_id:
|
||||
continue
|
||||
|
||||
|
||||
location = cols[1].text.strip()
|
||||
name = cols[2].text.strip()
|
||||
category = cols[3].text.strip()
|
||||
|
||||
feeds.append({
|
||||
"id": feed_id,
|
||||
"listeners": listeners,
|
||||
"location": location,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"stream_url": f"https://broadcastify.cdnstream1.com/{feed_id}"
|
||||
})
|
||||
|
||||
|
||||
feeds.append(
|
||||
{
|
||||
"id": feed_id,
|
||||
"listeners": listeners,
|
||||
"location": location,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"stream_url": f"https://broadcastify.cdnstream1.com/{feed_id}",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Successfully scraped {len(feeds)} top feeds from Broadcastify.")
|
||||
return feeds
|
||||
|
||||
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"Broadcastify Scrape Exception: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Cache OpenMHZ systems mapping so we don't have to fetch all 450+ every time
|
||||
openmhz_systems_cache = TTLCache(maxsize=1, ttl=3600)
|
||||
|
||||
|
||||
@cached(openmhz_systems_cache)
|
||||
def get_openmhz_systems():
|
||||
"""Fetches the full directory of OpenMHZ systems."""
|
||||
logger.info("Scraping OpenMHZ Systems (Cache Miss)")
|
||||
scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'desktop': True})
|
||||
|
||||
scraper = cloudscraper.create_scraper(
|
||||
browser={"browser": "chrome", "platform": "windows", "desktop": True}
|
||||
)
|
||||
|
||||
try:
|
||||
res = scraper.get("https://api.openmhz.com/systems", timeout=15)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
# Return list of systems
|
||||
return data.get('systems', []) if isinstance(data, dict) else []
|
||||
return data.get("systems", []) if isinstance(data, dict) else []
|
||||
return []
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"OpenMHZ Systems Scrape Exception: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Cache specific city calls briefly (15-30s) to limit our polling rate
|
||||
openmhz_calls_cache = TTLCache(maxsize=100, ttl=20)
|
||||
|
||||
|
||||
@cached(openmhz_calls_cache)
|
||||
def get_recent_openmhz_calls(sys_name: str):
|
||||
"""Fetches the actual audio burst .m4a URLs for a specific system (e.g., 'wmata')."""
|
||||
logger.info(f"Fetching OpenMHZ calls for {sys_name} (Cache Miss)")
|
||||
scraper = cloudscraper.create_scraper(browser={'browser': 'chrome', 'platform': 'windows', 'desktop': True})
|
||||
|
||||
scraper = cloudscraper.create_scraper(
|
||||
browser={"browser": "chrome", "platform": "windows", "desktop": True}
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"https://api.openmhz.com/{sys_name}/calls"
|
||||
res = scraper.get(url, timeout=15)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
return data.get('calls', []) if isinstance(data, dict) else []
|
||||
return data.get("calls", []) if isinstance(data, dict) else []
|
||||
return []
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"OpenMHZ Calls Scrape Exception ({sys_name}): {e}")
|
||||
return []
|
||||
|
||||
|
||||
US_STATES = {
|
||||
'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR', 'California': 'CA',
|
||||
'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE', 'Florida': 'FL', 'Georgia': 'GA',
|
||||
'Hawaii': 'HI', 'Idaho': 'ID', 'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA',
|
||||
'Kansas': 'KS', 'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD',
|
||||
'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS',
|
||||
'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV', 'New Hampshire': 'NH',
|
||||
'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY', 'North Carolina': 'NC',
|
||||
'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK', 'Oregon': 'OR', 'Pennsylvania': 'PA',
|
||||
'Rhode Island': 'RI', 'South Carolina': 'SC', 'South Dakota': 'SD', 'Tennessee': 'TN',
|
||||
'Texas': 'TX', 'Utah': 'UT', 'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA',
|
||||
'West Virginia': 'WV', 'Wisconsin': 'WI', 'Wyoming': 'WY', 'Washington, D.C.': 'DC', 'District of Columbia': 'DC'
|
||||
"Alabama": "AL",
|
||||
"Alaska": "AK",
|
||||
"Arizona": "AZ",
|
||||
"Arkansas": "AR",
|
||||
"California": "CA",
|
||||
"Colorado": "CO",
|
||||
"Connecticut": "CT",
|
||||
"Delaware": "DE",
|
||||
"Florida": "FL",
|
||||
"Georgia": "GA",
|
||||
"Hawaii": "HI",
|
||||
"Idaho": "ID",
|
||||
"Illinois": "IL",
|
||||
"Indiana": "IN",
|
||||
"Iowa": "IA",
|
||||
"Kansas": "KS",
|
||||
"Kentucky": "KY",
|
||||
"Louisiana": "LA",
|
||||
"Maine": "ME",
|
||||
"Maryland": "MD",
|
||||
"Massachusetts": "MA",
|
||||
"Michigan": "MI",
|
||||
"Minnesota": "MN",
|
||||
"Mississippi": "MS",
|
||||
"Missouri": "MO",
|
||||
"Montana": "MT",
|
||||
"Nebraska": "NE",
|
||||
"Nevada": "NV",
|
||||
"New Hampshire": "NH",
|
||||
"New Jersey": "NJ",
|
||||
"New Mexico": "NM",
|
||||
"New York": "NY",
|
||||
"North Carolina": "NC",
|
||||
"North Dakota": "ND",
|
||||
"Ohio": "OH",
|
||||
"Oklahoma": "OK",
|
||||
"Oregon": "OR",
|
||||
"Pennsylvania": "PA",
|
||||
"Rhode Island": "RI",
|
||||
"South Carolina": "SC",
|
||||
"South Dakota": "SD",
|
||||
"Tennessee": "TN",
|
||||
"Texas": "TX",
|
||||
"Utah": "UT",
|
||||
"Vermont": "VT",
|
||||
"Virginia": "VA",
|
||||
"Washington": "WA",
|
||||
"West Virginia": "WV",
|
||||
"Wisconsin": "WI",
|
||||
"Wyoming": "WY",
|
||||
"Washington, D.C.": "DC",
|
||||
"District of Columbia": "DC",
|
||||
}
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2):
|
||||
R = 3958.8 # Earth radius in miles
|
||||
R = 3958.8 # Earth radius in miles
|
||||
dLat = math.radians(lat2 - lat1)
|
||||
dLon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dLat/2) * math.sin(dLat/2) + \
|
||||
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * \
|
||||
math.sin(dLon/2) * math.sin(dLon/2)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
||||
a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(math.radians(lat1)) * math.cos(
|
||||
math.radians(lat2)
|
||||
) * math.sin(dLon / 2) * math.sin(dLon / 2)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
return R * c
|
||||
|
||||
|
||||
def find_nearest_openmhz_systems_list(lat: float, lng: float, limit: int = 5):
|
||||
"""
|
||||
Finds the strictly nearest OpenMHZ systems by distance.
|
||||
@@ -153,20 +208,21 @@ def find_nearest_openmhz_systems_list(lat: float, lng: float, limit: int = 5):
|
||||
# Calculate distance for all systems that provide coordinates
|
||||
valid_systems = []
|
||||
for s in systems:
|
||||
s_lat = s.get('lat')
|
||||
s_lng = s.get('lng')
|
||||
s_lat = s.get("lat")
|
||||
s_lng = s.get("lng")
|
||||
if s_lat is not None and s_lng is not None:
|
||||
dist = haversine_distance(lat, lng, float(s_lat), float(s_lng))
|
||||
s['distance_miles'] = dist
|
||||
s["distance_miles"] = dist
|
||||
valid_systems.append(s)
|
||||
|
||||
if not valid_systems:
|
||||
return []
|
||||
|
||||
# Sort strictly by distance
|
||||
valid_systems.sort(key=lambda x: x['distance_miles'])
|
||||
valid_systems.sort(key=lambda x: x["distance_miles"])
|
||||
return valid_systems[:limit]
|
||||
|
||||
|
||||
def find_nearest_openmhz_system(lat: float, lng: float):
|
||||
"""
|
||||
Returns the single closest OpenMHZ system by distance.
|
||||
|
||||
@@ -16,13 +16,38 @@ dossier_cache = TTLCache(maxsize=500, ttl=86400)
|
||||
_nominatim_last_call = 0.0
|
||||
|
||||
|
||||
def _reverse_geocode_offline(lat: float, lng: float) -> dict:
|
||||
"""Offline fallback via reverse_geocoder when external reverse geocoding is blocked."""
|
||||
try:
|
||||
import reverse_geocoder as rg
|
||||
|
||||
hit = rg.search((lat, lng), mode=1)[0]
|
||||
country_code = (hit.get("cc") or "").upper()
|
||||
city = hit.get("name") or ""
|
||||
state = hit.get("admin1") or ""
|
||||
display = ", ".join(part for part in [city, state, country_code] if part)
|
||||
return {
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": country_code or "Unknown",
|
||||
"country_code": country_code,
|
||||
"display_name": display,
|
||||
"offline_fallback": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Offline reverse geocode failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lng: float) -> dict:
|
||||
global _nominatim_last_call
|
||||
url = (
|
||||
f"https://nominatim.openstreetmap.org/reverse?"
|
||||
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
|
||||
)
|
||||
headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
|
||||
headers = {
|
||||
"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"
|
||||
}
|
||||
|
||||
for attempt in range(2):
|
||||
# Enforce Nominatim's 1 req/sec policy
|
||||
@@ -33,26 +58,32 @@ def _reverse_geocode(lat: float, lng: float) -> dict:
|
||||
|
||||
try:
|
||||
# Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
|
||||
res = _requests.get(url, timeout=10, headers=headers)
|
||||
res = _requests.get(url, timeout=4, headers=headers)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"city": addr.get("city")
|
||||
or addr.get("town")
|
||||
or addr.get("village")
|
||||
or addr.get("county")
|
||||
or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
elif res.status_code == 429:
|
||||
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
|
||||
time.sleep(2)
|
||||
logger.warning(
|
||||
f"Nominatim 429 rate-limited, retrying after 1s (attempt {attempt+1})"
|
||||
)
|
||||
time.sleep(1)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Nominatim returned {res.status_code}")
|
||||
except (_requests.RequestException, ConnectionError, TimeoutError, OSError) as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
return {}
|
||||
return _reverse_geocode_offline(lat, lng)
|
||||
|
||||
|
||||
def _fetch_country_data(country_code: str) -> dict:
|
||||
@@ -63,9 +94,12 @@ def _fetch_country_data(country_code: str) -> dict:
|
||||
f"?fields=name,population,capital,languages,region,subregion,area,currencies,borders,flag"
|
||||
)
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
res = fetch_with_curl(url, timeout=5)
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
data = res.json()
|
||||
if isinstance(data, list):
|
||||
return data[0] if data and isinstance(data[0], dict) else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
||||
logger.warning(f"RestCountries failed for {country_code}: {e}")
|
||||
return {}
|
||||
@@ -87,7 +121,7 @@ def _fetch_wikidata_leader(country_name: str) -> dict:
|
||||
"""
|
||||
url = f"https://query.wikidata.org/sparql?query={quote(sparql)}&format=json"
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=15)
|
||||
res = fetch_with_curl(url, timeout=6)
|
||||
if res.status_code == 200:
|
||||
results = res.json().get("results", {}).get("bindings", [])
|
||||
if results:
|
||||
@@ -113,7 +147,7 @@ def _fetch_local_wiki_summary(place_name: str, country_name: str = "") -> dict:
|
||||
slug = quote(name.replace(" ", "_"))
|
||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{slug}"
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
res = fetch_with_curl(url, timeout=5)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
if data.get("type") != "disambiguation":
|
||||
@@ -122,7 +156,13 @@ def _fetch_local_wiki_summary(place_name: str, country_name: str = "") -> dict:
|
||||
"extract": data.get("extract", ""),
|
||||
"thumbnail": data.get("thumbnail", {}).get("source", ""),
|
||||
}
|
||||
except (ConnectionError, TimeoutError, ValueError, KeyError, OSError): # Intentional: optional enrichment
|
||||
except (
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
OSError,
|
||||
): # Intentional: optional enrichment
|
||||
continue
|
||||
return {}
|
||||
|
||||
@@ -148,33 +188,37 @@ def get_region_dossier(lat: float, lng: float) -> dict:
|
||||
city_name = geo.get("city", "")
|
||||
state_name = geo.get("state", "")
|
||||
|
||||
# Step 2: Parallel fetch with timeouts to prevent hanging
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
# Step 2: Parallel fetch with real timeouts that do not block on executor shutdown
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
|
||||
try:
|
||||
country_fut = pool.submit(_fetch_country_data, country_code)
|
||||
leader_fut = pool.submit(_fetch_wikidata_leader, country_name)
|
||||
local_fut = pool.submit(_fetch_local_wiki_summary, city_name or state_name, country_name)
|
||||
# Also fetch country-level Wikipedia summary as fallback for local
|
||||
local_fut = pool.submit(
|
||||
_fetch_local_wiki_summary, city_name or state_name, country_name
|
||||
)
|
||||
country_wiki_fut = pool.submit(_fetch_local_wiki_summary, country_name, "")
|
||||
|
||||
try:
|
||||
country_data = country_fut.result(timeout=12)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Country data fetch timed out or failed")
|
||||
country_data = {}
|
||||
try:
|
||||
leader_data = leader_fut.result(timeout=12)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Leader data fetch timed out or failed")
|
||||
leader_data = {"leader": "Unknown", "government_type": "Unknown"}
|
||||
try:
|
||||
local_data = local_fut.result(timeout=12)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Local wiki fetch timed out or failed")
|
||||
local_data = {}
|
||||
try:
|
||||
country_wiki_data = country_wiki_fut.result(timeout=12)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
country_wiki_data = {}
|
||||
try:
|
||||
country_data = country_fut.result(timeout=6)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Country data fetch timed out or failed")
|
||||
country_data = {}
|
||||
try:
|
||||
leader_data = leader_fut.result(timeout=6)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Leader data fetch timed out or failed")
|
||||
leader_data = {"leader": "Unknown", "government_type": "Unknown"}
|
||||
try:
|
||||
local_data = local_fut.result(timeout=5)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
logger.warning("Local wiki fetch timed out or failed")
|
||||
local_data = {}
|
||||
try:
|
||||
country_wiki_data = country_wiki_fut.result(timeout=5)
|
||||
except Exception: # Intentional: optional enrichment
|
||||
country_wiki_data = {}
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
# If no local data but we have country wiki summary, use that
|
||||
if not local_data.get("extract") and country_wiki_data.get("extract"):
|
||||
@@ -203,7 +247,11 @@ def get_region_dossier(lat: float, lng: float) -> dict:
|
||||
"leader": leader_data.get("leader", "Unknown"),
|
||||
"government_type": leader_data.get("government_type", "Unknown"),
|
||||
"population": country_data.get("population", 0),
|
||||
"capital": (country_data.get("capital") or ["Unknown"])[0] if isinstance(country_data.get("capital"), list) else "Unknown",
|
||||
"capital": (
|
||||
(country_data.get("capital") or ["Unknown"])[0]
|
||||
if isinstance(country_data.get("capital"), list)
|
||||
else "Unknown"
|
||||
),
|
||||
"languages": lang_list,
|
||||
"currencies": currency_list,
|
||||
"region": country_data.get("region", ""),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
SatNOGS ground station + observation fetcher.
|
||||
Queries the SatNOGS Network API for online ground stations and recent
|
||||
satellite observations. No API key required for read-only access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_station_cache = TTLCache(maxsize=1, ttl=600) # 10-minute cache
|
||||
_obs_cache = TTLCache(maxsize=1, ttl=300) # 5-minute cache
|
||||
|
||||
|
||||
@cached(_station_cache)
|
||||
def fetch_satnogs_stations() -> list[dict]:
|
||||
"""Fetch online SatNOGS ground stations (status=2 = online)."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://network.satnogs.org/api/stations/",
|
||||
params={"format": "json", "status": 2},
|
||||
timeout=20,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
stations = []
|
||||
for s in resp.json():
|
||||
lat, lng = s.get("lat"), s.get("lng")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
lat, lng = float(lat), float(lng)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if abs(lat) > 90 or abs(lng) > 180:
|
||||
continue
|
||||
|
||||
antennas = s.get("antenna") or []
|
||||
antenna_str = ", ".join(
|
||||
a.get("antenna_type", "") for a in antennas if a.get("antenna_type")
|
||||
)
|
||||
|
||||
stations.append(
|
||||
{
|
||||
"id": s.get("id"),
|
||||
"name": (s.get("name") or "Unknown")[:120],
|
||||
"lat": round(lat, 5),
|
||||
"lng": round(lng, 5),
|
||||
"altitude": s.get("altitude"),
|
||||
"antenna": antenna_str[:200],
|
||||
"observations": s.get("observations", 0),
|
||||
"status": s.get("status"),
|
||||
"last_seen": s.get("last_seen"),
|
||||
}
|
||||
)
|
||||
logger.info(f"SatNOGS: fetched {len(stations)} online stations")
|
||||
return stations
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"SatNOGS stations error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@cached(_obs_cache)
|
||||
def fetch_satnogs_observations() -> list[dict]:
|
||||
"""Fetch recent good observations (first page, ~25 results)."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://network.satnogs.org/api/observations/",
|
||||
params={"format": "json", "status": "good"},
|
||||
timeout=20,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
obs = []
|
||||
for o in resp.json():
|
||||
lat = o.get("station_lat")
|
||||
lng = o.get("station_lng")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
try:
|
||||
lat, lng = float(lat), float(lng)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Satellite name from TLE line 0, or fall back to NORAD ID
|
||||
tle0 = (o.get("tle0") or "").strip()
|
||||
sat_name = tle0 if tle0 else f"NORAD {o.get('norad_cat_id', '?')}"
|
||||
|
||||
obs.append(
|
||||
{
|
||||
"id": o.get("id"),
|
||||
"satellite_name": sat_name[:80],
|
||||
"norad_id": o.get("norad_cat_id"),
|
||||
"station_name": (o.get("station_name") or "Unknown")[:80],
|
||||
"lat": round(lat, 5),
|
||||
"lng": round(lng, 5),
|
||||
"start": o.get("start"),
|
||||
"end": o.get("end"),
|
||||
"frequency": o.get("transmitter_downlink_low"),
|
||||
"mode": o.get("transmitter_mode"),
|
||||
"waterfall": o.get("waterfall"),
|
||||
"audio": o.get("archive_url") or o.get("payload"),
|
||||
"status": o.get("vetted_status"),
|
||||
}
|
||||
)
|
||||
logger.info(f"SatNOGS: fetched {len(obs)} recent observations")
|
||||
return obs
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e:
|
||||
logger.error(f"SatNOGS observations error: {e}")
|
||||
return []
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Sentinel-2 satellite imagery search via Microsoft Planetary Computer STAC API.
|
||||
Free, keyless search for metadata + thumbnails. Used in the right-click dossier.
|
||||
|
||||
We use the raw STAC HTTP API with explicit timeouts so the right-click dossier
|
||||
cannot hang behind a slow client library call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,6 +17,32 @@ logger = logging.getLogger(__name__)
|
||||
_sentinel_cache = TTLCache(maxsize=200, ttl=3600)
|
||||
|
||||
|
||||
def _esri_imagery_fallback(lat: float, lng: float) -> dict:
|
||||
lat_span = 0.18
|
||||
lng_span = 0.24
|
||||
bbox = f"{lng - lng_span},{lat - lat_span},{lng + lng_span},{lat + lat_span}"
|
||||
fullres = (
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/"
|
||||
f"export?bbox={bbox}&bboxSR=4326&imageSR=4326&size=1600,900&format=png32&f=image"
|
||||
)
|
||||
thumbnail = (
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/"
|
||||
f"export?bbox={bbox}&bboxSR=4326&imageSR=4326&size=640,360&format=png32&f=image"
|
||||
)
|
||||
return {
|
||||
"found": True,
|
||||
"scene_id": None,
|
||||
"datetime": None,
|
||||
"cloud_cover": None,
|
||||
"thumbnail_url": thumbnail,
|
||||
"fullres_url": fullres,
|
||||
"bbox": [lng - lng_span, lat - lat_span, lng + lng_span, lat + lat_span],
|
||||
"platform": "Esri World Imagery",
|
||||
"fallback": True,
|
||||
"message": "Planetary Computer unavailable; using Esri World Imagery fallback",
|
||||
}
|
||||
|
||||
|
||||
def search_sentinel2_scene(lat: float, lng: float) -> dict:
|
||||
"""Search for the latest Sentinel-2 L2A scene covering a point."""
|
||||
cache_key = f"{round(lat, 2)}_{round(lng, 2)}"
|
||||
@@ -21,62 +50,56 @@ def search_sentinel2_scene(lat: float, lng: float) -> dict:
|
||||
return _sentinel_cache[cache_key]
|
||||
|
||||
try:
|
||||
from pystac_client import Client
|
||||
|
||||
catalog = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(days=30)
|
||||
|
||||
search = catalog.search(
|
||||
collections=["sentinel-2-l2a"],
|
||||
intersects={"type": "Point", "coordinates": [lng, lat]},
|
||||
datetime=f"{start.isoformat()}Z/{end.isoformat()}Z",
|
||||
sortby=[{"field": "datetime", "direction": "desc"}],
|
||||
max_items=3,
|
||||
query={"eo:cloud_cover": {"lt": 30}},
|
||||
search_payload = {
|
||||
"collections": ["sentinel-2-l2a"],
|
||||
"intersects": {"type": "Point", "coordinates": [lng, lat]},
|
||||
"datetime": f"{start.isoformat()}Z/{end.isoformat()}Z",
|
||||
"sortby": [{"field": "datetime", "direction": "desc"}],
|
||||
"limit": 3,
|
||||
"query": {"eo:cloud_cover": {"lt": 30}},
|
||||
}
|
||||
search_res = requests.post(
|
||||
"https://planetarycomputer.microsoft.com/api/stac/v1/search",
|
||||
json=search_payload,
|
||||
timeout=8,
|
||||
headers={"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard)"},
|
||||
)
|
||||
|
||||
items = list(search.items())
|
||||
if not items:
|
||||
result = {"found": False, "message": "No clear scenes in last 30 days"}
|
||||
search_res.raise_for_status()
|
||||
data = search_res.json()
|
||||
features = data.get("features", [])
|
||||
if not features:
|
||||
result = _esri_imagery_fallback(lat, lng)
|
||||
_sentinel_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
item = items[0]
|
||||
# Try to sign item first for Azure blob URLs
|
||||
try:
|
||||
import planetary_computer
|
||||
item = planetary_computer.sign_item(item)
|
||||
except ImportError:
|
||||
pass # planetary_computer not installed, try unsigned URLs
|
||||
except (ConnectionError, TimeoutError, ValueError) as e:
|
||||
logger.warning(f"Sentinel-2 signing failed: {e}")
|
||||
|
||||
# Get the rendered_preview (full-res PNG) and thumbnail separately
|
||||
rendered = item.assets.get("rendered_preview")
|
||||
thumbnail = item.assets.get("thumbnail")
|
||||
item = features[0]
|
||||
assets = item.get("assets", {}) or {}
|
||||
rendered = assets.get("rendered_preview") or {}
|
||||
thumbnail = assets.get("thumbnail") or {}
|
||||
|
||||
# Full-res image URL — what opens when user clicks
|
||||
fullres_url = rendered.href if rendered else (thumbnail.href if thumbnail else None)
|
||||
fullres_url = rendered.get("href") or thumbnail.get("href")
|
||||
# Thumbnail URL — what shows in the popup card
|
||||
thumb_url = thumbnail.href if thumbnail else (rendered.href if rendered else None)
|
||||
thumb_url = thumbnail.get("href") or rendered.get("href")
|
||||
|
||||
result = {
|
||||
"found": True,
|
||||
"scene_id": item.id,
|
||||
"datetime": item.datetime.isoformat() if item.datetime else None,
|
||||
"cloud_cover": item.properties.get("eo:cloud_cover"),
|
||||
"scene_id": item.get("id"),
|
||||
"datetime": item.get("properties", {}).get("datetime"),
|
||||
"cloud_cover": item.get("properties", {}).get("eo:cloud_cover"),
|
||||
"thumbnail_url": thumb_url,
|
||||
"fullres_url": fullres_url,
|
||||
"bbox": list(item.bbox) if item.bbox else None,
|
||||
"platform": item.properties.get("platform", "Sentinel-2"),
|
||||
"bbox": list(item.get("bbox", [])) if item.get("bbox") else None,
|
||||
"platform": item.get("properties", {}).get("platform", "Sentinel-2"),
|
||||
}
|
||||
_sentinel_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
except ImportError:
|
||||
logger.warning("pystac-client not installed — Sentinel-2 search unavailable")
|
||||
return {"found": False, "error": "pystac-client not installed"}
|
||||
except (requests.RequestException, ConnectionError, TimeoutError, ValueError) as e:
|
||||
logger.error(f"Sentinel-2 search failed for ({lat}, {lng}): {e}")
|
||||
return {"found": False, "error": str(e)}
|
||||
result = _esri_imagery_fallback(lat, lng)
|
||||
result["error"] = str(e)
|
||||
_sentinel_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user