mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Initial scaffold: navi-backend + navi-traffic (extraction #1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
dfd1d38405
15 changed files with 491 additions and 0 deletions
17
backend/.gitignore
vendored
Normal file
17
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Virtualenv
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Test / tooling caches
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Local env files (secrets live in /etc/navi-backend/*.env on the host)
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
21
backend/LICENSE
Normal file
21
backend/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Matt Johnson / Echo6
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
61
backend/README.md
Normal file
61
backend/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# navi-backend
|
||||||
|
|
||||||
|
Monorepo of small, single-responsibility HTTP services extracted from the `recon`
|
||||||
|
codebase as part of the **recon ↔ Navi decoupling** project. Each service owns a
|
||||||
|
slice of the `/api/*` surface that `navi.echo6.co` depends on, runs behind the
|
||||||
|
existing Caddy/Authentik edge, and is fronted by the `navi.echo6.co` nginx vhost.
|
||||||
|
|
||||||
|
See `HANDOFF-recon-navi-decoupling-v3.md` for the full plan. This repo is
|
||||||
|
extraction **#1**: `navi-traffic`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
navi-backend/
|
||||||
|
├── shared/ # cross-service helpers, imported by every service
|
||||||
|
│ ├── auth.py # get_user_id(req), require_auth decorator (Authentik header)
|
||||||
|
│ └── admin_info.py # build_info_response(), mask_key(), time_dependency()
|
||||||
|
├── services/
|
||||||
|
│ └── navi_traffic/ # extraction #1 — TomTom traffic tile proxy (:8421)
|
||||||
|
│ ├── app.py # Flask factory (create_app) + gunicorn entry
|
||||||
|
│ ├── traffic.py # /api/traffic/flow/<z>/<x>/<y>.png (ported from recon)
|
||||||
|
│ ├── admin.py # /api/admin/navi-traffic/info (§4.5 admin convention)
|
||||||
|
│ └── tests/
|
||||||
|
└── deploy/
|
||||||
|
├── systemd/navi-traffic.service
|
||||||
|
└── nginx/navi-traffic.conf.snippet
|
||||||
|
```
|
||||||
|
|
||||||
|
Service directories use an underscore (`navi_traffic`) so they're importable
|
||||||
|
Python packages; the **service name** stays `navi-traffic` (hyphen) in systemd,
|
||||||
|
nginx, and the admin-info `service` field.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Single workspace, single virtualenv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/bin/pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest services/navi_traffic/tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run (local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOMTOM_API_KEY=... .venv/bin/gunicorn 'services.navi_traffic.app:create_app()' \
|
||||||
|
--bind 127.0.0.1:8421 --workers 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## The admin-info convention (§4.5)
|
||||||
|
|
||||||
|
Every service exposes `GET /api/admin/<service-name>/info`, gated by `require_auth`,
|
||||||
|
returning a uniform shape: `service`, `version` (git SHA), `port`, `config`, `env`
|
||||||
|
(names + masked values), `dependencies` (upstream health checks), `filesystem`,
|
||||||
|
`runtime` (uptime / request count / last error). No aggregator — a future admin
|
||||||
|
panel fans out to each service in parallel.
|
||||||
44
backend/deploy/nginx/navi-traffic.conf.snippet
Normal file
44
backend/deploy/nginx/navi-traffic.conf.snippet
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# =============================================================================
|
||||||
|
# navi-traffic — nginx integration for the navi.echo6.co vhost
|
||||||
|
#
|
||||||
|
# Two parts, both required. Behavior-neutral with respect to the existing
|
||||||
|
# `location /api/` proxy: only /api/traffic/* requests change path (they now go
|
||||||
|
# to the navi-traffic service on :8421 and get an nginx cache layer).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# (A) NEW FILE: /etc/nginx/conf.d/traffic-cache.conf
|
||||||
|
# http{} context (conf.d/* is included there). Defines the proxy_cache zone
|
||||||
|
# consumed by section (B). Mirrors the existing satellite-cache.conf and
|
||||||
|
# dem-cache.conf pattern verified in Phase 0.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
proxy_cache_path /mnt/nav/tile-cache/traffic levels=1:2
|
||||||
|
keys_zone=traffic_cache:10m
|
||||||
|
max_size=10g
|
||||||
|
inactive=24h
|
||||||
|
use_temp_path=off;
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# (B) EDIT: /etc/nginx/sites-available/navi.echo6.co
|
||||||
|
# Add the location block below INSIDE the existing
|
||||||
|
# server { server_name navi.echo6.co; ... }
|
||||||
|
# block, placed BEFORE the existing `location /api/ { ... }` block.
|
||||||
|
#
|
||||||
|
# nginx selects the longest matching prefix, so `/api/traffic/` wins over
|
||||||
|
# `/api/` regardless of order — placing it first keeps the file readable and
|
||||||
|
# makes the precedence obvious to a human.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
location /api/traffic/ {
|
||||||
|
proxy_pass http://127.0.0.1:8421;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 10s;
|
||||||
|
|
||||||
|
# Cache successful tiles for 120s (matches the handler's Cache-Control).
|
||||||
|
proxy_cache traffic_cache;
|
||||||
|
proxy_cache_valid 200 120s;
|
||||||
|
proxy_cache_use_stale error timeout updating;
|
||||||
|
add_header X-Cache-Status $upstream_cache_status;
|
||||||
|
}
|
||||||
15
backend/deploy/systemd/navi-traffic.service
Normal file
15
backend/deploy/systemd/navi-traffic.service
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
[Unit]
|
||||||
|
Description=navi-traffic — TomTom traffic tile proxy (Echo6 navi-backend, extraction #1)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=zvx
|
||||||
|
WorkingDirectory=/home/zvx/projects/repos/navi-backend
|
||||||
|
EnvironmentFile=/etc/navi-backend/navi-traffic.env
|
||||||
|
ExecStart=/home/zvx/projects/repos/navi-backend/.venv/bin/gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
backend/pyproject.toml
Normal file
25
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "navi-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Echo6 navi-backend monorepo — Navi API services extracted from recon (decoupling project)."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
# Single workspace, single .venv. pytest is included so `pip install -e .` is the
|
||||||
|
# only setup step needed before running the test suite.
|
||||||
|
dependencies = [
|
||||||
|
"Flask>=3.0",
|
||||||
|
"gunicorn>=21",
|
||||||
|
"requests>=2.31",
|
||||||
|
"pytest>=8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["shared*", "services*"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["services"]
|
||||||
0
backend/services/navi_traffic/__init__.py
Normal file
0
backend/services/navi_traffic/__init__.py
Normal file
42
backend/services/navi_traffic/admin.py
Normal file
42
backend/services/navi_traffic/admin.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""navi-traffic admin-info endpoint (handoff §4.5).
|
||||||
|
|
||||||
|
``GET /api/admin/navi-traffic/info`` — Authentik-gated, read-only. Exposes the
|
||||||
|
service's version, port, masked env, upstream health, and runtime counters.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, current_app
|
||||||
|
|
||||||
|
from shared.auth import require_auth
|
||||||
|
from shared.admin_info import build_info_response, mask_key, time_dependency
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
PORT = 8421
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/admin/navi-traffic/info')
|
||||||
|
@require_auth
|
||||||
|
def navi_traffic_info():
|
||||||
|
metrics = current_app.config['METRICS']
|
||||||
|
info = build_info_response(
|
||||||
|
service='navi-traffic',
|
||||||
|
version=current_app.config.get('VERSION', 'unknown'),
|
||||||
|
port=PORT,
|
||||||
|
config={},
|
||||||
|
env=[{
|
||||||
|
'name': 'TOMTOM_API_KEY',
|
||||||
|
'value': mask_key(os.environ.get('TOMTOM_API_KEY')),
|
||||||
|
}],
|
||||||
|
dependencies=[
|
||||||
|
time_dependency('tomtom-api', 'https://api.tomtom.com/'),
|
||||||
|
],
|
||||||
|
filesystem=[],
|
||||||
|
runtime={
|
||||||
|
'uptime_s': round(time.time() - metrics['start_time'], 1),
|
||||||
|
'request_count': metrics['request_count'],
|
||||||
|
'last_error_at': metrics['last_error_at'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonify(info)
|
||||||
54
backend/services/navi_traffic/app.py
Normal file
54
backend/services/navi_traffic/app.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""navi-traffic Flask application factory + gunicorn entry.
|
||||||
|
|
||||||
|
Gunicorn entry:
|
||||||
|
gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from . import traffic, admin
|
||||||
|
|
||||||
|
|
||||||
|
def _git_sha():
|
||||||
|
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
||||||
|
try:
|
||||||
|
sha = subprocess.check_output(
|
||||||
|
['git', 'rev-parse', '--short', 'HEAD'],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
return sha or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||||
|
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
||||||
|
# is its own process); they are indicative, not cluster-wide totals.
|
||||||
|
app.config['VERSION'] = _git_sha()
|
||||||
|
app.config['METRICS'] = {
|
||||||
|
'start_time': time.time(),
|
||||||
|
'request_count': 0,
|
||||||
|
'last_error_at': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _count_request():
|
||||||
|
app.config['METRICS']['request_count'] += 1
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _track_errors(response):
|
||||||
|
if response.status_code >= 500:
|
||||||
|
app.config['METRICS']['last_error_at'] = time.strftime(
|
||||||
|
'%Y-%m-%dT%H:%M:%SZ', time.gmtime()
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.register_blueprint(traffic.bp)
|
||||||
|
app.register_blueprint(admin.bp)
|
||||||
|
return app
|
||||||
0
backend/services/navi_traffic/tests/__init__.py
Normal file
0
backend/services/navi_traffic/tests/__init__.py
Normal file
75
backend/services/navi_traffic/tests/test_traffic.py
Normal file
75
backend/services/navi_traffic/tests/test_traffic.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Tests for the navi-traffic flow proxy.
|
||||||
|
|
||||||
|
Covers all four status paths (200 / 503 / 502 / 504) and the success-path
|
||||||
|
headers, plus the mask_key convention. The TomTom upstream is mocked — no
|
||||||
|
network calls.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.navi_traffic.app import create_app
|
||||||
|
from shared.admin_info import mask_key
|
||||||
|
|
||||||
|
UPSTREAM = (
|
||||||
|
'https://api.tomtom.com/maps/orbis/traffic/tile/flow/'
|
||||||
|
'10/200/400.png?key=abcdef123456&apiVersion=1&style=light'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
monkeypatch.setenv('TOMTOM_API_KEY', 'abcdef123456')
|
||||||
|
app = create_app()
|
||||||
|
app.testing = True
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_success_returns_png_with_cache_headers(client):
|
||||||
|
fake = MagicMock(status_code=200, content=b'\x89PNG\r\n fake tile bytes')
|
||||||
|
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||||
|
return_value=fake) as mock_get:
|
||||||
|
resp = client.get('/api/traffic/flow/10/200/400.png')
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers['Content-Type'] == 'image/png'
|
||||||
|
assert resp.headers['Cache-Control'] == 'public, max-age=120'
|
||||||
|
assert resp.data == b'\x89PNG\r\n fake tile bytes'
|
||||||
|
# exact upstream URL + params + 10s timeout (behavior-neutral port)
|
||||||
|
assert mock_get.call_args.args[0] == UPSTREAM
|
||||||
|
assert mock_get.call_args.kwargs.get('timeout') == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_missing_key_returns_503(monkeypatch):
|
||||||
|
monkeypatch.delenv('TOMTOM_API_KEY', raising=False)
|
||||||
|
app = create_app()
|
||||||
|
app.testing = True
|
||||||
|
resp = app.test_client().get('/api/traffic/flow/1/2/3.png')
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert b'not configured' in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_upstream_non_200_returns_502(client):
|
||||||
|
fake = MagicMock(status_code=403, content=b'')
|
||||||
|
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||||
|
return_value=fake):
|
||||||
|
resp = client.get('/api/traffic/flow/1/2/3.png')
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert b'Upstream error' in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_exception_returns_504(client):
|
||||||
|
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||||
|
side_effect=Exception('connection reset')):
|
||||||
|
resp = client.get('/api/traffic/flow/1/2/3.png')
|
||||||
|
assert resp.status_code == 504
|
||||||
|
assert b'Upstream timeout' in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_key_recon_pattern():
|
||||||
|
# matches recon's api_keys_admin._mask_key: first4 + '...' + last4
|
||||||
|
assert mask_key('') is None
|
||||||
|
assert mask_key(None) is None
|
||||||
|
assert mask_key('abcdefgh') == '****' # 8 chars, fully masked
|
||||||
|
assert mask_key('tk_1234567890ABCDEF') == 'tk_1...CDEF'
|
||||||
|
assert mask_key('TOMTOM_KEY_abcdefXYZ') == 'TOMT...fXYZ'
|
||||||
32
backend/services/navi_traffic/traffic.py
Normal file
32
backend/services/navi_traffic/traffic.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""TomTom traffic flow tile proxy.
|
||||||
|
|
||||||
|
Behavior-neutral port of recon ``lib/api.py:1212-1229`` (the PR #4 Orbis flow
|
||||||
|
proxy). Same upstream URL, same buffered passthrough, same headers, same error
|
||||||
|
codes. The only purpose is to keep ``TOMTOM_API_KEY`` server-side.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
from flask import Blueprint, make_response
|
||||||
|
|
||||||
|
bp = Blueprint('traffic', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/traffic/flow/<int:z>/<int:x>/<int:y>.png')
|
||||||
|
def api_traffic_flow(z, x, y):
|
||||||
|
"""Proxy TomTom traffic flow tiles to hide API key from frontend."""
|
||||||
|
key = os.environ.get('TOMTOM_API_KEY')
|
||||||
|
if not key:
|
||||||
|
return 'Traffic service not configured', 503
|
||||||
|
# Orbis Maps Traffic API (migrated from classic)
|
||||||
|
url = f'https://api.tomtom.com/maps/orbis/traffic/tile/flow/{z}/{x}/{y}.png?key={key}&apiVersion=1&style=light'
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(url, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return 'Upstream error', 502
|
||||||
|
r = make_response(resp.content)
|
||||||
|
r.headers['Content-Type'] = 'image/png'
|
||||||
|
r.headers['Cache-Control'] = 'public, max-age=120'
|
||||||
|
return r
|
||||||
|
except Exception:
|
||||||
|
return 'Upstream timeout', 504
|
||||||
0
backend/shared/__init__.py
Normal file
0
backend/shared/__init__.py
Normal file
76
backend/shared/admin_info.py
Normal file
76
backend/shared/admin_info.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Helpers for the uniform ``/api/admin/<service>/info`` endpoint (handoff §4.5).
|
||||||
|
|
||||||
|
Every navi-backend service exposes one admin-info endpoint with the same shape so
|
||||||
|
a future admin panel can fan out to all of them. These helpers keep each
|
||||||
|
service's handler down to a few lines.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def mask_key(value):
|
||||||
|
"""Mask a secret for display, matching recon's ``api_keys_admin._mask_key``.
|
||||||
|
|
||||||
|
Pattern: ``first4 + '...' + last4`` (e.g. ``"tk_1...CDEF"``). Values of 8
|
||||||
|
chars or fewer are fully masked as ``'****'`` so short strings don't reveal
|
||||||
|
their endpoints. Returns ``None`` for empty/None input.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if len(value) <= 8:
|
||||||
|
return '****'
|
||||||
|
return value[:4] + '...' + value[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def time_dependency(name, url, method='HEAD', timeout=5):
|
||||||
|
"""Health-check an upstream dependency.
|
||||||
|
|
||||||
|
Returns ``{name, status, latency_ms}`` (plus ``error`` on failure), where
|
||||||
|
``status`` is ``"ok"`` for any response below HTTP 500, else ``"error"``.
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
resp = requests.request(method, url, timeout=timeout)
|
||||||
|
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||||
|
if resp.status_code < 500:
|
||||||
|
return {'name': name, 'status': 'ok', 'latency_ms': latency_ms}
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'status': 'error',
|
||||||
|
'latency_ms': latency_ms,
|
||||||
|
'error': f'HTTP {resp.status_code}',
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'status': 'error',
|
||||||
|
'latency_ms': latency_ms,
|
||||||
|
'error': str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_info_response(service, version, port, config, env, dependencies,
|
||||||
|
filesystem, runtime):
|
||||||
|
"""Assemble the uniform admin-info dict (handoff §4.5).
|
||||||
|
|
||||||
|
- ``service`` short name, e.g. ``"navi-traffic"``
|
||||||
|
- ``version`` git SHA (or semver if tagged)
|
||||||
|
- ``port`` listening port
|
||||||
|
- ``config`` loaded config dict, secrets masked
|
||||||
|
- ``env`` list of ``{name, value}`` with values masked via mask_key
|
||||||
|
- ``dependencies`` list of time_dependency() results
|
||||||
|
- ``filesystem`` list of /mnt/nav paths with existence + read checks
|
||||||
|
- ``runtime`` ``{uptime_s, request_count, last_error_at}``
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'service': service,
|
||||||
|
'version': version,
|
||||||
|
'port': port,
|
||||||
|
'config': config,
|
||||||
|
'env': env,
|
||||||
|
'dependencies': dependencies,
|
||||||
|
'filesystem': filesystem,
|
||||||
|
'runtime': runtime,
|
||||||
|
}
|
||||||
29
backend/shared/auth.py
Normal file
29
backend/shared/auth.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Authentication helpers shared across every navi-backend service.
|
||||||
|
|
||||||
|
Auth is enforced at the Caddy/Authentik edge (forward_auth). By the time a
|
||||||
|
request reaches a service it carries an ``X-Authentik-Username`` header iff the
|
||||||
|
user is authenticated. These helpers read that header — they do not perform
|
||||||
|
auth themselves, they assert that the edge already did.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_id(req):
|
||||||
|
"""Return the Authentik-supplied username for a request, or None if absent."""
|
||||||
|
return req.headers.get('X-Authentik-Username')
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(fn):
|
||||||
|
"""Reject requests with no ``X-Authentik-Username`` header (401 JSON).
|
||||||
|
|
||||||
|
Use on endpoints that must never be reachable unauthenticated even if the
|
||||||
|
edge is misconfigured or bypassed (e.g. the internal nginx :8440 path).
|
||||||
|
"""
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not get_user_id(request):
|
||||||
|
return jsonify({'error': 'authentication required'}), 401
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
Loading…
Add table
Add a link
Reference in a new issue