Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@ __pycache__/
.pytest_cache/
.venv/
node_modules/
dist/
frontend/dist/
backend/.pytest_cache/
coverage/
.env
.env.*
!.env.example
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.5.3--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.5.4--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down Expand Up @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external

## Current Alpha Status

RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.3-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.5.4-beta**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -248,3 +248,13 @@ npm run build
## License

RouteForge is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later).


## Security baseline

For production polish and selfhosting hardening guidance, see:

- `docs/operations/security.md`
- `docs/operations/release-checklist.md`

In the standard Docker setup, API calls are same-origin via frontend nginx (`/api` proxy). CORS is primarily needed for split frontend/backend deployments.
24 changes: 24 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Release Notes

## v0.5.4-beta

**Production Polish & Security Baseline**

### Highlights

- Security headers for frontend Nginx
- Improved same-origin proxy security guidance
- Container hardening review
- Safer environment and secrets documentation
- Logging baseline improvements
- Security operations documentation
- Release checklist documentation
- Optional security warnings in system status

### Known limitations

- No authentication yet
- No multi-user support yet
- No advanced RBAC yet
- No automated release pipeline yet

---


## Licensing

Expand Down
13 changes: 12 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
FROM python:3.12-slim

WORKDIR /app

RUN useradd --create-home --shell /usr/sbin/nologin routeforge

COPY pyproject.toml ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./alembic.ini
RUN pip install --no-cache-dir .

RUN pip install --no-cache-dir . \
&& mkdir -p /app/data \
&& chown -R routeforge:routeforge /app

USER routeforge

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
2 changes: 1 addition & 1 deletion backend/app/api/routes_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

@router.get('/health')
def health() -> dict:
return {"status": "ok", "version": "v0.5.3-beta", "database": get_database_status(engine).get("status", "unknown")}
return {"status": "ok", "version": "v0.5.4-beta", "database": get_database_status(engine).get("status", "unknown")}
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.5.3-beta',
'version': 'v0.5.4-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
4 changes: 3 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ class Settings(BaseSettings):
ripestat_retry_backoff_seconds: float = 0.5
ripestat_use_stale_cache_on_error: bool = True
cache_ttl_seconds: int = Field(default=900, validation_alias="RIPESTAT_CACHE_TTL_SECONDS")
cors_origins: str = "http://192.168.58.167:3000,http://127.0.0.1:3000"
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
demo_mode: bool = Field(default=False, validation_alias="ROUTEFORGE_DEMO_MODE")
log_level: str = "INFO"
postgres_password: str = Field(default="", validation_alias="POSTGRES_PASSWORD")

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

Expand Down
10 changes: 9 additions & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,18 @@ def get_database_status(engine: Engine | None) -> dict:
return payload


def _security_warnings() -> list[str]:
warnings: list[str] = []
if settings.postgres_password == "change-me":
warnings.append("POSTGRES_PASSWORD uses the default example value. Change it before production use.")
return warnings


def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
"version": "v0.5.3-beta",
"version": "v0.5.4-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
Expand All @@ -119,6 +126,7 @@ def build_system_status(engine: Engine | None) -> dict:
"retry_backoff_seconds": settings.ripestat_retry_backoff_seconds,
"use_stale_cache_on_error": settings.ripestat_use_stale_cache_on_error,
},
"security_warnings": _security_warnings(),
"features": {
"asn_check": True,
"prefix_check": True,
Expand Down
19 changes: 18 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import logging

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

Expand All @@ -7,9 +9,13 @@
from app.api.routes_reports import router as reports_router
from app.api.routes_system import router as system_router
from app.config import settings
from app.core.system_status import database_type_from_url
from app.database import Base, engine

app = FastAPI(title="RouteForge", version="0.5.3")
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")

app = FastAPI(title="RouteForge", version="0.5.4")

app.add_middleware(
CORSMiddleware,
Expand All @@ -25,6 +31,17 @@ def startup() -> None:
if settings.database_url.startswith("sqlite"):
Base.metadata.create_all(bind=engine)

logger.info(
"RouteForge startup: version=%s mode=%s database_type=%s read_only=%s ripestat_cache_ttl=%s ripestat_timeout=%s ripestat_retries=%s",
app.version,
"DEMO" if settings.demo_mode else "LIVE",
database_type_from_url(settings.database_url),
True,
settings.cache_ttl_seconds,
settings.ripestat_timeout_seconds,
settings.ripestat_max_retries,
)


app.include_router(health_router)
app.include_router(checks_router)
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
version = "0.5.3"
version = "0.5.4"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
assert payload.get('version') == 'v0.5.3-beta'
assert payload.get('version') == 'v0.5.4-beta'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
Expand Down
30 changes: 30 additions & 0 deletions docs/operations/release-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Release Checklist

## Before release

- `git pull`
- Run backend tests (`cd backend && pytest -q`)
- Run frontend production build (`cd frontend && npm run build`)
- Validate Compose (`docker compose config` and production config)
- Check system status endpoint (`/api/system/status`)
- Check migration status visibility (`database.schema_version`, `migration_head`, `migration_status`)

## Tagging

- `git tag -a vX.Y.Z[-beta] -m "RouteForge vX.Y.Z[-beta]"`
- `git push origin vX.Y.Z[-beta]`

## GitHub Release

- Use release title matching the version and sprint scope
- Enable **prerelease** checkbox for beta versions
- Include structured release notes with highlights and known limitations

## Post-release smoke test

- `GET /health`
- `GET /api/system/info`
- `GET /api/system/status`
- Basic ASN check
- Basic Prefix check
- Export summary from report history
51 changes: 51 additions & 0 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Security Baseline

## Read-only safety model

RouteForge is read-only by design. It validates routing state and preflight conditions but does not push configuration changes to external systems.

## Reverse proxy / HTTPS recommendation

Use RouteForge behind a reverse proxy with TLS termination in production (for example Caddy, Traefik, or Nginx). Keep plain HTTP only for local lab usage.

## Secrets and .env handling

- Copy `.env.example` to `.env` and set real values before production use.
- Never commit `.env` files to git.
- Change `POSTGRES_PASSWORD` from the example default.

## Default password warning

`/api/system/status` exposes `security_warnings` when a risky default is detected (currently: `POSTGRES_PASSWORD=change-me`). No secret values are returned.

## CORS guidance

In the standard Docker setup, frontend and API are same-origin via nginx (`/api` proxy). CORS is mainly relevant for split deployments where frontend and backend are served from different origins.

## Security headers

Frontend nginx sets baseline headers:

- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer-when-downgrade`
- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
- A conservative initial Content-Security-Policy for SPA usage

## Container hardening

- Backend container runs as non-root user `routeforge`.
- Backend image keeps only runtime-relevant files.
- Frontend image is multi-stage (build + runtime).
- Nginx container still runs with default upstream behavior; strict non-root nginx runtime can be added later as a dedicated hardening step.

## What RouteForge does not do

- no ROA creation
- no RIPE DB writes
- no router deployment

## Current limitations

- no authentication yet
- no multi-user support yet
6 changes: 6 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ server {
root /usr/share/nginx/html;
index index.html;

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;

location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
Expand Down
5 changes: 3 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
"version": "0.5.3",
"version": "0.5.4",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export default function App() {
getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch(() => setSystemStatusError('System status could not be loaded.'))
}, [])

const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.5.3-beta · read-only preflight checks', [system])
const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.5.4-beta · read-only preflight checks', [system])
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active]
const proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'

return <Layout active={active} onNav={setActive} systemLine={systemLine} title={title} demoMode={Boolean(system?.demo_mode)}>
{active === 'dashboard' && <section className='space-y-4'>
<article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.5.3-beta</h1><p className='mt-2 text-slate-600'>Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.</p></article>
<article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.5.4-beta</h1><p className='mt-2 text-slate-600'>Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.</p></article>
<article className='rf-card p-4'><h3 className='font-semibold'>System Health</h3>{systemStatusError ? <p className='text-sm text-rose-700 mt-2'>{systemStatusError}</p> : <div className='mt-2 grid gap-2 text-sm md:grid-cols-4'><div>Status: <b>{systemStatus?.status || 'unknown'}</b></div><div>Mode: <b>{systemStatus?.mode || 'unknown'}</b></div><div>Database: <b>{systemStatus?.database?.status || 'unknown'}</b></div><div>Version: <b>{systemStatus?.version || 'unknown'}</b></div></div>}</article>
</section>}
{active === 'asn' && <AsnCheckForm />}
Expand All @@ -44,10 +44,11 @@ export default function App() {
<div>Version: <b>{systemStatus.version}</b></div><div>Mode: <b>{systemStatus.mode}</b></div><div>Read-only: <b>{String(systemStatus.read_only)}</b></div><div>Demo mode: <b>{String(systemStatus.demo_mode)}</b></div>
<div>API Proxy: <b>{proxyStatus}</b></div><div>Database: <b>{systemStatus.database?.status || 'unknown'}</b></div><div>Schema Version: <b>{systemStatus.database?.schema_version || 'unknown'}</b></div><div>Migration Head: <b>{systemStatus.database?.migration_head || 'unknown'}</b></div><div>Migration Status: <StatusBadge status={migrationStatus === 'up_to_date' ? 'OK' : migrationStatus === 'behind' ? 'WARNING' : migrationStatus === 'error' ? 'CRITICAL' : 'UNKNOWN'} /></div>
</article>
{systemStatus.security_warnings && systemStatus.security_warnings.length > 0 && <article className='rf-card p-4 border border-amber-300 bg-amber-50 text-amber-900 text-sm'><h3 className='font-semibold mb-2'>Security Warnings</h3><ul className='list-disc ml-5'>{systemStatus.security_warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></article>}
<article className='rf-card p-4 text-sm'><h3 className='font-semibold mb-2'>RIPEstat Settings</h3><pre>{JSON.stringify(systemStatus.ripestat, null, 2)}</pre></article>
<article className='rf-card p-4 text-sm'><h3 className='font-semibold mb-2'>Features</h3><pre>{JSON.stringify(systemStatus.features, null, 2)}</pre></article>
</>}
</section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p>RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.</p><p><b>Version:</b> v0.5.3-beta</p></section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p>RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.</p><p><b>Version:</b> v0.5.4-beta</p></section>}
</Layout>
}
2 changes: 1 addition & 1 deletion frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode }:
<div className='flex flex-wrap gap-2 text-xs font-semibold'>
<span className={`rounded-full border px-3 py-1 ${demoMode ? 'border-amber-300 bg-amber-50 text-amber-700' : 'border-emerald-300 bg-emerald-50 text-emerald-700'}`}>{demoMode ? 'DEMO' : 'LIVE'}</span>
<span className='rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-blue-700'>READ-ONLY</span>
<span className='rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-700'>v0.5.3-beta</span>
<span className='rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-700'>v0.5.4-beta</span>
</div>
</header>
<main className='space-y-4'>{children}</main>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ export type SystemStatus = {
api_proxy?: ApiProxyStatus
ripestat?: RipestatRuntimeSettings
features?: SystemFeatures
security_warnings?: string[]
}
Loading