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:
Matt Johnson 2026-05-21 22:11:10 -06:00
commit dfd1d38405
15 changed files with 491 additions and 0 deletions

17
backend/.gitignore vendored Normal file
View 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
View 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
View 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.

View 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;
}

View 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
View 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"]

View 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)

View 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

View 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'

View 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

View file

View 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
View 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