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
+349 -13
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from contextlib import asynccontextmanager
import asyncio
import json
@@ -44,6 +45,7 @@ from services.alert_engine import evaluate_vessel, evaluate_aid_movement, aid_al
from services.gps_reader import GPSReader
from services.aton_decoder import decode_type21, decode_type8_aton, process_aton_message, aton_state
from services import settings_store
from services.slave_relay import SlaveRelay
import services.ais_catcher as _ais_catcher
from services.ais_udp_reader import run_udp_listener
import services.ais_udp_reader as _ais_udp
@@ -56,7 +58,18 @@ models.lamp.Base.metadata.create_all(bind=engine)
models.contact.Base.metadata.create_all(bind=engine)
models.org.Base.metadata.create_all(bind=engine)
# Additive migrations for columns added after first install
ensure_column("aids", "lamp_id", "TEXT")
ensure_column("aids", "lamp_id", "TEXT")
ensure_column("aids", "displacement_warn_m", "REAL")
ensure_column("aids", "displacement_alarm_m", "REAL")
ensure_column("aids", "signal_loss_min", "INTEGER")
ensure_column("aids", "din3_function", "TEXT")
ensure_column("aids", "din4_function", "TEXT")
# Link estable a feature S-57 (cell + feature_id estable cuando viene de carta)
ensure_column("aids", "source_chart", "TEXT")
ensure_column("aids", "cell_id", "TEXT")
ensure_column("aids", "chart_feature_id", "TEXT")
ensure_column("lamps", "warn_pct", "REAL DEFAULT 20.0")
ensure_column("lamps", "alarm_pct", "REAL DEFAULT 10.0")
ensure_column("users", "prefs_json", "TEXT")
ensure_column("users", "company_id", "TEXT")
@@ -73,12 +86,22 @@ _aid_ownership_cache: dict[str, set] = {} # company_id → {aid_id, ...}
_vessel_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon}
_aton_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon}
# Signal-loss monitoring: last time each AIS AtoN was heard from
_aton_last_seen: dict[str, datetime] = {} # mmsi → datetime UTC
_signal_loss_state: dict[str, bool] = {} # mmsi → True if alert already sent
# Digital input alert state: "{mmsi}_{din3|din4}" → True if currently triggered
_digital_alert_state: dict[str, bool] = {}
# Source of truth for runtime config — mutated via POST /settings.
config = settings_store.SETTINGS
# Background AIS reader task handle (lets us stop/restart on source change)
_ais_task: asyncio.Task | None = None
# Slave relay — active when server_role == "SLAVE"
_slave_relay: SlaveRelay | None = None
# Last-known battery alert state per ATON to avoid re-emitting every msg.
# Values: None | 'WARN' | 'ALARM'
_battery_alert_state: dict[str, str | None] = {}
@@ -200,6 +223,8 @@ async def broadcast(message: dict,
owned_mmsi → filter vessel/AtoN traffic by MMSI (company users see only their own)
owned_aid_id → filter aid-position updates by aid_id
Admins (company_id=None) always receive everything.
When server_role == "SLAVE", also forwards the event upstream to the master.
"""
data = json.dumps(message)
dead = []
@@ -214,6 +239,10 @@ async def broadcast(message: dict,
if c in connected_clients:
connected_clients.remove(c)
# Forward upstream when acting as a slave
if _slave_relay is not None:
_slave_relay.send(message)
async def _persist_recording(db, alert: dict):
"""Save or close a RecordingEvent row when auto-recording triggers."""
from models.vessel import RecordingEvent
@@ -253,7 +282,9 @@ async def process_message(msg: dict):
alerts = evaluate_vessel(msg, aids_list, config)
await broadcast(msg) # vessels = public traffic, no filter
for alert in alerts:
await broadcast({"type": "alert", **alert})
# Filter proximity alerts to the company that owns the aid involved
await broadcast({"type": "alert", **alert},
owned_aid_id=alert.get("aid_id"))
if alert["tipo"] in ("GRABACION_INICIADA", "GRABACION_FINALIZADA"):
await _persist_recording(db, alert)
@@ -279,10 +310,20 @@ async def process_message(msg: dict):
if entry:
mmsi = entry["mmsi"]
aton_state[mmsi] = entry
_aton_last_seen[mmsi] = datetime.utcnow() # track for signal-loss
if _signal_loss_state.pop(mmsi, False):
# Signal restored — clear any active loss alert on clients
await broadcast({"type": "alert",
"tipo": "SENAL_RESTAURADA",
"mmsi": mmsi,
"timestamp": datetime.utcnow().isoformat()},
owned_mmsi=mmsi)
await broadcast({"type": "aton", **entry}, owned_mmsi=mmsi)
# Auto-upsert Aid row on first sight (Type 21 carries name + position).
# The user must then assign a lamp via the right panel.
# When an existing Aid with matching MMSI is found, update its
# lat_actual + run drift evaluation using that aid's per-buoy
# thresholds so the alert engine fires correctly.
if msg.get("msg_type") == 21 and entry.get("lat") is not None:
aid = db.query(Aid).filter(Aid.mmsi == mmsi).first()
if not aid:
@@ -298,6 +339,7 @@ async def process_message(msg: dict):
lat_nominal=entry["lat"],
lon_nominal=entry["lon"],
fuente_posicion="AIS",
source_chart="AIS",
)
db.add(aid); db.commit()
await broadcast({
@@ -311,6 +353,43 @@ async def process_message(msg: dict):
"mensaje": "Nueva ayuda detectada — falta asignar lámpara",
"timestamp": datetime.utcnow().isoformat(),
})
else:
# Existing aid configured by operator (or auto-created).
# Update its actual position, displacement, and broadcast
# an aid_position event so the map can render the AIS
# ghost marker. Drift alerts use this aid's per-buoy
# thresholds (or fall back to global config).
from services.alert_engine import haversine
aid.lat_actual = entry["lat"]
aid.lon_actual = entry["lon"]
aid.desplazamiento_m = haversine(
entry["lat"], entry["lon"],
aid.lat_nominal, aid.lon_nominal,
)
aid.ultima_senal = datetime.utcnow()
# Drift evaluation — fires WARN/ALARM only on state changes
drift_alerts = evaluate_aid_movement(
aid.id, entry["lat"], entry["lon"],
aid.lat_nominal, aid.lon_nominal, config,
warn_m=aid.displacement_warn_m,
alarm_m=aid.displacement_alarm_m,
)
# en_posicion = within swing_radius of nominal
aid.en_posicion = (aid.desplazamiento_m
<= (aid.radio_borneo_m or 10.0))
db.commit()
await broadcast({
"type": "aid_position",
"id": aid.id,
"lat_actual": entry["lat"],
"lon_actual": entry["lon"],
"desplazamiento_m": round(aid.desplazamiento_m, 1),
"en_posicion": aid.en_posicion,
"en_movimiento": False,
}, owned_aid_id=aid.id)
for alert in drift_alerts:
await broadcast({"type": "alert", **alert},
owned_aid_id=alert.get("aid_id"))
# Battery threshold check — per-aid lamp values if assigned,
# otherwise the global defaults from settings_store.
@@ -321,9 +400,11 @@ async def process_message(msg: dict):
if aid and aid.lamp_id:
lamp = db.query(Lamp).filter(Lamp.id == aid.lamp_id).first()
if lamp:
rng = lamp.voltage_max - lamp.voltage_min
warn_v = lamp.voltage_min + rng * 0.20
alarm_v = lamp.voltage_min + rng * 0.10
rng = lamp.voltage_max - lamp.voltage_min
warn_pct = (lamp.warn_pct or 20.0) / 100.0
alarm_pct = (lamp.alarm_pct or 10.0) / 100.0
warn_v = lamp.voltage_min + rng * warn_pct
alarm_v = lamp.voltage_min + rng * alarm_pct
threshold_source = f"lamp:{lamp.manufacturer} {lamp.model}"
else:
warn_v = config["battery_warn_v"]
@@ -346,9 +427,54 @@ async def process_message(msg: dict):
"umbral": alarm_v if new_state == "ALARM" else warn_v,
"fuente_umbral": threshold_source,
"timestamp": datetime.utcnow().isoformat(),
})
}, owned_mmsi=mmsi) # only the company that owns this buoy
_battery_alert_state[mmsi] = new_state
# ── Digital input alerts (water ingress / listing) ────────────
if msg.get("msg_type") == 8:
aid = aid or db.query(Aid).filter(Aid.mmsi == mmsi).first()
if aid:
import uuid as _uuid2
_DIN_ALERT_MAP = {
"WATER_INGRESS_WARN": ("ALERTA_AMARILLA", "INGRESO_AGUA", "⚠ Water ingress detected"),
"WATER_INGRESS_CRITICAL": ("ALERTA_ROJA", "HUNDIMIENTO", "🔴 Critical water ingress — sinking risk"),
"LISTING": ("ALERTA_ROJA", "ESCORA_CRITICA", "🔴 Critical list detected"),
}
for din_field, fn_attr in [("din3", "din3_function"), ("din4", "din4_function")]:
fn = getattr(aid, fn_attr, None)
if not fn or fn not in _DIN_ALERT_MAP:
continue
triggered = entry.get(din_field, False)
# Also check IEC standard water level bit for CRITICAL
if fn == "WATER_INGRESS_CRITICAL":
triggered = triggered or entry.get("water_level_high", False)
state_key = f"{mmsi}_{din_field}"
prev_state = _digital_alert_state.get(state_key)
if triggered and not prev_state:
tipo, subtipo, mensaje = _DIN_ALERT_MAP[fn]
await broadcast({
"type": "alert",
"id": str(_uuid2.uuid4()),
"tipo": tipo,
"subtipo": subtipo,
"mmsi": mmsi,
"aid_id": aid.id,
"aid_nombre": aid.nombre,
"mensaje": mensaje,
"timestamp": datetime.utcnow().isoformat(),
}, owned_mmsi=mmsi)
elif not triggered and prev_state:
# Condition cleared
await broadcast({
"type": "alert",
"tipo": "CONDICION_RESUELTA",
"subtipo": _DIN_ALERT_MAP[fn][1],
"mmsi": mmsi,
"aid_id": aid.id,
"timestamp": datetime.utcnow().isoformat(),
}, owned_mmsi=mmsi)
_digital_alert_state[state_key] = triggered
# ── Auto-persist AtonTrack (DVR) ─────────────────────────────
at_lat = entry.get("lat")
at_lon = entry.get("lon")
@@ -374,7 +500,9 @@ async def process_message(msg: dict):
if aid:
alert_list = evaluate_aid_movement(
aid_id, msg["lat_actual"], msg["lon_actual"],
aid.lat_nominal, aid.lon_nominal, config
aid.lat_nominal, aid.lon_nominal, config,
warn_m=aid.displacement_warn_m, # per-aid override (None → global)
alarm_m=aid.displacement_alarm_m,
)
aid.lat_actual = msg["lat_actual"]
aid.lon_actual = msg["lon_actual"]
@@ -383,7 +511,9 @@ async def process_message(msg: dict):
db.commit()
await broadcast(msg) # aid positions = public, no filter
for alert in alert_list:
await broadcast({"type": "alert", **alert})
# Only the company that owns this aid receives movement/position alerts
await broadcast({"type": "alert", **alert},
owned_aid_id=alert.get("aid_id"))
finally:
db.close()
@@ -444,6 +574,62 @@ async def lifespan(app: FastAPI):
global _ais_task
_ais_task = await _start_ais_source()
# ── Pre-warm chart-cell coverage cache in background ───────────────────
# Reading 691 cell files is a ~2.5 s one-time cost. Doing it at startup
# means the FIRST user request lands on a hot cache. Runs off the main
# event loop so the server is responsive immediately.
async def _warm_chart_cache():
try:
from services import chart_manager as _cm
import os
t0 = asyncio.get_event_loop().time()
count = 0
for cell_dir in _cm.CHARTS_DIR.iterdir():
if not cell_dir.is_dir():
continue
for fname in ("depths.geojson", "land.geojson", "zones.geojson",
"hazards.geojson", "features.geojson"):
cache = cell_dir / fname
if not cache.exists():
continue
try:
mtime = cache.stat().st_mtime
except OSError:
continue
cov_key = f"{cache}|{mtime}"
if cov_key in _cm._cell_coverage_cache:
continue
try:
import json as _json
fc = _json.loads(cache.read_text())
except Exception:
continue
cov = _cm._coverage_bbox(fc.get("features") or [])
if cov is not None:
_cm._cell_coverage_cache[cov_key] = cov
count += 1
# Yield to event loop every 50 files so we don't block
if count % 50 == 0:
await asyncio.sleep(0)
dt = asyncio.get_event_loop().time() - t0
print(f"[charts] Pre-warm: {count} files indexed in {dt:.1f}s "
f"({len(_cm._cell_coverage_cache)} cached bboxes)")
except Exception as e:
print(f"[charts] Pre-warm failed (non-fatal): {e}")
asyncio.create_task(_warm_chart_cache())
# ── Slave relay (start when role == SLAVE) ─────────────────────────────
global _slave_relay
role = config.get("server_role", "STANDALONE").upper()
master_url = config.get("master_url", "").strip()
slave_name = config.get("slave_name", "").strip() or config.get("station_name", "Slave")
if role == "SLAVE" and master_url:
_slave_relay = SlaveRelay(master_url=master_url, slave_name=slave_name)
await _slave_relay.start()
print(f"[cluster] Rol: SLAVE → maestro {master_url} (nombre='{slave_name}')")
elif role == "MASTER":
print(f"[cluster] Rol: MASTER — esperando conexiones de esclavos en /ws/slave")
# GPS — settings_store now holds the port (mirrored from .env on first run)
gps_port = config.get("gps_port") or None
gps_baud = config.get("gps_baud") or None
@@ -451,12 +637,64 @@ async def lifespan(app: FastAPI):
await gps.start()
app.state.gps = gps
# ── Signal-loss monitor (runs every 60 s) ──────────────────────────────
async def _signal_loss_monitor():
import uuid as _uuid
while True:
await asyncio.sleep(60)
now = datetime.utcnow()
db2 = SessionLocal()
try:
for mmsi, last_seen in list(_aton_last_seen.items()):
aid = db2.query(Aid).filter(Aid.mmsi == mmsi).first()
if not aid or not aid.signal_loss_min:
continue
elapsed_min = (now - last_seen).total_seconds() / 60
if elapsed_min >= aid.signal_loss_min:
if not _signal_loss_state.get(mmsi):
_signal_loss_state[mmsi] = True
await broadcast({
"type": "alert",
"id": str(_uuid.uuid4()),
"tipo": "ALERTA_ROJA",
"subtipo": "PERDIDA_SENAL",
"mmsi": mmsi,
"aid_id": aid.id,
"aid_nombre": aid.nombre,
"minutos_sin_senal": round(elapsed_min),
"umbral_min": aid.signal_loss_min,
"timestamp": now.isoformat(),
}, owned_mmsi=mmsi)
else:
_signal_loss_state.pop(mmsi, None)
finally:
db2.close()
asyncio.create_task(_signal_loss_monitor())
yield
# ── Shutdown: stop slave relay if running ──────────────────────────────
if _slave_relay is not None:
await _slave_relay.stop()
app = FastAPI(title="AidsMonitoring", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)
# Compress GeoJSON / large JSON responses. minimum_size=1024 skips tiny ones
# (status pings, single-aid lookups) where the gzip overhead isn't worth it.
# Chart payloads (depths 350 MB, land 124 MB cross-cell total) compress 510×.
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.include_router(auth_router.router)
app.include_router(aids.router)
@@ -587,7 +825,8 @@ async def update_settings(payload: dict,
# Split: system keys go to settings_store; everything stays in user prefs
SYSTEM_KEYS = {"ais_source","ais_serial_port","ais_baud","ais_net_addr",
"gps_port","gps_baud","smtp_host","smtp_port","smtp_user",
"smtp_password","smtp_from","smtp_from_name","smtp_use_tls"}
"smtp_password","smtp_from","smtp_from_name","smtp_use_tls",
"server_role","master_url","slave_name"}
system_patch = {k: v for k, v in (payload or {}).items() if k in SYSTEM_KEYS}
prev = settings_store.get_all()
@@ -602,6 +841,22 @@ async def update_settings(payload: dict,
_ais_task = await _start_ais_source()
applied.append(f"ais_source → {new['ais_source']}")
# Cluster role / master URL change — restart slave relay if needed
if "server_role" in system_patch or "master_url" in system_patch or "slave_name" in system_patch:
global _slave_relay
if _slave_relay is not None:
await _slave_relay.stop()
_slave_relay = None
role = new.get("server_role", "STANDALONE").upper()
master_url = new.get("master_url", "").strip()
sname = new.get("slave_name", "").strip() or new.get("station_name", "Slave")
if role == "SLAVE" and master_url:
_slave_relay = SlaveRelay(master_url=master_url, slave_name=sname)
await _slave_relay.start()
applied.append(f"server_role → SLAVE, relay → {master_url}")
else:
applied.append(f"server_role → {role}")
# GPS port change
if "gps_port" in system_patch or "gps_baud" in system_patch:
gps: GPSReader = getattr(app.state, "gps", None)
@@ -717,6 +972,79 @@ async def ais_stop():
return _ais_catcher.stop()
@app.websocket("/ws/slave")
async def slave_websocket_endpoint(ws: WebSocket):
"""
MASTER-mode endpoint. Slave servers connect here to stream their events.
The master rebroadcasts each event to all locally connected clients (after
tagging it with _slave origin), and merges vessel/aton state in memory.
"""
if config.get("server_role", "STANDALONE").upper() not in ("MASTER", "STANDALONE"):
await ws.close(code=1008, reason="This server is not a MASTER")
return
await ws.accept()
slave_name = "unknown"
print(f"[cluster] Nuevo esclavo conectado desde {ws.client}")
try:
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except Exception:
continue
msg_type = msg.get("type")
# Hello handshake — log the slave name
if msg_type == "slave_hello":
slave_name = msg.get("slave_name", "unknown")
print(f"[cluster] Esclavo identificado: '{slave_name}'")
continue
# Merge vessel state into master's in-memory snapshot
if msg_type == "vessel":
mmsi = msg.get("mmsi")
if mmsi:
vessels_state[mmsi] = msg
elif msg_type == "aton":
mmsi = msg.get("mmsi")
if mmsi:
from services.aton_decoder import aton_state as _aton_state
_aton_state[mmsi] = msg
elif msg_type == "aid_position":
aid_id = msg.get("id")
if aid_id and aid_id in aids_state:
aids_state[aid_id].update(msg)
# Rebroadcast to all locally connected browser clients
# _slave tag identifies origin; admins see all; company clients
# still filtered by ownership as usual.
await broadcast(msg)
except WebSocketDisconnect:
print(f"[cluster] Esclavo '{slave_name}' desconectado.")
except Exception as e:
print(f"[cluster] Error con esclavo '{slave_name}': {e}")
@app.get("/cluster/status")
async def cluster_status(current_user=Depends(get_current_user)):
"""Return current cluster role and slave relay status."""
role = config.get("server_role", "STANDALONE").upper()
info: dict = {
"role": role,
"slave_name": config.get("slave_name", ""),
"master_url": config.get("master_url", ""),
}
if role == "SLAVE" and _slave_relay is not None:
info["relay"] = _slave_relay.status()
return info
@app.websocket("/ws")
async def websocket_endpoint(
ws: WebSocket,
@@ -742,7 +1070,12 @@ async def websocket_endpoint(
finally:
_db.close()
except Exception:
pass # invalid/expired token → treat as anonymous (admin-level view)
await ws.close(code=1008)
return
if token is None:
await ws.close(code=1008)
return
client = {"ws": ws, "company_id": company_id}
connected_clients.append(client)
@@ -776,8 +1109,11 @@ async def websocket_endpoint(
}))
# Re-emit any active aid alerts so new clients see current state
# Company users only receive alerts for aids they own
from datetime import datetime as _dt
for aid_id, state in aid_alert_state.items():
if owned_aid_ids is not None and aid_id not in owned_aid_ids:
continue # this aid doesn't belong to the connecting client's company
if state == 'RED':
await ws.send_text(json.dumps({"type": "alert", "tipo": "ALERTA_ROJA",
"subtipo": "AYUDA_EN_MOVIMIENTO", "aid_id": aid_id,