42a50037ab
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.
New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
879 lines
29 KiB
Python
Executable File
879 lines
29 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Pixel ADS-B Server - Connects directly to ADS-B receivers"""
|
|
import asyncio
|
|
import socket
|
|
import json
|
|
import time
|
|
import os
|
|
import copy
|
|
import secrets
|
|
import ipaddress
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import datetime
|
|
from typing import Dict, Set, List
|
|
from aiohttp import web
|
|
import netifaces
|
|
import bcrypt
|
|
|
|
WEB_DIR = Path(__file__).parent
|
|
CONFIG_FILE = WEB_DIR / "config.json"
|
|
|
|
# Track server start time
|
|
SERVER_START_TIME = time.time()
|
|
|
|
# Default configuration - all possible settings with sane defaults
|
|
DEFAULT_CONFIG = {
|
|
"setup_complete": False,
|
|
"admin": {
|
|
"password_hash": "",
|
|
"session_secret": ""
|
|
},
|
|
"receivers": "AUTO",
|
|
"receiver_port": 30003,
|
|
"location": {
|
|
"name": "My Location",
|
|
"lat": 0.0,
|
|
"lon": 0.0
|
|
},
|
|
"web_port": 2001,
|
|
"theme": "desert",
|
|
"site": {
|
|
"title": "ADS-Bit",
|
|
"subtitle": "Retro Flight Tracker"
|
|
},
|
|
"display": {
|
|
"temperature_unit": "F",
|
|
"show_weather": True,
|
|
"show_sidebar": True,
|
|
"default_view_direction": 0
|
|
},
|
|
"tuning": {
|
|
"flight_timeout_seconds": 60,
|
|
"broadcast_interval_seconds": 1,
|
|
"cleanup_interval_seconds": 10,
|
|
"receiver_reconnect_seconds": 5
|
|
}
|
|
}
|
|
|
|
|
|
def deep_merge(base: dict, override: dict) -> dict:
|
|
"""Deep merge override into base, returning new dict. Base values used as defaults."""
|
|
result = copy.deepcopy(base)
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = deep_merge(result[key], value)
|
|
else:
|
|
result[key] = copy.deepcopy(value)
|
|
return result
|
|
|
|
|
|
# Active configuration
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
|
|
def load_config():
|
|
"""Load configuration from config.json, deep-merged over defaults."""
|
|
global config
|
|
if CONFIG_FILE.exists():
|
|
try:
|
|
with open(CONFIG_FILE, 'r') as f:
|
|
loaded = json.load(f)
|
|
config = deep_merge(DEFAULT_CONFIG, loaded)
|
|
print(f"Loaded config from {CONFIG_FILE}")
|
|
except Exception as e:
|
|
print(f"Error loading config: {e}, using defaults")
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
else:
|
|
print(f"No config file found at {CONFIG_FILE}, using defaults")
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
print(f" Setup complete: {config['setup_complete']}")
|
|
print(f" Receivers: {config['receivers']}")
|
|
print(f" Receiver port: {config['receiver_port']}")
|
|
print(f" Location: {config['location']['name']} ({config['location']['lat']}, {config['location']['lon']})")
|
|
print(f" Web port: {config['web_port']}")
|
|
print(f" Theme: {config.get('theme', 'desert')}")
|
|
|
|
|
|
def save_config():
|
|
"""Persist current config to config.json."""
|
|
try:
|
|
with open(CONFIG_FILE, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
print(f"Config saved to {CONFIG_FILE}")
|
|
except Exception as e:
|
|
print(f"Error saving config: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session management (in-memory, lost on restart)
|
|
# ---------------------------------------------------------------------------
|
|
sessions: Dict[str, float] = {} # token -> expiry timestamp
|
|
SESSION_DURATION = 86400 # 24 hours
|
|
|
|
|
|
def create_session() -> str:
|
|
token = secrets.token_hex(32)
|
|
sessions[token] = time.time() + SESSION_DURATION
|
|
return token
|
|
|
|
|
|
def validate_session(token: str) -> bool:
|
|
if not token:
|
|
return False
|
|
expiry = sessions.get(token)
|
|
if expiry is None:
|
|
return False
|
|
if time.time() > expiry:
|
|
sessions.pop(token, None)
|
|
return False
|
|
return True
|
|
|
|
|
|
def destroy_session(token: str):
|
|
sessions.pop(token, None)
|
|
|
|
|
|
def get_session_token(request) -> str:
|
|
"""Extract session token from cookie."""
|
|
return request.cookies.get('adsbit_session', '')
|
|
|
|
|
|
def require_auth(handler):
|
|
"""Decorator that checks for valid admin session."""
|
|
async def wrapper(request):
|
|
token = get_session_token(request)
|
|
if not validate_session(token):
|
|
return web.json_response({"error": "Unauthorized"}, status=401)
|
|
return await handler(request)
|
|
return wrapper
|
|
|
|
|
|
def require_setup_incomplete(handler):
|
|
"""Decorator that only allows access when setup is not complete."""
|
|
async def wrapper(request):
|
|
if config.get("setup_complete", False):
|
|
return web.json_response({"error": "Setup already complete"}, status=403)
|
|
return await handler(request)
|
|
return wrapper
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flight data storage
|
|
# ---------------------------------------------------------------------------
|
|
flights: Dict[str, dict] = {}
|
|
connected_clients: Set = set()
|
|
receivers: List[str] = []
|
|
receiver_tasks: List[asyncio.Task] = []
|
|
|
|
|
|
@dataclass
|
|
class SBSMessage:
|
|
msg_type: str
|
|
icao: str
|
|
callsign: str = ""
|
|
altitude: int = 0
|
|
speed: float = 0
|
|
track: float = 0
|
|
lat: float = 0
|
|
lon: float = 0
|
|
vrate: int = 0
|
|
squawk: str = ""
|
|
ground: bool = False
|
|
|
|
|
|
def parse_sbs_message(line: str):
|
|
"""Parse SBS/BaseStation format message"""
|
|
parts = line.strip().split(',')
|
|
if len(parts) < 10:
|
|
return None
|
|
|
|
msg_type = parts[0]
|
|
if msg_type not in ['MSG']:
|
|
return None
|
|
|
|
icao = parts[4].strip()
|
|
if not icao:
|
|
return None
|
|
|
|
msg = SBSMessage(msg_type=msg_type, icao=icao)
|
|
|
|
if len(parts) > 10 and parts[10].strip():
|
|
msg.callsign = parts[10].strip()
|
|
if len(parts) > 11 and parts[11].strip():
|
|
try:
|
|
msg.altitude = int(parts[11])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 12 and parts[12].strip():
|
|
try:
|
|
msg.speed = float(parts[12])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 13 and parts[13].strip():
|
|
try:
|
|
msg.track = float(parts[13])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 14 and parts[14].strip():
|
|
try:
|
|
msg.lat = float(parts[14])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 15 and parts[15].strip():
|
|
try:
|
|
msg.lon = float(parts[15])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 16 and parts[16].strip():
|
|
try:
|
|
msg.vrate = int(parts[16])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 17 and parts[17].strip():
|
|
msg.squawk = parts[17].strip()
|
|
if len(parts) > 21 and parts[21].strip() == '-1':
|
|
msg.ground = True
|
|
|
|
return msg
|
|
|
|
|
|
async def connect_to_receiver(host: str, port: int = 30003):
|
|
"""Connect to an SBS receiver and process messages"""
|
|
reconnect_delay = config['tuning']['receiver_reconnect_seconds']
|
|
print(f"Connecting to receiver at {host}:{port}...")
|
|
while True:
|
|
try:
|
|
reader, writer = await asyncio.open_connection(host, port)
|
|
print(f"Connected to {host}:{port}")
|
|
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
break
|
|
|
|
line = line.decode('utf-8', errors='ignore')
|
|
msg = parse_sbs_message(line)
|
|
if msg and msg.icao:
|
|
if msg.icao not in flights:
|
|
flights[msg.icao] = {
|
|
'icao': msg.icao,
|
|
'callsign': '',
|
|
'altitude': 0,
|
|
'speed': 0,
|
|
'track': 0,
|
|
'lat': 0,
|
|
'lon': 0,
|
|
'vrate': 0,
|
|
'squawk': '',
|
|
'ground': False,
|
|
'last_seen': time.time()
|
|
}
|
|
|
|
flight = flights[msg.icao]
|
|
if msg.callsign:
|
|
flight['callsign'] = msg.callsign
|
|
if msg.altitude:
|
|
flight['altitude'] = msg.altitude
|
|
if msg.speed:
|
|
flight['speed'] = msg.speed
|
|
if msg.track:
|
|
flight['track'] = msg.track
|
|
if msg.lat:
|
|
flight['lat'] = msg.lat
|
|
if msg.lon:
|
|
flight['lon'] = msg.lon
|
|
if msg.vrate:
|
|
flight['vrate'] = msg.vrate
|
|
if msg.squawk:
|
|
flight['squawk'] = msg.squawk
|
|
flight['ground'] = msg.ground
|
|
flight['last_seen'] = time.time()
|
|
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception as e:
|
|
print(f"Receiver {host}:{port} error: {e}")
|
|
await asyncio.sleep(reconnect_delay)
|
|
|
|
|
|
async def scan_for_receivers():
|
|
"""Scan local network for ADS-B receivers on configured port"""
|
|
port = config['receiver_port']
|
|
print(f"Scanning for ADS-B receivers on port {port}...")
|
|
found = []
|
|
|
|
for iface in netifaces.interfaces():
|
|
addrs = netifaces.ifaddresses(iface)
|
|
if netifaces.AF_INET in addrs:
|
|
for addr in addrs[netifaces.AF_INET]:
|
|
ip = addr.get('addr')
|
|
netmask = addr.get('netmask')
|
|
if ip and netmask and not ip.startswith('127.'):
|
|
try:
|
|
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
|
|
print(f"Scanning {network} ({network.num_addresses} hosts)...")
|
|
|
|
host_ips = [str(host) for host in network.hosts()]
|
|
|
|
async def check_ip(test_ip):
|
|
if await test_port(test_ip, port):
|
|
return test_ip
|
|
return None
|
|
|
|
results = await asyncio.gather(*[check_ip(h) for h in host_ips])
|
|
for result in results:
|
|
if result:
|
|
print(f"Found receiver at {result}:{port}")
|
|
found.append(result)
|
|
except ValueError as e:
|
|
print(f"Invalid network {ip}/{netmask}: {e}")
|
|
|
|
return found
|
|
|
|
|
|
async def test_port(ip: str, port: int, timeout: float = 0.5):
|
|
"""Test if a port is open"""
|
|
try:
|
|
conn = asyncio.open_connection(ip, port)
|
|
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
|
|
async def cleanup_old_flights():
|
|
"""Remove flights not seen recently (uses tuning config)."""
|
|
while True:
|
|
interval = config['tuning']['cleanup_interval_seconds']
|
|
timeout = config['tuning']['flight_timeout_seconds']
|
|
await asyncio.sleep(interval)
|
|
now = time.time()
|
|
to_remove = [icao for icao, flight in flights.items()
|
|
if now - flight['last_seen'] > timeout]
|
|
for icao in to_remove:
|
|
del flights[icao]
|
|
|
|
|
|
async def broadcast_flights():
|
|
"""Broadcast flight data to all connected WebSocket clients."""
|
|
while True:
|
|
interval = config['tuning']['broadcast_interval_seconds']
|
|
await asyncio.sleep(interval)
|
|
if connected_clients:
|
|
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
|
|
message = json.dumps({
|
|
'type': 'flights',
|
|
'flights': flight_list,
|
|
'count': len(flight_list)
|
|
})
|
|
|
|
dead_clients = set()
|
|
for client in connected_clients:
|
|
try:
|
|
await client.send_str(message)
|
|
except:
|
|
dead_clients.add(client)
|
|
|
|
connected_clients.difference_update(dead_clients)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket handler
|
|
# ---------------------------------------------------------------------------
|
|
async def websocket_handler(request):
|
|
"""Handle WebSocket connections from browser"""
|
|
ws = web.WebSocketResponse()
|
|
await ws.prepare(request)
|
|
|
|
connected_clients.add(ws)
|
|
print(f"WebSocket client connected ({len(connected_clients)} total)")
|
|
|
|
try:
|
|
async for msg in ws:
|
|
pass
|
|
finally:
|
|
connected_clients.discard(ws)
|
|
print(f"WebSocket client disconnected ({len(connected_clients)} remaining)")
|
|
|
|
return ws
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API routes (no auth)
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_receiver_location(request):
|
|
"""Return receiver location from config."""
|
|
return web.json_response({
|
|
"lat": config["location"]["lat"],
|
|
"lon": config["location"]["lon"],
|
|
"name": config["location"]["name"]
|
|
})
|
|
|
|
|
|
async def handle_config(request):
|
|
"""Return client-relevant configuration (public, no secrets)."""
|
|
return web.json_response({
|
|
"theme": config.get("theme", "desert"),
|
|
"location": config["location"],
|
|
"receivers": receivers,
|
|
"site": config.get("site", DEFAULT_CONFIG["site"]),
|
|
"display": config.get("display", DEFAULT_CONFIG["display"])
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth routes
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_auth_login(request):
|
|
"""POST /api/auth/login - Authenticate with admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
password = body.get("password", "")
|
|
stored_hash = config.get("admin", {}).get("password_hash", "")
|
|
|
|
if not stored_hash:
|
|
return web.json_response({"error": "No admin password set"}, status=403)
|
|
|
|
try:
|
|
if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
|
|
token = create_session()
|
|
resp = web.json_response({"ok": True})
|
|
resp.set_cookie('adsbit_session', token,
|
|
max_age=SESSION_DURATION,
|
|
httponly=True,
|
|
samesite='Lax',
|
|
path='/')
|
|
return resp
|
|
except Exception:
|
|
pass
|
|
|
|
return web.json_response({"error": "Invalid password"}, status=401)
|
|
|
|
|
|
async def handle_auth_logout(request):
|
|
"""POST /api/auth/logout - Destroy session."""
|
|
token = get_session_token(request)
|
|
destroy_session(token)
|
|
resp = web.json_response({"ok": True})
|
|
resp.del_cookie('adsbit_session', path='/')
|
|
return resp
|
|
|
|
|
|
async def handle_auth_check(request):
|
|
"""GET /api/auth/check - Check if current session is valid."""
|
|
token = get_session_token(request)
|
|
if validate_session(token):
|
|
return web.json_response({"authenticated": True})
|
|
return web.json_response({"authenticated": False}, status=401)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup routes (only when setup_complete is false)
|
|
# ---------------------------------------------------------------------------
|
|
@require_setup_incomplete
|
|
async def handle_setup_password(request):
|
|
"""POST /api/setup/password - Set initial admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
password = body.get("password", "")
|
|
if len(password) < 4:
|
|
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
|
|
|
|
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
config['admin']['password_hash'] = hashed
|
|
config['admin']['session_secret'] = secrets.token_hex(16)
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_location(request):
|
|
"""POST /api/setup/location - Set location during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
config['location']['name'] = body.get("name", config['location']['name'])
|
|
config['location']['lat'] = float(body.get("lat", config['location']['lat']))
|
|
config['location']['lon'] = float(body.get("lon", config['location']['lon']))
|
|
save_config()
|
|
return web.json_response({"ok": True, "location": config['location']})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_receivers(request):
|
|
"""POST /api/setup/receivers - Configure receivers during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
mode = body.get("mode", "AUTO")
|
|
if mode == "AUTO":
|
|
config['receivers'] = "AUTO"
|
|
else:
|
|
ips = body.get("ips", [])
|
|
if isinstance(ips, list) and ips:
|
|
config['receivers'] = ips
|
|
else:
|
|
config['receivers'] = "AUTO"
|
|
|
|
if "port" in body:
|
|
config['receiver_port'] = int(body['port'])
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_scan(request):
|
|
"""GET /api/setup/scan - Scan for receivers during setup."""
|
|
found = await scan_for_receivers()
|
|
return web.json_response({"receivers": found})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_theme(request):
|
|
"""POST /api/setup/theme - Set theme and preferences during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
if "theme" in body:
|
|
config['theme'] = body['theme']
|
|
if "temperature_unit" in body:
|
|
config['display']['temperature_unit'] = body['temperature_unit']
|
|
if "site_title" in body:
|
|
config['site']['title'] = body['site_title']
|
|
if "site_subtitle" in body:
|
|
config['site']['subtitle'] = body['site_subtitle']
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_complete(request):
|
|
"""POST /api/setup/complete - Mark setup as done."""
|
|
# Verify a password was set
|
|
if not config.get('admin', {}).get('password_hash', ''):
|
|
return web.json_response({"error": "Admin password must be set first"}, status=400)
|
|
|
|
config['setup_complete'] = True
|
|
save_config()
|
|
|
|
# Restart receiver connections with new config
|
|
await restart_receiver_connections()
|
|
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admin API routes (require auth)
|
|
# ---------------------------------------------------------------------------
|
|
@require_auth
|
|
async def handle_admin_config(request):
|
|
"""GET /api/admin/config - Return full config (minus password hash)."""
|
|
safe_config = copy.deepcopy(config)
|
|
safe_config['admin'] = {"password_set": bool(config['admin']['password_hash'])}
|
|
return web.json_response(safe_config)
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_config_update(request):
|
|
"""PUT /api/admin/config - Update config sections."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
# Updatable sections
|
|
if "location" in body:
|
|
loc = body["location"]
|
|
if "name" in loc:
|
|
config['location']['name'] = loc['name']
|
|
if "lat" in loc:
|
|
config['location']['lat'] = float(loc['lat'])
|
|
if "lon" in loc:
|
|
config['location']['lon'] = float(loc['lon'])
|
|
|
|
if "receivers" in body:
|
|
config['receivers'] = body['receivers']
|
|
|
|
if "receiver_port" in body:
|
|
config['receiver_port'] = int(body['receiver_port'])
|
|
|
|
if "theme" in body:
|
|
config['theme'] = body['theme']
|
|
|
|
if "site" in body:
|
|
for k in ('title', 'subtitle'):
|
|
if k in body['site']:
|
|
config['site'][k] = body['site'][k]
|
|
|
|
if "display" in body:
|
|
for k in ('temperature_unit', 'show_weather', 'show_sidebar', 'default_view_direction'):
|
|
if k in body['display']:
|
|
config['display'][k] = body['display'][k]
|
|
|
|
if "tuning" in body:
|
|
for k in ('flight_timeout_seconds', 'broadcast_interval_seconds',
|
|
'cleanup_interval_seconds', 'receiver_reconnect_seconds'):
|
|
if k in body['tuning']:
|
|
config['tuning'][k] = int(body['tuning'][k])
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_status(request):
|
|
"""GET /api/admin/status - Live dashboard data."""
|
|
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
|
|
uptime = int(time.time() - SERVER_START_TIME)
|
|
hours, remainder = divmod(uptime, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
|
|
return web.json_response({
|
|
"active_flights": len(flight_list),
|
|
"total_tracked": len(flights),
|
|
"connected_viewers": len(connected_clients),
|
|
"receivers": receivers,
|
|
"receiver_count": len(receivers),
|
|
"uptime": f"{hours}h {minutes}m {seconds}s",
|
|
"uptime_seconds": uptime
|
|
})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_themes(request):
|
|
"""GET /api/admin/themes - List available themes."""
|
|
bg_dir = WEB_DIR / "backgrounds"
|
|
themes = []
|
|
if bg_dir.exists():
|
|
for d in sorted(bg_dir.iterdir()):
|
|
if d.is_dir() and (d / "north.png").exists():
|
|
themes.append(d.name)
|
|
return web.json_response({"themes": themes})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_password(request):
|
|
"""POST /api/admin/password - Change admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
current = body.get("current_password", "")
|
|
new_pw = body.get("new_password", "")
|
|
|
|
if len(new_pw) < 4:
|
|
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
|
|
|
|
stored_hash = config.get("admin", {}).get("password_hash", "")
|
|
if stored_hash:
|
|
try:
|
|
if not bcrypt.checkpw(current.encode('utf-8'), stored_hash.encode('utf-8')):
|
|
return web.json_response({"error": "Current password is incorrect"}, status=401)
|
|
except Exception:
|
|
return web.json_response({"error": "Current password is incorrect"}, status=401)
|
|
|
|
hashed = bcrypt.hashpw(new_pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
config['admin']['password_hash'] = hashed
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_scan_receivers(request):
|
|
"""POST /api/admin/scan-receivers - Trigger a receiver scan."""
|
|
found = await scan_for_receivers()
|
|
return web.json_response({"receivers": found})
|
|
|
|
|
|
async def restart_receiver_connections():
|
|
"""Cancel existing receiver tasks and start new ones."""
|
|
global receivers, receiver_tasks
|
|
|
|
# Cancel old tasks
|
|
for task in receiver_tasks:
|
|
task.cancel()
|
|
receiver_tasks.clear()
|
|
|
|
# Determine receivers
|
|
receiver_config = config["receivers"]
|
|
receiver_port = config["receiver_port"]
|
|
|
|
try:
|
|
if receiver_config == "AUTO":
|
|
found = await scan_for_receivers()
|
|
receivers = found
|
|
elif isinstance(receiver_config, list):
|
|
receivers = list(receiver_config)
|
|
elif isinstance(receiver_config, str) and receiver_config != "AUTO":
|
|
receivers = [receiver_config]
|
|
else:
|
|
receivers = []
|
|
except Exception as e:
|
|
print(f"Error determining receivers: {e}")
|
|
receivers = []
|
|
|
|
# Start new tasks
|
|
for r in receivers:
|
|
task = asyncio.create_task(connect_to_receiver(r, receiver_port))
|
|
receiver_tasks.append(task)
|
|
|
|
print(f"Receiver connections restarted: {len(receivers)} receiver(s)")
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_restart_receivers(request):
|
|
"""POST /api/admin/restart-receivers - Restart all receiver connections."""
|
|
await restart_receiver_connections()
|
|
return web.json_response({"ok": True, "receivers": receivers})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static file serving with setup redirect
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_http(request):
|
|
"""Serve static files, with setup redirect logic."""
|
|
path = request.path
|
|
|
|
# Root redirect: if setup not complete, redirect to /setup
|
|
if path == '/' or path == '/index.html':
|
|
if not config.get('setup_complete', False):
|
|
raise web.HTTPFound('/setup')
|
|
path = '/index.html'
|
|
|
|
# Serve /setup -> setup/setup.html
|
|
if path == '/setup' or path == '/setup/':
|
|
file_path = WEB_DIR / 'setup' / 'setup.html'
|
|
if file_path.exists():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Setup page not found")
|
|
|
|
# Serve /admin -> admin/admin.html
|
|
if path == '/admin' or path == '/admin/':
|
|
file_path = WEB_DIR / 'admin' / 'admin.html'
|
|
if file_path.exists():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Admin page not found")
|
|
|
|
file_path = WEB_DIR / path.lstrip('/')
|
|
if file_path.exists() and file_path.is_file():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Not Found")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Application setup
|
|
# ---------------------------------------------------------------------------
|
|
async def start_http_server():
|
|
"""Start HTTP server with WebSocket support."""
|
|
port = config["web_port"]
|
|
app = web.Application()
|
|
|
|
# WebSocket
|
|
app.router.add_get('/ws', websocket_handler)
|
|
|
|
# Public API
|
|
app.router.add_get('/api/receiver-location', handle_receiver_location)
|
|
app.router.add_get('/api/config', handle_config)
|
|
|
|
# Auth API
|
|
app.router.add_post('/api/auth/login', handle_auth_login)
|
|
app.router.add_post('/api/auth/logout', handle_auth_logout)
|
|
app.router.add_get('/api/auth/check', handle_auth_check)
|
|
|
|
# Setup API
|
|
app.router.add_post('/api/setup/password', handle_setup_password)
|
|
app.router.add_post('/api/setup/location', handle_setup_location)
|
|
app.router.add_post('/api/setup/receivers', handle_setup_receivers)
|
|
app.router.add_get('/api/setup/scan', handle_setup_scan)
|
|
app.router.add_post('/api/setup/theme', handle_setup_theme)
|
|
app.router.add_post('/api/setup/complete', handle_setup_complete)
|
|
|
|
# Admin API
|
|
app.router.add_get('/api/admin/config', handle_admin_config)
|
|
app.router.add_put('/api/admin/config', handle_admin_config_update)
|
|
app.router.add_get('/api/admin/status', handle_admin_status)
|
|
app.router.add_get('/api/admin/themes', handle_admin_themes)
|
|
app.router.add_post('/api/admin/password', handle_admin_password)
|
|
app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers)
|
|
app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers)
|
|
|
|
# Static files (must be last - catch-all)
|
|
app.router.add_get('/{tail:.*}', handle_http)
|
|
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, '0.0.0.0', port)
|
|
await site.start()
|
|
print(f"HTTP server running on http://0.0.0.0:{port}")
|
|
if not config.get('setup_complete', False):
|
|
print(f"First-run setup at http://0.0.0.0:{port}/setup")
|
|
print(f"Admin panel at http://0.0.0.0:{port}/admin")
|
|
|
|
|
|
async def main():
|
|
"""Main entry point"""
|
|
global receivers, receiver_tasks
|
|
|
|
print("Pixel ADS-B Server Starting...")
|
|
|
|
load_config()
|
|
|
|
print(f"Access at http://0.0.0.0:{config['web_port']}")
|
|
|
|
# Get receivers based on config
|
|
receiver_config = config["receivers"]
|
|
receiver_port = config["receiver_port"]
|
|
|
|
if receiver_config == "AUTO":
|
|
found = await scan_for_receivers()
|
|
receivers = found
|
|
elif isinstance(receiver_config, list):
|
|
receivers = receiver_config
|
|
print(f"Using configured receivers: {receivers}")
|
|
elif isinstance(receiver_config, str):
|
|
receivers = [receiver_config]
|
|
print(f"Using configured receiver: {receivers[0]}")
|
|
|
|
if not receivers:
|
|
print("WARNING: No receivers found or configured!")
|
|
else:
|
|
print(f"Using {len(receivers)} receiver(s)")
|
|
|
|
# Start all tasks
|
|
tasks = [
|
|
start_http_server(),
|
|
cleanup_old_flights(),
|
|
broadcast_flights(),
|
|
]
|
|
|
|
# Connect to all receivers
|
|
for receiver in receivers:
|
|
task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port))
|
|
receiver_tasks.append(task)
|
|
tasks.append(task)
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|