Add basic auth

This commit is contained in:
Jack Kingsman
2026-03-11 10:01:57 -07:00
parent fa1c086f5f
commit 528a94d2bd
11 changed files with 356 additions and 7 deletions

View File

@@ -109,7 +109,7 @@ Radio startup/setup is one place where that frontend bubbling is intentional: if
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -443,6 +443,8 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.

View File

@@ -12,7 +12,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
* Visualize the mesh as a map or node set, view repeater stats, and more!
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](app_screenshot.png)
@@ -225,9 +225,13 @@ npm run build # build the frontend
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
## Additional Setup

View File

@@ -44,6 +44,7 @@ app/
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── events.py # Typed WS event payload serialization
├── websocket.py # WS manager + broadcast helpers
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers

View File

@@ -19,6 +19,8 @@ class Settings(BaseSettings):
database_path: str = "data/meshcore.db"
disable_bots: bool = False
enable_message_poll_fallback: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -36,6 +38,11 @@ class Settings(BaseSettings):
)
if self.ble_address and not self.ble_pin:
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
if self.basic_auth_partially_configured:
raise ValueError(
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
"must be set together."
)
return self
@property
@@ -46,6 +53,15 @@ class Settings(BaseSettings):
return "ble"
return "serial"
@property
def basic_auth_enabled(self) -> bool:
return bool(self.basic_auth_username and self.basic_auth_password)
@property
def basic_auth_partially_configured(self) -> bool:
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
return any_credentials_set and not self.basic_auth_enabled
settings = Settings()

View File

@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
class CacheControlStaticFiles(StaticFiles):
"""StaticFiles variant that adds a fixed Cache-Control header."""
def __init__(self, *args, cache_control: str, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cache_control = cache_control
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = self.cache_control
return response
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
return FileResponse(path, headers={"Cache-Control": cache_control})
def _is_index_file(path: Path, index_file: Path) -> bool:
"""Return True when the requested file is the SPA shell index.html."""
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
return False
if assets_dir.exists() and assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
app.mount(
"/assets",
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
name="assets",
)
else:
logger.warning(
"Frontend assets directory missing at %s; /assets files will not be served",
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
raise HTTPException(status_code=404, detail="Not found") from None
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
cache_control = (
INDEX_CACHE_CONTROL
if _is_index_file(file_path, index_file)
else STATIC_FILE_CACHE_CONTROL
)
return _file_response(file_path, cache_control=cache_control)
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
logger.info("Serving frontend from %s", frontend_dir)
return True

View File

@@ -5,8 +5,10 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
@@ -30,6 +32,7 @@ from app.routers import (
statistics,
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
setup_logging()
@@ -114,6 +117,8 @@ app = FastAPI(
lifespan=lifespan,
)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],

121
app/security.py Normal file
View File

@@ -0,0 +1,121 @@
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
from __future__ import annotations
import base64
import binascii
import json
import logging
import secrets
from typing import Any
from starlette.datastructures import Headers
logger = logging.getLogger(__name__)
_AUTH_REALM = "RemoteTerm"
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
class BasicAuthMiddleware:
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
self.app = app
self.username = username
self.password = password
self.realm = realm
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
def _is_authorized(self, scope: dict[str, Any]) -> bool:
headers = Headers(scope=scope)
authorization = headers.get("authorization")
if not authorization:
return False
scheme, _, token = authorization.partition(" ")
if not token or scheme.lower() != "basic":
return False
token = token.strip()
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
logger.debug("Rejecting malformed basic auth header")
return False
username, sep, password = decoded.partition(":")
if not sep:
return False
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
password, self.password
)
async def _send_http_unauthorized(self, send) -> None:
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def _send_websocket_unauthorized(self, send) -> None:
await send(
{
"type": "websocket.http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "websocket.http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def __call__(self, scope, receive, send) -> None:
scope_type = scope["type"]
if scope_type not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
if self._is_authorized(scope):
await self.app(scope, receive, send)
return
if scope_type == "http":
await self._send_http_unauthorized(send)
return
await self._send_websocket_unauthorized(send)
def add_optional_basic_auth_middleware(app, settings) -> None:
"""Enable app-wide basic auth when configured via environment variables."""
if not settings.basic_auth_enabled:
return
app.add_middleware(
BasicAuthMiddleware,
username=settings.basic_auth_username,
password=settings.basic_auth_password,
)

View File

@@ -80,3 +80,41 @@ class TestBLEPinRequirement:
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
assert s.ble_pin == "123456"
class TestBasicAuthConfiguration:
"""Ensure basic auth credentials are configured as a pair."""
def test_basic_auth_disabled_by_default(self):
s = Settings(serial_port="", tcp_host="", ble_address="")
assert s.basic_auth_enabled is False
def test_basic_auth_enabled_when_both_credentials_are_set(self):
s = Settings(
serial_port="",
tcp_host="",
ble_address="",
basic_auth_username="mesh",
basic_auth_password="secret",
)
assert s.basic_auth_enabled is True
def test_basic_auth_requires_password_with_username(self):
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
Settings(
serial_port="",
tcp_host="",
ble_address="",
basic_auth_username="mesh",
basic_auth_password="",
)
def test_basic_auth_requires_username_with_password(self):
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
Settings(
serial_port="",
tcp_host="",
ble_address="",
basic_auth_username="",
basic_auth_password="secret",
)

View File

@@ -3,7 +3,13 @@ import logging
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
from app.frontend_static import (
ASSET_CACHE_CONTROL,
INDEX_CACHE_CONTROL,
STATIC_FILE_CACHE_CONTROL,
register_frontend_missing_fallback,
register_frontend_static_routes,
)
def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
@@ -57,10 +63,12 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
root_response = client.get("/")
assert root_response.status_code == 200
assert "index page" in root_response.text
assert root_response.headers["cache-control"] == INDEX_CACHE_CONTROL
manifest_response = client.get("/site.webmanifest")
assert manifest_response.status_code == 200
assert manifest_response.headers["content-type"].startswith("application/manifest+json")
assert manifest_response.headers["cache-control"] == "no-store"
manifest = manifest_response.json()
assert manifest["start_url"] == "http://testserver/"
assert manifest["scope"] == "http://testserver/"
@@ -71,14 +79,22 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
file_response = client.get("/robots.txt")
assert file_response.status_code == 200
assert file_response.text == "User-agent: *"
assert file_response.headers["cache-control"] == STATIC_FILE_CACHE_CONTROL
explicit_index_response = client.get("/index.html")
assert explicit_index_response.status_code == 200
assert "index page" in explicit_index_response.text
assert explicit_index_response.headers["cache-control"] == INDEX_CACHE_CONTROL
missing_response = client.get("/channel/some-route")
assert missing_response.status_code == 200
assert "index page" in missing_response.text
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
asset_response = client.get("/assets/app.js")
assert asset_response.status_code == 200
assert "console.log('ok');" in asset_response.text
assert asset_response.headers["cache-control"] == ASSET_CACHE_CONTROL
def test_webmanifest_uses_forwarded_origin_headers(tmp_path):

View File

@@ -0,0 +1,13 @@
"""Tests for direct-serve HTTP quality features such as gzip compression."""
from fastapi.testclient import TestClient
from app.main import app
def test_openapi_json_is_gzipped_when_client_accepts_gzip():
with TestClient(app) as client:
response = client.get("/openapi.json", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"

98
tests/test_security.py Normal file
View File

@@ -0,0 +1,98 @@
"""Tests for optional app-wide HTTP Basic authentication."""
from __future__ import annotations
import base64
import pytest
from fastapi import FastAPI, WebSocket
from fastapi.testclient import TestClient
from starlette.testclient import WebSocketDenialResponse
from app.config import Settings
from app.security import add_optional_basic_auth_middleware
def _auth_header(username: str, password: str) -> dict[str, str]:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return {"Authorization": f"Basic {token}"}
def _build_app(*, username: str = "", password: str = "") -> FastAPI:
settings = Settings(
serial_port="",
tcp_host="",
ble_address="",
basic_auth_username=username,
basic_auth_password=password,
)
app = FastAPI()
add_optional_basic_auth_middleware(app, settings)
@app.get("/protected")
async def protected():
return {"ok": True}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
await websocket.accept()
await websocket.send_json({"ok": True})
await websocket.close()
return app
def test_http_request_is_denied_without_basic_auth_credentials():
app = _build_app(username="mesh", password="secret")
with TestClient(app) as client:
response = client.get("/protected")
assert response.status_code == 401
assert response.json() == {"detail": "Unauthorized"}
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
assert response.headers["cache-control"] == "no-store"
def test_http_request_is_allowed_with_valid_basic_auth_credentials():
app = _build_app(username="mesh", password="secret")
with TestClient(app) as client:
response = client.get("/protected", headers=_auth_header("mesh", "secret"))
assert response.status_code == 200
assert response.json() == {"ok": True}
def test_http_request_accepts_case_insensitive_basic_auth_scheme():
app = _build_app(username="mesh", password="secret")
header = _auth_header("mesh", "secret")
header["Authorization"] = header["Authorization"].replace("Basic", "basic")
with TestClient(app) as client:
response = client.get("/protected", headers=header)
assert response.status_code == 200
assert response.json() == {"ok": True}
def test_websocket_handshake_is_denied_without_basic_auth_credentials():
app = _build_app(username="mesh", password="secret")
with TestClient(app) as client:
with pytest.raises(WebSocketDenialResponse) as exc_info:
with client.websocket_connect("/ws"):
pass
response = exc_info.value
assert response.status_code == 401
assert response.json() == {"detail": "Unauthorized"}
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
def test_websocket_handshake_is_allowed_with_valid_basic_auth_credentials():
app = _build_app(username="mesh", password="secret")
with TestClient(app) as client:
with client.websocket_connect("/ws", headers=_auth_header("mesh", "secret")) as websocket:
assert websocket.receive_json() == {"ok": True}