Add telnet server

This commit is contained in:
Ian Renton
2026-09-11 15:55:57 +01:00
parent 04f5df5260
commit 5e56cd3b19
32 changed files with 222 additions and 35 deletions
+276
View File
@@ -0,0 +1,276 @@
import asyncio
import logging
import os
import tornado
from tornado.web import StaticFileHandler
from core.config import (
ALLOW_SPOTTING,
API_ONLY_MODE,
BASE_URL,
LOG_WEB_REQUESTS,
WEB_SERVER_PORT,
)
from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import (
APILookupCallHandler,
APILookupGridHandler,
APILookupSIGRefHandler,
)
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
from webserver.handlers.api.v1_addspot import V1APISpotHandler
from webserver.handlers.api.v1_compatability import V1RedirectHandler
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
from webserver.handlers.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
from webserver.sse_broadcaster import SSEBroadcaster
logger = logging.getLogger(__name__)
_HERE = os.path.dirname(__file__ or "")
class WebServer:
"""Provides the public-facing web server."""
def __init__(self):
"""Constructor"""
self._data_store = DATA_STORE
self._data_providers = DATA_PROVIDERS
self._spot_broadcaster = SSEBroadcaster()
self._alert_broadcaster = SSEBroadcaster()
self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
self.web_server_metrics = {
"last_page_access_time": None,
"last_api_access_time": None,
"page_access_counter": 0,
"api_access_counter": 0,
"status": "Starting",
}
def setup(self):
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
def start(self):
"""Start the web server"""
asyncio.run(self._start_inner())
def stop(self):
"""Stop the web server"""
self._shutdown_event.set()
async def _start_inner(self):
"""Start method (async). Sets up the Tornado application."""
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
# to avoid copy-pasting the same thing to every route declaration below.
handler_opts = {"web_server_metrics": self.web_server_metrics}
# API endpoints are always enabled
api_routes = [
(
r"/api/v2/spots",
APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v2/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts, **handler_opts},
),
(
r"/api/v2/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
),
(
r"/api/v2/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts},
),
(
r"/api/v2/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get(), **handler_opts},
),
(
r"/api/v2/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v2/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get(), **handler_opts},
),
(
r"/api/v2/status",
APIStatusHandler,
{"status_data": self._data_store.status.get(), **handler_opts},
),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}),
(
r"/api/v2/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts,
},
),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
# that have the major breaking changes get a bespoke handler.
v1_compat_routes = [
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
),
(
r"/api/v1/spot",
V1APISpotHandler,
{
"spots": self._data_store.spots,
**handler_opts,
},
),
(r"/api/v1/(.*)", V1RedirectHandler),
]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
if self._api_only_mode:
logger.info("API-only mode is enabled. Web UI will not be served.")
ui_routes = [
(
r"/",
PageTemplateHandler,
{"template_name": "api_only_home", **handler_opts},
)
]
else:
ui_routes = [
(r"/", PageTemplateHandler, {"template_name": "spots", **handler_opts}),
(
r"/map",
PageTemplateHandler,
{"template_name": "map", **handler_opts},
),
(
r"/bands",
PageTemplateHandler,
{"template_name": "bands", **handler_opts},
),
(
r"/alerts",
PageTemplateHandler,
{"template_name": "alerts", **handler_opts},
),
(
r"/conditions",
PageTemplateHandler,
{"template_name": "conditions", **handler_opts},
),
(
r"/status",
PageTemplateHandler,
{"template_name": "status", **handler_opts},
),
(
r"/about",
PageTemplateHandler,
{"template_name": "about", **handler_opts},
),
]
# Only allow the Add Spot page if spotting is allowed
if ALLOW_SPOTTING:
ui_routes += [
(
r"/add-spot",
PageTemplateHandler,
{"template_name": "add_spot", **handler_opts},
)
]
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
# mode.
misc_routes = [
(
r"/apidocs",
PageTemplateHandler,
{"template_name": "apidocs", **handler_opts},
),
(r"/metrics", PrometheusMetricsHandler),
(r"/manifest.webmanifest", ManifestHandler),
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
# testing.
(
r"/static/(.*)",
StaticFileHandler,
{"path": os.path.join(_HERE, "../static")},
),
]
app = tornado.web.Application(
api_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False,
)
app.listen(self._port, xheaders=True)
logger.info(f"Web server running on port {WEB_SERVER_PORT!s}")
logger.info(f"You can access your copy of Spothole at {BASE_URL}")
await self._shutdown_event.wait()
def request_log(handler):
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
web request logging altogetether."""
if LOG_WEB_REQUESTS:
if handler.get_status() < 500:
log_method = logger.info
else:
log_method = logger.warning
request = handler.request
client_ip = request.remote_ip
referrer = request.headers.get("Referer", "-")
user_agent = request.headers.get("User-Agent", "-")
log_method(
f'{client_ip} - "{request.method} {request.uri}" {handler.get_status()} {request.request_time():.2f}ms | Ref: {referrer} | UA: {user_agent}'
)
# Global object
WEB_SERVER = WebServer()