security: CORS hardening, path traversal fix, WebSocket auth + cleanup

- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:45:43 -04:00
parent 3e04c4113f
commit cfd94f905a
47 changed files with 1847 additions and 427 deletions
+9 -4
View File
@@ -119,10 +119,15 @@ def evaluate_vessel(vessel, aids, config):
return alerts
def evaluate_aid_movement(aid_id, lat_actual, lon_actual, lat_nominal, lon_nominal, config=None):
config = config or {}
warn_m = config.get("displacement_warn_m", 10.0)
alarm_m = config.get("displacement_alarm_m", 15.0)
def evaluate_aid_movement(aid_id, lat_actual, lon_actual, lat_nominal, lon_nominal,
config=None, warn_m=None, alarm_m=None):
"""
warn_m / alarm_m: per-aid override from Aid.displacement_warn_m / alarm_m.
If None, falls back to global config values.
"""
config = config or {}
warn_m = warn_m if warn_m is not None else config.get("displacement_warn_m", 10.0)
alarm_m = alarm_m if alarm_m is not None else config.get("displacement_alarm_m", 15.0)
desplazamiento = haversine(lat_actual, lon_actual, lat_nominal, lon_nominal)
en_movimiento = detect_continuous_movement(aid_id, lat_actual, lon_actual)
+19 -3
View File
@@ -113,11 +113,22 @@ def decode_type8_aton(payload: str) -> dict | None:
analog3 = _bits(payload, 81, 12) * 0.05
analog4 = _bits(payload, 93, 12) * 0.05
# Digital bits: racon, lamp, buoy code alarm, controller alarm, etc.
racon = bool(_bits(payload, 105, 2))
light_ok = bool(_bits(payload, 107, 2))
health = _bits(payload, 109, 2) # 0=ok,1=warn,2=alarm,3=no signal
racon = bool(_bits(payload, 105, 2))
light_ok = bool(_bits(payload, 107, 2))
health = _bits(payload, 109, 2) # 0=ok,1=warn,2=alarm,3=no signal
battery_low = bool(_bits(payload, 111, 1))
# IEC 62320-2 extended digital inputs (bits 112119)
# bit 112 = buoy code / hull integrity → water ingress sensor (IN3)
# bit 113 = controller alarm → listing/tilt sensor (IN4)
# bit 114 = fog signal status
# bit 115 = EPIRB armed
# bit 116 = water level alarm → bilge high (critical)
# bits 117-119 = spare / manufacturer-defined
din3 = bool(_bits(payload, 112, 1)) # hull/water ingress
din4 = bool(_bits(payload, 113, 1)) # listing/tilt
water_level = bool(_bits(payload, 116, 1)) # bilge high level
return {
"mmsi": str(mmsi),
"msg_type": 8,
@@ -132,6 +143,11 @@ def decode_type8_aton(payload: str) -> dict | None:
"light_ok": light_ok,
"health": health,
"battery_low": battery_low,
# Digital inputs — meaning depends on din3_function / din4_function
# configured per-aid in the Aid model
"din3": din3, # IN3 state (True = triggered)
"din4": din4, # IN4 state (True = triggered)
"water_level_high": water_level, # bilge high level (IEC standard bit)
"timestamp": datetime.utcnow().isoformat(),
}
except Exception:
+108 -11
View File
@@ -1143,12 +1143,79 @@ def list_cells() -> list[dict]:
def delete_cell(cell_id: str):
cell_dir = CHARTS_DIR / cell_id.upper()
cell_dir = (CHARTS_DIR / cell_id.upper()).resolve()
if CHARTS_DIR.resolve() not in cell_dir.parents:
raise ValueError(f"Invalid cell_id: {cell_id}")
if cell_dir.exists():
shutil.rmtree(cell_dir)
def get_all_features() -> dict:
# Per-(cell, file) coverage bbox cache. Populated lazily the first time a
# cell's GeoJSON file is read; subsequent bbox queries can skip the file
# entirely if its coverage doesn't intersect the query bbox. Keyed by the
# absolute path of the cache file. Invalidated implicitly on cache rebuild
# because rebuilt files get a fresh mtime, which we include in the key.
_cell_coverage_cache: dict[str, tuple[float, float, float, float]] = {}
def _coverage_bbox(features: list) -> tuple[float, float, float, float] | None:
"""Return (min_lon, min_lat, max_lon, max_lat) covering all features.
Falls back to None when no coordinates are extractable."""
min_lon = min_lat = float("inf")
max_lon = max_lat = float("-inf")
found = False
for f in features:
# Prefer the explicit per-feature bbox if the build wrote one
fb = (f.get("properties") or {}).get("bbox")
if fb and len(fb) == 4:
min_lon = min(min_lon, fb[0]); min_lat = min(min_lat, fb[1])
max_lon = max(max_lon, fb[2]); max_lat = max(max_lat, fb[3])
found = True
continue
geom = f.get("geometry") or {}
gt = geom.get("type")
coords = geom.get("coordinates")
if not coords:
continue
# Fast path for Point — by far the most common
if gt == "Point":
lon, lat = coords[0], coords[1]
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
found = True
else:
# Walk the nested coordinate arrays (LineString, Polygon, Multi*)
stack = [coords]
while stack:
x = stack.pop()
if isinstance(x, (list, tuple)) and len(x) >= 2 and not isinstance(x[0], (list, tuple)):
lon, lat = x[0], x[1]
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
found = True
elif isinstance(x, (list, tuple)):
stack.extend(x)
return (min_lon, min_lat, max_lon, max_lat) if found else None
def _feature_in_bbox(feat: dict, w: float, s: float, e: float, n: float) -> bool:
"""Spatial filter: return True iff the feature intersects (w,s,e,n).
Prefers a pre-computed properties.bbox, falls back to Point geometry."""
fb = (feat.get("properties") or {}).get("bbox")
if fb and len(fb) == 4:
return not (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n)
geom = feat.get("geometry") or {}
if geom.get("type") == "Point":
c = geom.get("coordinates") or [None, None]
if c[0] is None or c[1] is None:
return True # malformed — keep, don't lose it
return w <= c[0] <= e and s <= c[1] <= n
# No bbox + non-point geometry — keep it (better to render than to lose).
return True
def get_all_features(bbox: tuple[float, float, float, float] | None = None) -> dict:
all_features = []
for cell_dir in CHARTS_DIR.iterdir():
cache = cell_dir / "features.geojson"
@@ -1167,30 +1234,60 @@ def get_all_features() -> dict:
# Backfill aid_type for old caches that pre-date the classifier.
if "aid_type" not in p:
p["aid_type"] = classify(p.get("layer", ""), p)
all_features.extend(fc["features"])
if bbox is not None and not _feature_in_bbox(f, *bbox):
continue
all_features.append(f)
return {"type": "FeatureCollection", "features": all_features}
def _aggregate_cache(filename: str, bbox=None) -> dict:
"""Generic aggregator: read <filename> from every installed cell."""
"""Generic aggregator: read <filename> from every installed cell.
Uses a per-file coverage-bbox cache to skip cells that don't intersect
the query bbox without reading their GeoJSON content."""
all_features = []
w = s = e = n = None
if bbox is not None:
w, s, e, n = bbox
for cell_dir in CHARTS_DIR.iterdir():
if not cell_dir.is_dir():
continue
cache = cell_dir / filename
if not cache.exists():
continue
# Pre-skip via cached cell-coverage bbox. Key includes mtime so
# the entry self-invalidates when the file is rebuilt.
if bbox is not None:
try:
mtime = cache.stat().st_mtime
except OSError:
mtime = 0
cov_key = f"{cache}|{mtime}"
cov = _cell_coverage_cache.get(cov_key)
if cov is not None:
if cov[2] < w or cov[0] > e or cov[3] < s or cov[1] > n:
continue # cell entirely outside query — skip file open
try:
fc = json.loads(cache.read_text())
except Exception:
continue
for f in (fc.get("features") or []):
feats = fc.get("features") or []
# Populate coverage cache on first read so subsequent bbox queries
# can short-circuit.
if bbox is not None and cov_key not in _cell_coverage_cache:
cov2 = _coverage_bbox(feats)
if cov2 is not None:
_cell_coverage_cache[cov_key] = cov2
if cov2[2] < w or cov2[0] > e or cov2[3] < s or cov2[1] > n:
continue # just learned: cell outside query
for f in feats:
p = f.setdefault("properties", {})
p["cell"] = cell_dir.name
if bbox is not None:
fb = p.get("bbox")
if fb and (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n):
continue
if bbox is not None and not _feature_in_bbox(f, w, s, e, n):
continue
all_features.append(f)
return {"type": "FeatureCollection", "features": all_features}
@@ -1201,8 +1298,8 @@ def get_all_depths(bbox: tuple[float, float, float, float] | None = None) -> dic
def get_all_land(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("land.geojson", bbox)
def get_all_hazards() -> dict:
return _aggregate_cache("hazards.geojson")
def get_all_hazards(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("hazards.geojson", bbox)
def get_all_zones(bbox: tuple[float, float, float, float] | None = None) -> dict:
return _aggregate_cache("zones.geojson", bbox)
+10
View File
@@ -53,6 +53,16 @@ DEFAULTS: dict = {
"smtp_from": "",
"smtp_from_name": "AidsMonitoring",
"smtp_use_tls": True,
# ── Cluster / multi-server role ──────────────────────────────────────────
# STANDALONE: single server (default)
# MASTER : central aggregator — accepts connections from slave servers
# SLAVE : field server — forwards all events to the master
"server_role": os.getenv("SERVER_ROLE", "STANDALONE"),
# URL of the master's slave WebSocket endpoint (required when SLAVE)
# Example: "ws://10.0.0.1:8000/ws/slave"
"master_url": os.getenv("MASTER_URL", ""),
# Human-readable name for this slave (shown in master's status panel)
"slave_name": os.getenv("SLAVE_NAME", ""),
}
SETTINGS: dict = dict(DEFAULTS)
+177
View File
@@ -0,0 +1,177 @@
"""
AidsMonitoring Slave relay service
=====================================
When server_role == "SLAVE", this service maintains a persistent WebSocket
connection to the master server and forwards all AIS/ATON/aid_position/alert
events upstream.
The relay is fully non-blocking:
- Events are enqueued from broadcast() via send() (never blocks)
- A single asyncio task drains the queue and writes to the master WS
- If the master is unreachable, a ring-buffer keeps the last max_queue
messages; older events are silently dropped
- On reconnect the slave sends a hello so the master can log it
Usage (from main.py):
relay = SlaveRelay(master_url="ws://10.0.0.1:8000/ws/slave",
slave_name="Puerto Barranquilla")
await relay.start()
...
relay.send({"type": "vessel", "mmsi": "012345678", ...})
...
await relay.stop()
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections import deque
log = logging.getLogger("aids.slave_relay")
class SlaveRelay:
"""Non-blocking WebSocket relay: slave → master."""
def __init__(
self,
master_url: str,
slave_name: str,
max_queue: int = 500,
reconnect_delay: float = 5.0,
):
"""
Parameters
----------
master_url WebSocket URL of master's slave endpoint.
e.g. "ws://192.168.1.10:8000/ws/slave"
slave_name Human-readable identifier sent in hello message.
e.g. "Puerto Barranquilla"
max_queue Ring-buffer capacity. Oldest events dropped when full.
reconnect_delay Seconds to wait before reconnecting after a disconnect.
"""
self.master_url = master_url
self.slave_name = slave_name
self._max_queue = max_queue
self._reconnect_delay = reconnect_delay
self._queue: deque = deque(maxlen=max_queue)
self._task: asyncio.Task | None = None
self._running = False
self._connected = False
self._connect_count = 0 # total successful connections (for logging)
# ── Public API ──────────────────────────────────────────────────────────
async def start(self):
"""Start the background relay task."""
if self._task and not self._task.done():
return # already running
self._running = True
self._task = asyncio.create_task(self._run(), name="slave_relay")
log.info(f"[slave] Relay iniciado → {self.master_url} (nombre='{self.slave_name}')")
async def stop(self):
"""Cancel the relay task and wait for it to finish."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._connected = False
log.info("[slave] Relay detenido.")
def send(self, msg: dict):
"""
Enqueue an event for forwarding. Non-blocking safe to call from
broadcast() in the main event loop. Drops oldest if queue is full.
"""
if not self._running:
return
# Tag every message with slave origin so the master knows the source
tagged = {**msg, "_slave": self.slave_name}
self._queue.append(tagged)
# ── Status ───────────────────────────────────────────────────────────────
@property
def connected(self) -> bool:
return self._connected
@property
def queue_len(self) -> int:
return len(self._queue)
def status(self) -> dict:
return {
"running": self._running,
"connected": self._connected,
"master_url": self.master_url,
"slave_name": self.slave_name,
"queue_len": len(self._queue),
"connect_count": self._connect_count,
}
# ── Background task ──────────────────────────────────────────────────────
async def _run(self):
import websockets
while self._running:
try:
log.info(f"[slave] Conectando a maestro {self.master_url}")
async with websockets.connect(
self.master_url,
ping_interval=20,
ping_timeout=10,
close_timeout=5,
open_timeout=10,
) as ws:
self._connected = True
self._connect_count += 1
log.info(
f"[slave] Conectado al maestro "
f"(conexión #{self._connect_count}, "
f"{len(self._queue)} msgs en cola)"
)
# ── Announce this slave ───────────────────────────────
await ws.send(json.dumps({
"type": "slave_hello",
"slave_name": self.slave_name,
"version": "1.0",
}))
# ── Drain queue continuously ──────────────────────────
while self._running:
if self._queue:
msg = self._queue.popleft()
try:
await ws.send(json.dumps(msg))
except Exception as send_err:
# Re-queue the failed message (prepend)
self._queue.appendleft(msg)
log.warning(
f"[slave] Error al enviar: {send_err}"
)
break # force reconnect
else:
# Nothing to send — yield to event loop
await asyncio.sleep(0.02) # 20 ms idle
except asyncio.CancelledError:
break
except Exception as conn_err:
log.warning(
f"[slave] Desconectado ({conn_err}). "
f"Reintentando en {self._reconnect_delay} s …"
)
finally:
self._connected = False
if self._running:
await asyncio.sleep(self._reconnect_delay)