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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
@@ -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 112–119)
|
||||
# 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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user