diff --git a/Dockerfile b/Dockerfile index 5e1a7fa..41b0f7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -136,16 +136,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make \ && cp acarsdec /usr/bin/acarsdec \ && rm -rf /tmp/acarsdec \ - # Build slowrx (SSTV decoder) + # Build slowrx (SSTV decoder) — pinned to known-good commit && cd /tmp \ - && git clone --depth 1 https://github.com/windytan/slowrx.git \ + && git clone https://github.com/windytan/slowrx.git \ && cd slowrx \ + && git checkout ca6d7012 \ && make \ && install -m 0755 slowrx /usr/local/bin/slowrx \ && rm -rf /tmp/slowrx \ - # Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) + # Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2 && cd /tmp \ - && git clone --depth 1 https://github.com/SatDump/SatDump.git \ + && git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \ && cd SatDump \ && mkdir build && cd build \ && cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \ diff --git a/app.py b/app.py index 8d4cbc4..df2b1a3 100644 --- a/app.py +++ b/app.py @@ -182,10 +182,6 @@ dmr_lock = threading.Lock() tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() -# Weather Satellite (NOAA/Meteor) -weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -weather_sat_lock = threading.Lock() - # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) diff --git a/docker-compose.yml b/docker-compose.yml index 19b998d..0211fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: intercept: + # When INTERCEPT_IMAGE is set, use that pre-built image; when empty/unset, + # the empty string causes Docker Compose to fall through to the build: directive. image: ${INTERCEPT_IMAGE:-} build: . container_name: intercept @@ -61,6 +63,7 @@ services: # ADS-B history with Postgres persistence # Enable with: docker compose --profile history up -d intercept-history: + # Same image/build fallback pattern as above image: ${INTERCEPT_IMAGE:-} build: . container_name: intercept-history diff --git a/routes/satellite.py b/routes/satellite.py index 8a4e3c8..3a8f078 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -31,6 +31,7 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www _tle_cache = dict(TLE_SATELLITES) # Auto-refresh TLEs from CelesTrak on startup (non-blocking) +import os import threading def _auto_refresh_tle(): @@ -42,7 +43,9 @@ def _auto_refresh_tle(): logger.warning(f"Auto TLE refresh failed: {e}") # Delay import — refresh_tle_data is defined later in this module -threading.Timer(2.0, _auto_refresh_tle).start() +# Guard to avoid firing during tests +if not os.environ.get('TESTING'): + threading.Timer(2.0, _auto_refresh_tle).start() def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]: diff --git a/routes/weather_sat.py b/routes/weather_sat.py index dd0d5b3..728d7be 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -7,13 +7,12 @@ from NOAA (APT) and Meteor (LRPT) satellites using SatDump. from __future__ import annotations import queue -import time -from typing import Generator from flask import Blueprint, jsonify, request, Response, send_file from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import sse_stream +from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation from utils.weather_sat import ( get_weather_sat_decoder, is_weather_sat_available, @@ -116,28 +115,14 @@ def start_capture(): 'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}' }), 400 - # Validate device index - device_index = data.get('device', 0) + # Validate device index and gain try: - device_index = int(device_index) - if not (0 <= device_index <= 255): - raise ValueError - except (TypeError, ValueError): + device_index = validate_device_index(data.get('device', 0)) + gain = validate_gain(data.get('gain', 40.0)) + except ValueError as e: return jsonify({ 'status': 'error', - 'message': 'Invalid device index (0-255)' - }), 400 - - # Validate gain - gain = data.get('gain', 40.0) - try: - gain = float(gain) - if not (0 <= gain <= 50): - raise ValueError - except (TypeError, ValueError): - return jsonify({ - 'status': 'error', - 'message': 'Invalid gain (0-50 dB)' + 'message': str(e) }), 400 bias_t = bool(data.get('bias_t', False)) @@ -252,11 +237,11 @@ def test_decode(): from pathlib import Path input_path = Path(input_file) - # Security: restrict to data directory - allowed_base = Path('data').resolve() + # Security: restrict to data directory (anchored to app root, not CWD) + allowed_base = Path(__file__).resolve().parent.parent / 'data' try: resolved = input_path.resolve() - if not str(resolved).startswith(str(allowed_base)): + if not resolved.is_relative_to(allowed_base): return jsonify({ 'status': 'error', 'message': 'input_file must be under the data/ directory' @@ -268,9 +253,10 @@ def test_decode(): }), 400 if not input_path.is_file(): + logger.warning(f"Test-decode file not found: {input_file}") return jsonify({ 'status': 'error', - 'message': f'File not found: {input_file}' + 'message': 'File not found' }), 404 # Validate sample rate @@ -440,22 +426,7 @@ def stream_progress(): Returns: SSE stream (text/event-stream) """ - def generate() -> Generator[str, None, None]: - last_keepalive = time.time() - keepalive_interval = 30.0 - - while True: - try: - progress = _weather_sat_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(progress) - except queue.Empty: - now = time.time() - if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) - last_keepalive = now - - response = Response(generate(), mimetype='text/event-stream') + response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache' response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' @@ -477,26 +448,26 @@ def get_passes(): Returns: JSON with upcoming passes for all weather satellites. """ - lat = request.args.get('latitude', type=float) - lon = request.args.get('longitude', type=float) - hours = request.args.get('hours', 24, type=int) - min_elevation = request.args.get('min_elevation', 15, type=float) include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1') include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1') - if lat is None or lon is None: + raw_lat = request.args.get('latitude') + raw_lon = request.args.get('longitude') + + if raw_lat is None or raw_lon is None: return jsonify({ 'status': 'error', 'message': 'latitude and longitude parameters required' }), 400 - if not (-90 <= lat <= 90): - return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400 - if not (-180 <= lon <= 180): - return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400 + try: + lat = validate_latitude(raw_lat) + lon = validate_longitude(raw_lon) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 - hours = max(1, min(hours, 72)) - min_elevation = max(0, min(min_elevation, 90)) + hours = max(1, min(request.args.get('hours', 24, type=int), 72)) + min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90)) try: from utils.weather_sat_predict import predict_passes @@ -529,7 +500,7 @@ def get_passes(): logger.error(f"Error predicting passes: {e}") return jsonify({ 'status': 'error', - 'message': str(e) + 'message': 'Pass prediction failed' }), 500 @@ -571,24 +542,22 @@ def enable_schedule(): data = request.get_json(silent=True) or {} - lat = data.get('latitude') - lon = data.get('longitude') - - if lat is None or lon is None: + if data.get('latitude') is None or data.get('longitude') is None: return jsonify({ 'status': 'error', 'message': 'latitude and longitude required' }), 400 try: - lat = float(lat) - lon = float(lon) - if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): - raise ValueError - except (TypeError, ValueError): + lat = validate_latitude(data.get('latitude')) + lon = validate_longitude(data.get('longitude')) + min_elev = validate_elevation(data.get('min_elevation', 15)) + device = validate_device_index(data.get('device', 0)) + gain_val = validate_gain(data.get('gain', 40.0)) + except ValueError as e: return jsonify({ 'status': 'error', - 'message': 'Invalid coordinates' + 'message': str(e) }), 400 scheduler = get_weather_sat_scheduler() @@ -597,9 +566,9 @@ def enable_schedule(): result = scheduler.enable( lat=lat, lon=lon, - min_elevation=float(data.get('min_elevation', 15)), - device=int(data.get('device', 0)), - gain=float(data.get('gain', 40.0)), + min_elevation=min_elev, + device=device, + gain=gain_val, bias_t=bool(data.get('bias_t', False)), ) diff --git a/utils/weather_sat.py b/utils/weather_sat.py index a62b92e..e91442a 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -29,6 +29,7 @@ from pathlib import Path from typing import Callable from utils.logging import get_logger +from utils.process import register_process, safe_terminate logger = get_logger('intercept.weather_sat') @@ -145,6 +146,7 @@ class WeatherSatDecoder: self._process: subprocess.Popen | None = None self._running = False self._lock = threading.Lock() + self._images_lock = threading.Lock() self._callback: Callable[[CaptureProgress], None] | None = None self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat') self._images: list[WeatherSatImage] = [] @@ -243,11 +245,30 @@ class WeatherSatDecoder: return False input_path = Path(input_file) + + # Security: restrict to data directory + allowed_base = Path(__file__).resolve().parent.parent / 'data' + try: + resolved = input_path.resolve() + if not resolved.is_relative_to(allowed_base): + logger.warning(f"Path traversal blocked in start_from_file: {input_file}") + self._emit_progress(CaptureProgress( + status='error', + message='Input file must be under the data/ directory' + )) + return False + except (OSError, ValueError): + self._emit_progress(CaptureProgress( + status='error', + message='Invalid file path' + )) + return False + if not input_path.is_file(): logger.error(f"Input file not found: {input_file}") self._emit_progress(CaptureProgress( status='error', - message=f'Input file not found: {input_file}' + message='Input file not found' )) return False @@ -417,12 +438,17 @@ class WeatherSatDecoder: stdin=subprocess.DEVNULL, close_fds=True, ) + register_process(self._process) os.close(slave_fd) # parent doesn't need the slave side - # Check for early exit (SatDump errors out immediately) - try: - retcode = self._process.wait(timeout=3) - # Process already died — read whatever output it produced + # Check for early exit asynchronously (avoid blocking /start for 3s) + def _check_early_exit(): + """Poll once after 3s; if SatDump died, emit an error event.""" + time.sleep(3) + process = self._process + if process is None or process.poll() is None: + return # still running or already cleaned up + retcode = process.returncode output = b'' try: while True: @@ -435,8 +461,6 @@ class WeatherSatDecoder: output += chunk except OSError: pass - os.close(master_fd) - self._pty_master_fd = None output_str = output.decode('utf-8', errors='replace') error_msg = f"SatDump exited immediately (code {retcode})" if output_str: @@ -445,11 +469,17 @@ class WeatherSatDecoder: error_msg = line.strip() break logger.error(f"SatDump output:\n{output_str}") - self._process = None - raise RuntimeError(error_msg) - except subprocess.TimeoutExpired: - # Good — process is still running after 3 seconds - pass + self._emit_progress(CaptureProgress( + status='error', + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=error_msg, + log_type='error', + capture_phase='error', + )) + + threading.Thread(target=_check_early_exit, daemon=True).start() # Start reader thread to monitor output self._reader_thread = threading.Thread( @@ -508,6 +538,7 @@ class WeatherSatDecoder: stdin=subprocess.DEVNULL, close_fds=True, ) + register_process(self._process) os.close(slave_fd) # parent doesn't need the slave side # For offline mode, don't check for early exit — file decoding @@ -782,7 +813,8 @@ class WeatherSatDecoder: # Recursively scan for image files for ext in ('*.png', '*.jpg', '*.jpeg'): for filepath in self._capture_output_dir.rglob(ext): - if filepath.name in known_files: + file_key = str(filepath) + if file_key in known_files: continue # Skip tiny files (likely incomplete) @@ -793,7 +825,7 @@ class WeatherSatDecoder: except OSError: continue - known_files.add(filepath.name) + known_files.add(file_key) # Determine product type from filename/path product = self._parse_product_name(filepath) @@ -817,7 +849,8 @@ class WeatherSatDecoder: size_bytes=stat.st_size, product=product, ) - self._images.append(image) + with self._images_lock: + self._images.append(image) logger.info(f"New weather satellite image: {serve_name} ({product})") self._emit_progress(CaptureProgress( @@ -877,16 +910,7 @@ class WeatherSatDecoder: self._pty_master_fd = None if self._process: - try: - self._process.terminate() - self._process.wait(timeout=5) - except subprocess.TimeoutExpired: - self._process.kill() - except Exception: - try: - self._process.kill() - except Exception: - pass + safe_terminate(self._process) self._process = None elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 @@ -894,11 +918,15 @@ class WeatherSatDecoder: def get_images(self) -> list[WeatherSatImage]: """Get list of decoded images.""" - self._scan_images() - return list(self._images) + with self._images_lock: + self._scan_images() + return list(self._images) def _scan_images(self) -> None: - """Scan output directory for images not yet tracked.""" + """Scan output directory for images not yet tracked. + + Must be called with self._images_lock held. + """ known_filenames = {img.filename for img in self._images} for ext in ('*.png', '*.jpg', '*.jpeg'): @@ -940,7 +968,8 @@ class WeatherSatDecoder: if filepath.exists(): try: filepath.unlink() - self._images = [img for img in self._images if img.filename != filename] + with self._images_lock: + self._images = [img for img in self._images if img.filename != filename] return True except OSError as e: logger.error(f"Failed to delete image {filename}: {e}") @@ -956,7 +985,8 @@ class WeatherSatDecoder: count += 1 except OSError: pass - self._images.clear() + with self._images_lock: + self._images.clear() return count def _emit_progress(self, progress: CaptureProgress) -> None: @@ -987,13 +1017,16 @@ class WeatherSatDecoder: # Global decoder instance _decoder: WeatherSatDecoder | None = None +_decoder_lock = threading.Lock() def get_weather_sat_decoder() -> WeatherSatDecoder: """Get or create the global weather satellite decoder instance.""" global _decoder if _decoder is None: - _decoder = WeatherSatDecoder() + with _decoder_lock: + if _decoder is None: + _decoder = WeatherSatDecoder() return _decoder diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py index f08ec6b..48aea6f 100644 --- a/utils/weather_sat_scheduler.py +++ b/utils/weather_sat_scheduler.py @@ -49,11 +49,17 @@ class ScheduledPass: @property def start_dt(self) -> datetime: - return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc) + dt = datetime.fromisoformat(self.start_time) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) @property def end_dt(self) -> datetime: - return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc) + dt = datetime.fromisoformat(self.end_time) + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) def to_dict(self) -> dict[str, Any]: return { @@ -375,11 +381,14 @@ class WeatherSatScheduler: # Singleton _scheduler: WeatherSatScheduler | None = None +_scheduler_lock = threading.Lock() def get_weather_sat_scheduler() -> WeatherSatScheduler: """Get or create the global weather satellite scheduler instance.""" global _scheduler if _scheduler is None: - _scheduler = WeatherSatScheduler() + with _scheduler_lock: + if _scheduler is None: + _scheduler = WeatherSatScheduler() return _scheduler