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:
+349
-13
@@ -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 5–10×.
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user