5df38bad2d
Closes TG-4
938 lines
33 KiB
Python
938 lines
33 KiB
Python
import os
|
||
import time
|
||
import socket
|
||
import subprocess
|
||
import json
|
||
import threading
|
||
import hashlib
|
||
from urllib.request import urlopen, Request
|
||
from urllib.parse import urlencode
|
||
from urllib.error import URLError, HTTPError
|
||
|
||
from flask import Flask, jsonify, render_template, send_from_directory, send_file, request, Response
|
||
|
||
try:
|
||
from flask_sock import Sock
|
||
except ImportError:
|
||
Sock = None
|
||
|
||
try:
|
||
from websocket import create_connection as _ws_create_connection
|
||
from websocket import WebSocketConnectionClosedException as _WsClosed
|
||
except ImportError:
|
||
_ws_create_connection = None
|
||
_WsClosed = Exception
|
||
|
||
from state import get_cpu_usage_percent
|
||
from network_manager import (
|
||
load_network_config, save_network_config, get_current_network_info,
|
||
switch_network_mode, network_config_lock, NETWORK_DEFAULTS,
|
||
)
|
||
from terminal import terminal_session
|
||
from transponder import (
|
||
load_transponder_config,
|
||
normalize_transponder_config,
|
||
save_transponder_config,
|
||
merge_transponder_request,
|
||
build_preview,
|
||
send_transmission,
|
||
build_slot_udp_payload,
|
||
is_aistx_phy_available,
|
||
parse_nrzi_hex,
|
||
send_raw_nrzi_packet,
|
||
tx_gpio_pulse,
|
||
)
|
||
|
||
app = Flask(__name__)
|
||
sock = Sock(app) if Sock else None
|
||
|
||
|
||
# ==================== ais_hub upstream ====================
|
||
|
||
AIS_HUB_URL = os.environ.get("AIS_HUB_URL", "http://127.0.0.1:8081").rstrip("/")
|
||
|
||
|
||
def _ais_hub_ws_url():
|
||
base = AIS_HUB_URL
|
||
if base.startswith("https://"):
|
||
return "wss://" + base[len("https://"):] + "/ws"
|
||
if base.startswith("http://"):
|
||
return "ws://" + base[len("http://"):] + "/ws"
|
||
return "ws://" + base + "/ws"
|
||
|
||
|
||
# ==================== helpers ====================
|
||
|
||
def _append_client_log_line(line: str):
|
||
"""Append a single line to client_errors.log (best-effort)."""
|
||
try:
|
||
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
|
||
with open(path, "a", encoding="utf-8") as f:
|
||
f.write(line.rstrip("\n") + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ==================== WebSocket terminal ====================
|
||
if sock is not None and os.name == "posix":
|
||
|
||
@sock.route("/ws/terminal")
|
||
def ws_terminal(ws):
|
||
terminal_session(ws)
|
||
|
||
|
||
# ==================== WebSocket proxy to ais_hub /ws ====================
|
||
if sock is not None:
|
||
|
||
@sock.route("/ws")
|
||
def ws_ais_hub_proxy(ws):
|
||
"""Прозрачный WS-прокси к ais_hub /ws (localhost:8081)."""
|
||
if _ws_create_connection is None:
|
||
# Не закрываем WS сразу: иначе браузер будет переподключаться в цикле и шуметь в консоли.
|
||
# Держим соединение открытым и периодически напоминаем про отсутствующую зависимость.
|
||
while True:
|
||
try:
|
||
ws.send(json.dumps({"type": "error", "ts": time.time(),
|
||
"data": {"error": "websocket-client not installed on server"}}))
|
||
except Exception:
|
||
return
|
||
try:
|
||
time.sleep(5)
|
||
except Exception:
|
||
return
|
||
|
||
upstream_url = _ais_hub_ws_url()
|
||
# Если ais_hub временно недоступен, не рвём соединение с браузером:
|
||
# держим WS открытым и периодически ретраим подключение к upstream.
|
||
upstream = None
|
||
backoff = 1.0
|
||
while upstream is None:
|
||
try:
|
||
upstream = _ws_create_connection(upstream_url, timeout=5)
|
||
break
|
||
except Exception as e:
|
||
try:
|
||
ws.send(json.dumps({"type": "error", "ts": time.time(),
|
||
"data": {"error": f"ais_hub unreachable: {e}"}}))
|
||
except Exception:
|
||
# Клиент ушёл — дальше ретраить нет смысла.
|
||
return
|
||
try:
|
||
time.sleep(backoff)
|
||
except Exception:
|
||
return
|
||
backoff = min(backoff * 2.0, 10.0)
|
||
|
||
stop = threading.Event()
|
||
|
||
def upstream_to_client():
|
||
try:
|
||
while not stop.is_set():
|
||
try:
|
||
msg = upstream.recv()
|
||
except _WsClosed:
|
||
break
|
||
except Exception:
|
||
break
|
||
if msg is None or msg == "":
|
||
break
|
||
if isinstance(msg, bytes):
|
||
try:
|
||
msg = msg.decode("utf-8", errors="ignore")
|
||
except Exception:
|
||
continue
|
||
try:
|
||
ws.send(msg)
|
||
except Exception:
|
||
break
|
||
finally:
|
||
stop.set()
|
||
|
||
t = threading.Thread(target=upstream_to_client, daemon=True)
|
||
t.start()
|
||
|
||
try:
|
||
while not stop.is_set():
|
||
try:
|
||
# flask_sock/simple_websocket have slightly different receive() signatures
|
||
# across versions; some don't support timeout=, and some raise on timeout.
|
||
try:
|
||
data = ws.receive(timeout=30)
|
||
except TypeError:
|
||
data = ws.receive()
|
||
except Exception as e:
|
||
# Don't tear down the whole proxy on a benign receive timeout.
|
||
if "timeout" in str(e).lower():
|
||
continue
|
||
break
|
||
if data is None:
|
||
break
|
||
try:
|
||
if isinstance(data, bytes):
|
||
upstream.send_binary(data)
|
||
else:
|
||
upstream.send(data)
|
||
except Exception:
|
||
break
|
||
finally:
|
||
stop.set()
|
||
try:
|
||
upstream.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ==================== Pages ====================
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return render_template("index.html")
|
||
|
||
|
||
@app.route("/cert")
|
||
def cert_install_page():
|
||
return render_template("cert.html")
|
||
|
||
|
||
# ==================== REST proxy to ais_hub /api/v1/* ====================
|
||
|
||
_HOP_HEADERS = {
|
||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||
"te", "trailers", "transfer-encoding", "upgrade", "content-encoding",
|
||
"content-length", "host",
|
||
}
|
||
|
||
|
||
@app.route("/api/v1/<path:rest>", methods=["GET", "POST", "PUT", "DELETE"])
|
||
def proxy_ais_hub(rest):
|
||
"""Прозрачный прокси REST /api/v1/* на ais_hub (127.0.0.1:8081)."""
|
||
url = f"{AIS_HUB_URL}/api/v1/{rest}"
|
||
qs = request.query_string.decode("utf-8") if request.query_string else ""
|
||
if qs:
|
||
url = f"{url}?{qs}"
|
||
|
||
method = request.method.upper()
|
||
body = None
|
||
if method in ("POST", "PUT"):
|
||
body = request.get_data() or b""
|
||
|
||
headers = {}
|
||
ct = request.headers.get("Content-Type")
|
||
if ct:
|
||
headers["Content-Type"] = ct
|
||
|
||
req = Request(url, data=body, method=method, headers=headers)
|
||
timeout = 10.0 if method in ("POST", "PUT") else 5.0
|
||
|
||
try:
|
||
resp = urlopen(req, timeout=timeout)
|
||
payload = resp.read()
|
||
status = getattr(resp, "status", 200) or 200
|
||
out_headers = {}
|
||
for k, v in resp.headers.items():
|
||
if k.lower() in _HOP_HEADERS:
|
||
continue
|
||
out_headers[k] = v
|
||
out_headers.setdefault("Cache-Control", "no-store")
|
||
return Response(payload, status=status, headers=out_headers)
|
||
except HTTPError as e:
|
||
try:
|
||
payload = e.read()
|
||
except Exception:
|
||
payload = b""
|
||
out_headers = {}
|
||
try:
|
||
for k, v in e.headers.items():
|
||
if k.lower() in _HOP_HEADERS:
|
||
continue
|
||
out_headers[k] = v
|
||
except Exception:
|
||
pass
|
||
out_headers.setdefault("Cache-Control", "no-store")
|
||
return Response(payload, status=e.code, headers=out_headers)
|
||
except URLError as e:
|
||
return jsonify({"error": f"ais_hub unreachable: {e.reason}"}), 503
|
||
except Exception as e:
|
||
return jsonify({"error": f"proxy error: {e}"}), 502
|
||
|
||
|
||
# ==================== API ====================
|
||
|
||
@app.route("/api/terminal")
|
||
def api_terminal():
|
||
"""Доступность веб-консоли (PTY + WebSocket)."""
|
||
return jsonify({"pty": os.name == "posix", "ws": sock is not None})
|
||
|
||
|
||
@app.route("/api/version")
|
||
def api_version():
|
||
"""Версия/сборка для отладки кэша фронта и деплоя."""
|
||
try:
|
||
here = os.path.dirname(__file__)
|
||
def _mtime(p):
|
||
try:
|
||
return int(os.path.getmtime(os.path.join(here, p)))
|
||
except Exception:
|
||
return None
|
||
return jsonify({
|
||
"server_time": int(time.time()),
|
||
"routes_py_mtime": _mtime("routes.py"),
|
||
"app_js_mtime": _mtime(os.path.join("static", "js", "app.js")),
|
||
"index_html_mtime": _mtime(os.path.join("templates", "index.html")),
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"server_time": int(time.time()), "error": str(e)})
|
||
|
||
|
||
@app.route("/api/sysinfo")
|
||
def api_sysinfo():
|
||
"""Локальная система: CPU %, температура, память, uptime. ais_hub этого не знает."""
|
||
now = int(time.time())
|
||
out = {"server_now": now}
|
||
|
||
try:
|
||
with open("/proc/uptime", "r") as f:
|
||
out["sys_uptime"] = int(float(f.read().split()[0]))
|
||
except Exception:
|
||
out["sys_uptime"] = None
|
||
|
||
try:
|
||
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
|
||
out["cpu_temp"] = round(int(f.read().strip()) / 1000.0, 1)
|
||
except Exception:
|
||
out["cpu_temp"] = None
|
||
|
||
out["cpu_percent"] = get_cpu_usage_percent()
|
||
|
||
try:
|
||
mi = {}
|
||
with open("/proc/meminfo", "r") as f:
|
||
for line in f:
|
||
k, v = line.split(":", 1)
|
||
mi[k.strip()] = int(v.strip().split()[0])
|
||
total = mi.get("MemTotal", 0)
|
||
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
|
||
out["mem_total_mb"] = round(total / 1024)
|
||
out["mem_used_mb"] = round((total - avail) / 1024)
|
||
out["mem_pct"] = round((total - avail) / total * 100, 1) if total else 0
|
||
except Exception:
|
||
out["mem_total_mb"] = None
|
||
out["mem_used_mb"] = None
|
||
out["mem_pct"] = None
|
||
|
||
return jsonify(out)
|
||
|
||
|
||
@app.route("/api/client_log", methods=["POST"])
|
||
def api_client_log():
|
||
"""
|
||
Приём логов/ошибок из браузера (front-end).
|
||
Пишет в client_errors.log рядом с routes.py.
|
||
"""
|
||
try:
|
||
data = request.get_json(force=True, silent=True) or {}
|
||
except Exception:
|
||
data = {}
|
||
|
||
level = str(data.get("level") or "info").lower()
|
||
msg = str(data.get("msg") or "")
|
||
ctx = data.get("ctx")
|
||
ts = data.get("ts")
|
||
url = data.get("url")
|
||
ua = data.get("ua")
|
||
|
||
try:
|
||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||
line = f"[{now}] level={level} msg={msg} url={url} ua={ua} ctx={ctx} ts={ts}"
|
||
_append_client_log_line(line)
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.route("/api/client_log_tail")
|
||
def api_client_log_tail():
|
||
"""Хвост client_errors.log для диагностики без SSH."""
|
||
try:
|
||
n = request.args.get("n", "200")
|
||
try:
|
||
n = int(n)
|
||
except Exception:
|
||
n = 200
|
||
n = max(1, min(2000, n))
|
||
|
||
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
|
||
if not os.path.exists(path):
|
||
return jsonify({"ok": True, "exists": False, "lines": []})
|
||
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
lines = f.readlines()
|
||
tail = [ln.rstrip("\n") for ln in lines[-n:]]
|
||
return jsonify({"ok": True, "exists": True, "lines": tail})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/transponder", methods=["GET"])
|
||
def api_transponder_get():
|
||
"""Настройки Class B. ownship тянется фронтом отдельно через /api/v1/ownship."""
|
||
try:
|
||
cfg = normalize_transponder_config(load_transponder_config())
|
||
return jsonify(
|
||
{
|
||
"ok": True,
|
||
"config": cfg,
|
||
"aistx_phy_available": is_aistx_phy_available(),
|
||
}
|
||
)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
def _fetch_ownship_from_hub():
|
||
"""Best-effort: последний GPS-fix из ais_hub. Возвращает dict (возможно пустой)."""
|
||
try:
|
||
resp = urlopen(f"{AIS_HUB_URL}/api/v1/ownship", timeout=2.0)
|
||
raw = resp.read()
|
||
own = json.loads(raw.decode("utf-8", errors="ignore"))
|
||
if not isinstance(own, dict):
|
||
return {}
|
||
# Адаптируем имена полей к тем, что ожидает transponder (course/speed/...).
|
||
return {
|
||
"lat": own.get("lat"),
|
||
"lon": own.get("lon"),
|
||
"course": own.get("cog"),
|
||
"speed": own.get("sog"),
|
||
"heading": None,
|
||
"timestamp": own.get("ts"),
|
||
"satellites": own.get("sats"),
|
||
"fix_quality": own.get("fix_quality"),
|
||
}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
@app.route("/api/transponder", methods=["POST"])
|
||
def api_transponder_save():
|
||
"""Сохранить настройки транспондера в transponder_config.json."""
|
||
data = request.get_json(force=True) or {}
|
||
base = load_transponder_config()
|
||
merged = merge_transponder_request(base, data)
|
||
saved = save_transponder_config(merged)
|
||
return jsonify({"ok": True, "config": saved})
|
||
|
||
|
||
@app.route("/api/transponder/preview", methods=["POST"])
|
||
def api_transponder_preview():
|
||
"""NRZI hex для типов 18/19/24 без отправки (отправка — 127.0.0.1:6010)."""
|
||
data = request.get_json(force=True) or {}
|
||
base = load_transponder_config()
|
||
cfg = merge_transponder_request(base, data)
|
||
own = _fetch_ownship_from_hub()
|
||
try:
|
||
prev = build_preview(cfg, own)
|
||
except RuntimeError as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 503
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 400
|
||
prev.pop("dictionaries", None)
|
||
return jsonify({"ok": True, "preview": prev})
|
||
|
||
|
||
@app.route("/api/transponder/send", methods=["POST"])
|
||
def api_transponder_send():
|
||
"""NRZI по UDP на 127.0.0.1:6010 с заголовком канал+слот (как тест слота)."""
|
||
data = request.get_json(force=True) or {}
|
||
which = data.get("which")
|
||
if which not in ("18", "19", "24A", "24B", "broadcast"):
|
||
return jsonify(
|
||
{"ok": False, "error": "which must be 18, 19, 24A, 24B, broadcast"}
|
||
), 400
|
||
|
||
cfg_body = {k: v for k, v in data.items() if k != "which"}
|
||
base = load_transponder_config()
|
||
cfg = merge_transponder_request(base, cfg_body)
|
||
own = _fetch_ownship_from_hub()
|
||
try:
|
||
result = send_transmission(cfg, own, which)
|
||
except RuntimeError as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 503
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
return jsonify({"ok": True, "result": result})
|
||
|
||
|
||
@app.route("/api/transponder/gpio_pulse", methods=["POST"])
|
||
def api_transponder_gpio_pulse():
|
||
"""Один импульс TX (GPIO), без UDP. Настройки скрипта — из тела запроса или transponder_config."""
|
||
data = request.get_json(force=True) or {}
|
||
base = load_transponder_config()
|
||
cfg = merge_transponder_request(base, data)
|
||
pulse = tx_gpio_pulse(cfg, after_udp=False)
|
||
if not pulse.get("ok"):
|
||
err = pulse.get("error") or pulse.get("stderr") or "GPIO pulse failed"
|
||
return jsonify({"ok": False, "error": err, "pulse": pulse}), 400
|
||
return jsonify({"ok": True, "pulse": pulse})
|
||
|
||
|
||
@app.route("/api/transponder/send_raw", methods=["POST"])
|
||
def api_transponder_send_raw():
|
||
"""Сырой NRZI (hex) + канал + слот → UDP 127.0.0.1:6010."""
|
||
data = request.get_json(force=True) or {}
|
||
hx = data.get("nrzi_hex") or data.get("hex")
|
||
if not isinstance(hx, str) or not hx.strip():
|
||
return jsonify({"ok": False, "error": "nrzi_hex (hex строка) обязателен"}), 400
|
||
channel = data.get("channel", "A")
|
||
try:
|
||
slot = int(data.get("slot", 0))
|
||
except (TypeError, ValueError):
|
||
return jsonify({"ok": False, "error": "slot must be integer 0..2249"}), 400
|
||
if channel not in ("A", "B"):
|
||
return jsonify({"ok": False, "error": "channel must be A or B"}), 400
|
||
if slot < 0 or slot > 2249:
|
||
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
|
||
try:
|
||
body = parse_nrzi_hex(hx)
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 400
|
||
if not body:
|
||
return jsonify({"ok": False, "error": "пустой payload после hex"}), 400
|
||
cfg_body = {
|
||
k: v
|
||
for k, v in data.items()
|
||
if k not in ("nrzi_hex", "hex", "channel", "slot")
|
||
}
|
||
base = load_transponder_config()
|
||
cfg = merge_transponder_request(base, cfg_body)
|
||
try:
|
||
result = send_raw_nrzi_packet(channel, slot, body, cfg=cfg)
|
||
except ValueError as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
return jsonify({"ok": True, "result": result})
|
||
|
||
|
||
# Аппаратно проверенная NRZI-последовательность для «Тест слота».
|
||
TEST_AIS_NRZI = bytes([
|
||
102, 102, 102, 254, 149, 61, 224, 94, 245, 171, 174, 169,
|
||
74, 84, 87, 105, 51, 82, 202, 166, 141, 99, 170, 170,
|
||
170, 253, 236, 63, 170, 170, 170, 170,
|
||
])
|
||
|
||
|
||
@app.route("/api/send_test_slot", methods=["POST"])
|
||
def api_send_test_slot():
|
||
"""Отправляет тестовую датаграмму AIS NRZI в указанный слот/канал (UDP 127.0.0.1:6010)."""
|
||
data = request.get_json(force=True)
|
||
if not data:
|
||
return jsonify({"ok": False, "error": "Empty payload"}), 400
|
||
|
||
channel = data.get("channel")
|
||
slot = data.get("slot")
|
||
|
||
if channel not in ("A", "B"):
|
||
return jsonify({"ok": False, "error": "channel must be 'A' or 'B'"}), 400
|
||
if not isinstance(slot, int) or slot < 0 or slot > 2249:
|
||
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
|
||
|
||
dest = ("127.0.0.1", 6010)
|
||
datagram = build_slot_udp_payload(channel, slot, TEST_AIS_NRZI)
|
||
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.sendto(datagram, dest)
|
||
s.close()
|
||
return jsonify({"ok": True, "dest": f"{dest[0]}:{dest[1]}", "size": len(datagram)})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
# ==================== Network API ====================
|
||
|
||
@app.route("/api/network", methods=["GET"])
|
||
def api_network_get():
|
||
"""Returns current network configuration and live status."""
|
||
with network_config_lock:
|
||
cfg = load_network_config()
|
||
live = get_current_network_info()
|
||
return jsonify({"config": cfg, "live": live})
|
||
|
||
|
||
@app.route("/api/network", methods=["POST"])
|
||
def api_network_save():
|
||
"""Saves network configuration (does NOT switch mode immediately)."""
|
||
data = request.get_json(force=True)
|
||
if not data:
|
||
return jsonify({"ok": False, "error": "Empty payload"}), 400
|
||
|
||
with network_config_lock:
|
||
cfg = load_network_config()
|
||
for key in NETWORK_DEFAULTS:
|
||
if key in data:
|
||
cfg[key] = data[key]
|
||
save_network_config(cfg)
|
||
return jsonify({"ok": True, "config": cfg})
|
||
|
||
|
||
@app.route("/api/network/switch", methods=["POST"])
|
||
def api_network_switch():
|
||
"""Switches network mode to AP or WiFi by running the shell script."""
|
||
data = request.get_json(force=True)
|
||
target = data.get("mode") if data else None
|
||
if target not in ("ap", "wifi"):
|
||
return jsonify({"ok": False, "error": "Provide 'mode': 'ap' or 'wifi'"}), 400
|
||
|
||
with network_config_lock:
|
||
cfg = load_network_config()
|
||
for key in NETWORK_DEFAULTS:
|
||
if key in data and key != "mode":
|
||
cfg[key] = data[key]
|
||
save_network_config(cfg)
|
||
|
||
result = switch_network_mode(target)
|
||
return jsonify(result)
|
||
|
||
|
||
AIS_MINI_CONF = "/ais-mini.conf"
|
||
AIS_MINI_SERVICE = "aisMini.service"
|
||
|
||
AIS_HUB_CONF = "/opt/aishub/config/config.yaml"
|
||
AIS_HUB_SERVICE = "ais_hub.service"
|
||
|
||
|
||
@app.route("/api/config", methods=["GET"])
|
||
def api_config_get():
|
||
"""Returns the content of the AIS-catcher Mini configuration file."""
|
||
try:
|
||
with open(AIS_MINI_CONF, "r") as f:
|
||
text = f.read()
|
||
return jsonify({"ok": True, "text": text})
|
||
except FileNotFoundError:
|
||
return jsonify({"ok": False, "error": f"File not found: {AIS_MINI_CONF}"}), 404
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/config", methods=["POST"])
|
||
def api_config_save():
|
||
"""Saves the AIS-catcher Mini configuration file."""
|
||
data = request.get_json(force=True)
|
||
text = data.get("text") if data else None
|
||
if text is None:
|
||
return jsonify({"ok": False, "error": "No 'text' field"}), 400
|
||
try:
|
||
with open(AIS_MINI_CONF, "w") as f:
|
||
f.write(text)
|
||
return jsonify({"ok": True})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/service/restart", methods=["POST"])
|
||
def api_service_restart():
|
||
"""Restarts the aisMini.service via systemctl."""
|
||
try:
|
||
result = subprocess.run(
|
||
["systemctl", "restart", AIS_MINI_SERVICE],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
if result.returncode == 0:
|
||
return jsonify({"ok": True, "message": f"{AIS_MINI_SERVICE} restarted"})
|
||
else:
|
||
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
|
||
except subprocess.TimeoutExpired:
|
||
return jsonify({"ok": False, "error": "Restart timed out"}), 500
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/service/status", methods=["GET"])
|
||
def api_service_status():
|
||
"""Returns the status of aisMini.service."""
|
||
try:
|
||
result = subprocess.run(
|
||
["systemctl", "is-active", AIS_MINI_SERVICE],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
state = result.stdout.strip()
|
||
return jsonify({"ok": True, "state": state})
|
||
except Exception as e:
|
||
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
|
||
|
||
|
||
@app.route("/api/config/aishub", methods=["GET"])
|
||
def api_aishub_config_get():
|
||
"""Returns the content of the ais_hub configuration YAML."""
|
||
try:
|
||
with open(AIS_HUB_CONF, "r", encoding="utf-8", errors="replace") as f:
|
||
text = f.read()
|
||
return jsonify({"ok": True, "text": text})
|
||
except FileNotFoundError:
|
||
return jsonify({"ok": False, "error": f"File not found: {AIS_HUB_CONF}"}), 404
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/config/aishub", methods=["POST"])
|
||
def api_aishub_config_save():
|
||
"""Saves the ais_hub configuration YAML."""
|
||
data = request.get_json(force=True)
|
||
text = data.get("text") if data else None
|
||
if text is None:
|
||
return jsonify({"ok": False, "error": "No 'text' field"}), 400
|
||
try:
|
||
with open(AIS_HUB_CONF, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
return jsonify({"ok": True})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/service/aishub/status", methods=["GET"])
|
||
def api_aishub_service_status():
|
||
"""Returns the status of ais_hub.service."""
|
||
try:
|
||
result = subprocess.run(
|
||
["systemctl", "is-active", AIS_HUB_SERVICE],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
state = result.stdout.strip()
|
||
return jsonify({"ok": True, "state": state})
|
||
except Exception as e:
|
||
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
|
||
|
||
|
||
@app.route("/api/service/aishub/restart", methods=["POST"])
|
||
def api_aishub_service_restart():
|
||
"""Restarts ais_hub.service via systemctl."""
|
||
try:
|
||
result = subprocess.run(
|
||
["systemctl", "restart", AIS_HUB_SERVICE],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
if result.returncode == 0:
|
||
return jsonify({"ok": True, "message": f"{AIS_HUB_SERVICE} restarted"})
|
||
else:
|
||
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
|
||
except subprocess.TimeoutExpired:
|
||
return jsonify({"ok": False, "error": "Restart timed out"}), 500
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/network/scan", methods=["GET"])
|
||
def api_network_scan():
|
||
"""Scans for available WiFi networks (Linux/iw only)."""
|
||
cfg = load_network_config()
|
||
iface = cfg.get("iface", "wlan0")
|
||
try:
|
||
out = subprocess.check_output(
|
||
["iw", "dev", iface, "scan", "ap-force"],
|
||
timeout=15, stderr=subprocess.DEVNULL,
|
||
).decode()
|
||
networks = []
|
||
current = {}
|
||
for line in out.splitlines():
|
||
line = line.strip()
|
||
if line.startswith("BSS "):
|
||
if current.get("ssid"):
|
||
networks.append(current)
|
||
current = {"bssid": line.split()[1].rstrip("("), "ssid": "", "signal": None}
|
||
elif line.startswith("SSID:"):
|
||
current["ssid"] = line.split(":", 1)[1].strip()
|
||
elif line.startswith("signal:"):
|
||
try:
|
||
current["signal"] = float(line.split(":")[1].strip().split()[0])
|
||
except (ValueError, IndexError):
|
||
pass
|
||
if current.get("ssid"):
|
||
networks.append(current)
|
||
seen = set()
|
||
unique = []
|
||
for n in networks:
|
||
if n["ssid"] not in seen:
|
||
seen.add(n["ssid"])
|
||
unique.append(n)
|
||
unique.sort(key=lambda x: x.get("signal") or -999, reverse=True)
|
||
return jsonify({"ok": True, "networks": unique})
|
||
except subprocess.TimeoutExpired:
|
||
return jsonify({"ok": False, "error": "Scan timed out"})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "networks": []})
|
||
|
||
|
||
# ==================== Certificate downloads ====================
|
||
|
||
@app.route("/ca.pem")
|
||
def download_ca():
|
||
"""Отдаёт корневой CA-сертификат для установки на клиентские устройства."""
|
||
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
|
||
if not os.path.exists(ca_path):
|
||
return "CA certificate not generated", 404
|
||
return send_file(ca_path, mimetype="application/x-pem-file",
|
||
as_attachment=True, download_name="AISMap_CA.pem")
|
||
|
||
|
||
@app.route("/ca.crt")
|
||
def download_ca_crt():
|
||
"""Тот же CA, но с расширением .crt — Android открывает установщик автоматически."""
|
||
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
|
||
if not os.path.exists(ca_path):
|
||
return "CA certificate not generated", 404
|
||
return send_file(ca_path, mimetype="application/x-x509-ca-cert",
|
||
as_attachment=True, download_name="AISMap_CA.crt")
|
||
|
||
|
||
# ==================== Static files with caching ====================
|
||
|
||
IMMUTABLE_YEAR = "public, max-age=31536000, immutable"
|
||
STATIC_MONTH = "public, max-age=2592000"
|
||
SHORT_REVALIDATE = "public, max-age=60, must-revalidate"
|
||
|
||
|
||
def _etag_for_path(path):
|
||
try:
|
||
st = os.stat(path)
|
||
h = hashlib.md5(f"{st.st_mtime_ns}:{st.st_size}".encode("ascii")).hexdigest()
|
||
return f'W/"{h}"'
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _serve_with_cache(directory, filename, cache_control):
|
||
"""send_from_directory + заголовок Cache-Control + ETag/304."""
|
||
abs_path = os.path.join(directory, filename)
|
||
etag = _etag_for_path(abs_path)
|
||
inm = request.headers.get("If-None-Match")
|
||
if etag and inm and inm == etag:
|
||
resp = Response(status=304)
|
||
resp.headers["ETag"] = etag
|
||
resp.headers["Cache-Control"] = cache_control
|
||
return resp
|
||
resp = send_from_directory(directory, filename)
|
||
resp.headers["Cache-Control"] = cache_control
|
||
if etag:
|
||
resp.headers["ETag"] = etag
|
||
return resp
|
||
|
||
|
||
@app.route("/svg/<path:filename>")
|
||
def serve_svg(filename):
|
||
"""SVG-иконки — редко меняются, долгий кеш."""
|
||
return _serve_with_cache("SVG", filename, STATIC_MONTH)
|
||
|
||
|
||
@app.route("/static/leaflet/<path:filename>")
|
||
def serve_leaflet(filename):
|
||
"""Leaflet (версионирован по содержимому) — immutable."""
|
||
return _serve_with_cache("static/leaflet", filename, IMMUTABLE_YEAR)
|
||
|
||
|
||
@app.route("/static/xterm/<path:filename>")
|
||
def serve_xterm(filename):
|
||
"""xterm.js — immutable."""
|
||
return _serve_with_cache("static/xterm", filename, IMMUTABLE_YEAR)
|
||
|
||
|
||
@app.route("/static/js/<path:filename>")
|
||
def serve_static_js(filename):
|
||
"""app.js, модули — SWR: всегда проверяем обновление, но отдаём из кеша если не изменилось."""
|
||
return _serve_with_cache("static/js", filename, SHORT_REVALIDATE)
|
||
|
||
|
||
@app.route("/static/css/<path:filename>")
|
||
def serve_static_css(filename):
|
||
return _serve_with_cache("static/css", filename, SHORT_REVALIDATE)
|
||
|
||
|
||
@app.route("/sw.js")
|
||
def serve_sw():
|
||
"""Service Worker должен быть в корне для scope '/'."""
|
||
path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
|
||
if not os.path.exists(path):
|
||
return "sw.js not found", 404
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
body = f.read()
|
||
resp = Response(body, mimetype="application/javascript")
|
||
resp.headers["Cache-Control"] = "no-store"
|
||
resp.headers["Service-Worker-Allowed"] = "/"
|
||
return resp
|
||
|
||
|
||
VECTOR_TILE_URL = os.environ.get("AISMAP_VECTOR_TILE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||
VECTOR_TILE_SERVICE = os.environ.get("AISMAP_VECTOR_TILE_SERVICE", "planet_small_z14").strip().strip("/")
|
||
|
||
|
||
@app.route("/vtiles/<int:z>/<int:x>/<int:y>.pbf")
|
||
def proxy_vector_tile(z, x, y):
|
||
"""Проксирует PBF-тайлы с локального векторного тайлсервера. immutable: тайл не меняется."""
|
||
try:
|
||
candidates = [
|
||
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/tiles/{z}/{x}/{y}.pbf",
|
||
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||
f"{VECTOR_TILE_URL}/data/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||
f"{VECTOR_TILE_URL}/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||
]
|
||
|
||
last_err = None
|
||
for url in candidates:
|
||
try:
|
||
resp = urlopen(url, timeout=5)
|
||
data = resp.read()
|
||
code = getattr(resp, "status", 200) or 200
|
||
headers = {
|
||
"Content-Type": resp.headers.get("Content-Type", "application/x-protobuf"),
|
||
"Cache-Control": IMMUTABLE_YEAR,
|
||
"Access-Control-Allow-Origin": "*",
|
||
"X-AISMap-Upstream": url,
|
||
}
|
||
ce = resp.headers.get("Content-Encoding")
|
||
if ce:
|
||
headers["Content-Encoding"] = ce
|
||
return data, code, headers
|
||
except HTTPError as e:
|
||
last_err = e
|
||
if getattr(e, "code", None) != 404:
|
||
raise
|
||
except URLError as e:
|
||
last_err = e
|
||
break
|
||
|
||
if isinstance(last_err, HTTPError) and getattr(last_err, "code", None) == 404:
|
||
return "Vector tile not found", 404
|
||
return "Vector tile upstream unavailable", 503
|
||
except (URLError, HTTPError):
|
||
return "Vector tile upstream error", 503
|
||
|
||
|
||
@app.route("/tiles/<int:z>/<int:x>/<int:y>.png")
|
||
def serve_tile(z, x, y):
|
||
"""Отдаёт растровые тайлы из static/tiles с вечным кешем (immutable)."""
|
||
max_tile = 2 ** z
|
||
if x < 0 or x >= max_tile or y < 0 or y >= max_tile:
|
||
return "Invalid tile coordinates", 404
|
||
|
||
rel_dir = os.path.join("static", "tiles", str(z), str(x))
|
||
tile_path = os.path.join(rel_dir, f"{y}.png")
|
||
|
||
if not os.path.exists(tile_path):
|
||
return "Tile not found", 404
|
||
|
||
etag = _etag_for_path(tile_path)
|
||
inm = request.headers.get("If-None-Match")
|
||
if etag and inm and inm == etag:
|
||
resp = Response(status=304)
|
||
resp.headers["ETag"] = etag
|
||
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
|
||
return resp
|
||
|
||
resp = send_from_directory(rel_dir, f"{y}.png")
|
||
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
|
||
if etag:
|
||
resp.headers["ETag"] = etag
|
||
return resp
|