Skip to content

Development

How the project is laid out, how to run it locally, and what is expected of a pull request.

For bug reports and feature requests see CONTRIBUTING.md.

Project layout

app.py                  Flask app, routes, CLI
core/                   Shared logic, one module per concern
agent/                  TMA - the Go agent for remote servers
tests/                  pytest suite
templates/
    index.html          SPA shell and side nav, plus the theme/pref bootstrap and the agent-switcher JS
    sections/           Top bar, stats bar
    tabs/               One file per tab
    modals/             Route, middleware, settings and other modals
static/
    css/app.css         Custom styles for the app shell (login.html and index.html each carry their own inline <style> block)
    css/tailwind.css    Generated from tailwind.input.css at image build - never edit it
    js/                 Application JS, one file per area
    vendor/             Third-party JS/CSS, bundled at image build
    sw.js               Service worker; openapi.yaml sits alongside it
docs/                   This VitePress site

core/

Shared logic lives here so it can be tested directly and imported without pulling in the Flask app. The modules form an acyclic graph, and it is worth keeping it that way:

env -> crypto, config -> agents_store -> settings -> auth, backups, notifications,
                                                     traefik, agents_http, geoip,
                                                     crowdsec, routes_build, git

certs and self_route stop at config and never reach settings. git also pulls agents_http and notifications; routes_build also pulls traefik.

ModuleOwns
envEnvironment-derived paths and constants, logger
cryptoFernet encryption for secrets at rest
configYAML read/write, Go-template preservation, path safety
agents_storeagents.yml persistence, secrets encrypted
settingsmanager.yml load/save, settings-derived paths
authlogin_required, csrf_protect, session and API-key checks
routes_buildTurning config into route objects, and merging them back
backupsTimestamped local backups and retention
gitGit backup: repo management, commits, pushes
traefikRead-only Traefik API client
agents_httpHTTP client for remote agents
notificationsIn-app log and webhook delivery
geoip, crowdsec, certs, self_routeFeature-specific helpers

Two conventions exist because breaking them caused real bugs:

  • Import mutable module state as a module, not a name. CONFIG_PATHS is rebound at runtime when a config file is created from the UI, so from core.env import CONFIG_PATHS captures a snapshot that never updates. Use env.CONFIG_PATHS.
  • Import config and settings under an alias (cfg_mod, settings_mod). config and settings are extremely common local variable names, and a local silently shadows the module.

static/js/

Classic scripts, loaded in order, no bundler and no ES modules. Over 200 functions are called from inline onclick= handlers in the templates, so every top-level function must stay a global. init.js holds the code that runs at load time; everything else is function declarations and self-contained state. It is loaded near the end, though not strictly last - check templates/index.html for the real order before assuming.

Tailwind purges what it cannot see

tailwind.config.js scans templates/**/*.html and static/js/**/*.js. Utility classes used only in JS-generated markup are purged if the file is not covered by those globs. Moving class-bearing markup to a new location means updating the glob - tests/test_assets.py fails if you forget.

Running locally

bash
pip install -r requirements.txt
./scripts/setup-assets.sh
TRAEFIK_API_URL=http://your-traefik:8080 CONFIG_PATH=config/dynamic.yml python3 app.py

static/vendor/ and static/css/tailwind.css are git-ignored and built at image build, so run setup-assets.sh once or the UI comes up unstyled. Re-run it after adding utility classes in a file Tailwind had not seen.

The UI is at http://localhost:5000. See Environment Variables for the full list, and CONTRIBUTING.md for the Docker build.

Tests

bash
pip install -r requirements-dev.txt
pytest              # Python suite
make lint           # ruff, undefined names are a hard failure
make coverage       # pytest with a coverage report
make agent-test     # go build, vet and test
make docs-dev       # this site, live

The suite runs against a temporary config directory and never touches a real Traefik or your own config. It runs on every pull request, along with ruff and the Go agent's build, vet and test; the coverage table lands in the workflow's job summary.

What is covered

AreaWhat it asserts
test_routes.pyRoute saves for HTTP, TCP and UDP; backend validation; the multi-backend safeguard; the mobile client contract, including that a backend edit preserves sticky, health checks and priority; comment preservation
test_presets.pySecurity-headers and streaming presets, the ownership ledger, refuse-to-overwrite
test_middlewares.pyMiddleware save and delete, unrelated middlewares preserved
test_backups.pyBackups created before a write and containing the previous content
test_auth.pyLogin, logout, CSRF rejection, unauthenticated access
test_core_*.pyEach core/ module directly, including git hardening and agent secret encryption
test_acme_paths.pySingle and multiple acme storage files
test_endpoints.pyEvery url_for() target resolves; no duplicate routes
test_assets.pyTailwind scans every file that emits utility classes
test_css.pyNo unshrinkable min-width/min-height floors in app.css; resize always paired with a scroll context
test_lint.pyNo undefined names; every core alias in app.py resolves
test_ui_prefs.pyInterface preferences round-trip through settings; unknown keys are dropped
test_version_sync.pyEvery version string matches core/env.py
test_api_docs_coverage.pyEvery /api/ route appears in docs/api.md and both OpenAPI copies, and the two specs are identical
test_api_auth_401.pyAPI paths answer an expired session with 401, not a redirect; page routes still redirect
test_api_key_csrf.pyAPI keys skip CSRF, as documented
test_crowdsec_mtls.py, test_crowdsec_scale.pyClient-certificate auth to the LAPI; the streaming read and its fallback
test_restore_static_target.pyA static backup restores to traefik.yml, never over the dynamic config
test_static_provider_keys.pySaving a provider section preserves keys the form does not manage
test_no_dashes.pyNo em dashes anywhere

The table is not exhaustive - tests/ holds more than this. Run pytest --collect-only -q for the full list.

Screenshots

scripts/screenshots/run.sh recaptures every desktop screenshot for the docs and README from a seeded demo environment - both themes, all tabs and modals - straight into docs/public/images/. Run it against the beta image after visual changes and review the git diff. Details in scripts/screenshots/README.md.

Conventions

  • Assert the YAML, not the status code. A 200 from /save proves nothing about what landed on disk. Load the written file and assert its structure - that is where config-corrupting bugs show up.
  • Add a test when you change the write path. /save, middleware saves and backups are the code that touches someone's live proxy config.
  • A test that guards a bug should fail without the fix. Check that it does before opening the PR.

Pull requests

  • Target dev. main is the released branch; a PR against it will be asked to retarget. A check enforces this.
  • One concern per PR. Small, focused PRs get reviewed and merged quickly. A large refactor mixed with a fix is hard to review and hard to revert.
  • Say what you tested. Especially for anything touching route or middleware saves. If you tested against a real Traefik, say which version.
  • Host and agent parity. If a feature works on the Host it should work when a remote agent is selected. The write path is shared, but the read path often is not - check both.

Code style

  • Python - 4-space indent, single quotes, type hints on new functions. No formatter is enforced.
  • HTML/JS - stay within the existing Tailwind and vanilla-JS patterns. No new frameworks.
  • CSS - add rules to static/css/app.css. No inline styles unless the value is dynamic.
  • No comments. Use clear names instead; the codebase follows this throughout.
  • No dead code. ruff runs in CI (ruff.toml, pyflakes rule set) and fails on unused imports.

Releasing

core/env.py holds APP_VERSION, and five other files must match it:

FileWhat
core/env.pyAPP_VERSION - the source of truth
static/sw.jsCACHE_NAME - bump it or browsers serve stale assets
agent/main.goVersion - baked in at build time, so a stale value makes every agent report as outdated
static/openapi.yamlinfo.version
docs/public/openapi.yamlinfo.version, and byte-identical to the file above
docs/.vitepress/config.tsthe nav version label and its releases/tag/ link

tests/test_version_sync.py fails the build when any of them drifts, so you do not have to remember the list.

Releases are cut by merging dev into main, tagging vX.Y.Z, and publishing a GitHub Release. The tag push builds and pushes the Docker images; publishing the release builds the agent binaries, so a draft release does not produce them.