forked from iarv/meshcore-hub
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741dd3ce84 | ||
|
|
0a12f389df | ||
|
|
8240c2fd57 |
@@ -458,6 +458,7 @@ Key variables:
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
@@ -376,6 +376,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add lat lon columns to nodes
|
||||
|
||||
Revision ID: 4e2e787a1660
|
||||
Revises: aa1162502616
|
||||
Create Date: 2026-01-09 20:04:04.273741+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4e2e787a1660"
|
||||
down_revision: Union[str, None] = "aa1162502616"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_column("lon")
|
||||
batch_op.drop_column("lat")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -252,6 +252,7 @@ services:
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_contact(
|
||||
# Device uses 'adv_name' for the advertised name
|
||||
name = payload.get("adv_name") or payload.get("name")
|
||||
|
||||
# GPS coordinates (optional)
|
||||
lat = payload.get("adv_lat")
|
||||
lon = payload.get("adv_lon")
|
||||
|
||||
logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}")
|
||||
|
||||
# Device uses numeric 'type' field, convert to string
|
||||
@@ -73,6 +77,11 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
# Update GPS coordinates if provided
|
||||
if lat is not None:
|
||||
node.lat = lat
|
||||
if lon is not None:
|
||||
node.lon = lon
|
||||
# Do NOT update last_seen for contact sync - only advertisement events
|
||||
# should update last_seen since that's when the node was actually seen
|
||||
else:
|
||||
@@ -84,6 +93,8 @@ def handle_contact(
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=None, # Will be set when we receive an advertisement
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -253,6 +253,12 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
|
||||
)
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
@@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
lat: GPS latitude coordinate (if available)
|
||||
lon: GPS longitude coordinate (if available)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -57,6 +59,14 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
lat: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
lon: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tags: Mapped[list["NodeTag"]] = relationship(
|
||||
|
||||
@@ -62,6 +62,8 @@ class NodeRead(BaseModel):
|
||||
last_seen: Optional[datetime] = Field(
|
||||
default=None, description="Last activity timestamp"
|
||||
)
|
||||
lat: Optional[float] = Field(default=None, description="GPS latitude coordinate")
|
||||
lon: Optional[float] = Field(default=None, description="GPS longitude coordinate")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Record update timestamp")
|
||||
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
|
||||
|
||||
@@ -50,6 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
admin_enabled: bool | None = None,
|
||||
network_name: str | None = None,
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
@@ -67,6 +68,7 @@ def create_app(
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
admin_enabled: Enable admin interface at /a/
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
@@ -96,6 +98,9 @@ def create_app(
|
||||
# Store configuration in app state (use args if provided, else settings)
|
||||
app.state.api_url = api_url or settings.api_base_url
|
||||
app.state.api_key = api_key or settings.api_key
|
||||
app.state.admin_enabled = (
|
||||
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
|
||||
)
|
||||
app.state.network_name = network_name or settings.network_name
|
||||
app.state.network_city = network_city or settings.network_city
|
||||
app.state.network_country = network_country or settings.network_country
|
||||
|
||||
@@ -9,6 +9,7 @@ from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
@@ -21,5 +22,6 @@ web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
|
||||
31
src/meshcore_hub/web/routes/admin.py
Normal file
31
src/meshcore_hub/web/routes/admin.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Admin page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/a", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home(request: Request) -> HTMLResponse:
|
||||
"""Render the admin page with OAuth2Proxy user info."""
|
||||
# Check if admin interface is enabled
|
||||
if not getattr(request.app.state, "admin_enabled", False):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Extract OAuth2Proxy headers
|
||||
context["auth_user"] = request.headers.get("X-Forwarded-User")
|
||||
context["auth_groups"] = request.headers.get("X-Forwarded-Groups")
|
||||
context["auth_email"] = request.headers.get("X-Forwarded-Email")
|
||||
context["auth_username"] = request.headers.get("X-Forwarded-Preferred-Username")
|
||||
|
||||
return templates.TemplateResponse("admin.html", context)
|
||||
38
src/meshcore_hub/web/templates/admin.html
Normal file
38
src/meshcore_hub/web/templates/admin.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Authenticated User</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-64">X-Forwarded-User</th>
|
||||
<td>{{ auth_user or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Preferred-Username</th>
|
||||
<td>{{ auth_username or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Email</th>
|
||||
<td>{{ auth_email or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Groups</th>
|
||||
<td>{{ auth_groups or '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user