8.3 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
NetAlertX is a network visibility / asset-intelligence platform: continuous device discovery, presence/intruder detection, IPAM drift tracking, notifications, and multi-site sync, aimed at homelabs, MSPs, and NOCs. Backend is Python, frontend is PHP/JS served by Nginx, data lives in SQLite plus flat config files.
Commands
Almost everything must run inside the devcontainer (Docker) — the host machine lacks the runtime environment (DB, /data/config, dependencies). Check with ls -d /workspaces/NetAlertX; if absent, you're on the host.
# Full test suite (default — comprehensive coverage over speed, don't optimize for time unless asked)
cd /workspaces/NetAlertX; pytest test/
# One file or directory
pytest test/plugins/test_adguard_export.py
pytest test/plugins/
# Fast/unit-only (only when explicitly asked for "fast"/"quick" tests)
pytest test/ -m 'not docker and not feature_complete'
# Reset the environment / pick up code changes / get a fresh API_TOKEN
bash /workspaces/NetAlertX/.devcontainer/scripts/setup.sh
sleep 5
python3 -c "from helper import get_setting_value; print(get_setting_value('API_TOKEN'))"
# Lint (matches CI in .github/workflows/code-checks.yml)
flake8 . --max-line-length=180 --ignore=E221,E222,E251,E203
# Full CI-equivalent run (regenerates devcontainer Dockerfile, rebuilds, runs everything)
./scripts/run_tests_in_docker_environment.sh
Rebuild the test image (docker buildx build -t netalertx-test .) only if the Dockerfile or dependencies changed — otherwise skip it, it's slow.
Outside the container, most plugin unit tests (test/plugins/test_*.py) still run standalone — they stub NetAlertX modules into sys.modules before importing the plugin script. See the stubbing pitfall below before adding one.
Architecture
Backend layout
server/__main__.py— entry point.server/plugin.py— plugin runner/scheduler.server/api_server/— Flask + GraphQL API.server/const.py/server/config_paths.py— resolve the three runtime path roots.server/conf.py— process-wide config variables (a deliberate workaround for cross-module globals).server/db/— the only layer allowed to touch SQLite directly (db_helper.py).server/models/— domain handlers on top of it (e.g.DeviceInstanceinmodels/device_instance.py). Never query the DB from elsewhere — go through a model ordb_helper.py.server/scan/,server/messaging/,server/workflows/,server/utils/— scanning pipeline, notification dispatch, the workflow-automation engine, and shared utilities (utils/datetime_utils.py'stimeNowUTC()is the only placedatetime.now()should be called — everything is stored in UTC).
Frontend
front/ is PHP + vanilla JS served by Nginx — no build step, no bundler, no package.json. Pages are top-level .php files; shared logic under front/php/.
Data & path conventions
Three distinct roots, each with a different persistence contract — get this wrong and data silently disappears on restart:
dbFolderPath(/data/db) — durable. Plugin-internal state (caches, "what did I already do" trackers) belongs here.configPath(/data/config) — durable, user-facing.app.conflives here; config-like plugin artifacts (exports, backups) belong here too.logPath(/tmp/log, plus/tmp/api,/tmp/db_is_locked, nginx state) — ephemeral tmpfs, wiped on every container restart. Never put anything here you need to survive a restart. (server/plugins/adguard_export,unifi_importwere both fixed this way after shipping with state files rooted inlogPath— check any plugin that opens a file outside itsRESULT_FILEagainst this before assuming it's fine.)
All three, plus dataPath (/data, the bare parent of dbFolderPath/configPath - avoid writing loose files directly under it; pick one of the two subpaths above instead), are exported from server/const.py and importable by any plugin.
Plugin system (server/plugins/*/)
Every plugin is a folder with config.json (manifest: settings, data contract, DB column mapping), an optional script.py-equivalent, and a README.md. Start from server/plugins/__template/. Full reference: docs/PLUGINS_DEV.md (its "Conventions Checklist" section is CI-enforced — see below).
Non-obvious things that have caused real, shipped bugs in this codebase:
RUN_TIMEOUTis the whole subprocess's kill-timeout, enforced byserver/plugin.py, not a safe per-call HTTP/subprocess timeout. A plugin that loops over N things and reusesRUN_TIMEOUTas each individual call's timeout can have one slow call burn the whole budget and get SIGKILLed before it writes its result file — silently losing the entire run. Two correct answers depending on the loop shape:- Looping over a config-declared, known-length list (e.g. a subnets setting) → mark that
params[]entry"timeoutMultiplier": trueinconfig.json; the framework scales the outer kill-timeout by the list length. Seearp_scan/config.json. - Looping over a runtime-variable-length collection (e.g. a notification queue) →
plugin_helper.per_item_timeout(run_timeout, item_count)divides the inner per-call budget instead. Seeserver/plugins/_publisher_ntfy/ntfy.py. test/plugins/test_plugin_conventions.pymechanically checks for the unguarded reuse pattern (AST-based, including the case where the loop calls a helper function that does the risky call) — run it after touching any plugin that makes network/subprocess calls in a loop.
- Looping over a config-declared, known-length list (e.g. a subnets setting) → mark that
plugin_helper.Plugin_Object:helpVal1-4andwatchedValue1-4both preserve a real0/Falseyou pass explicitly (checked viais not None) — only an actually-omitted (None) value defaults to"". Don't reintroduce a barex or ""coercion here; it silently discards legitimate falsy values (this was a real, if narrowly-triggered, bug).- A plugin's hardcoded Python fallback (
get_setting_value("X") or <literal>) must match that setting'sconfig.jsondefault_value—test_plugin_conventions.pychecks this too.RUNshould default to"disabled"for every non-core plugin; description strings render directly in the Settings UI and should stay short (README is for implementation detail). - Plugin unit tests that stub NetAlertX modules into
sys.modules(so a script imports standalone outside the container) must pop every stubbed name back out immediately after the one-time import — otherwise the fake module leaks and shadows the real one for every other test file collected in the same pytest session, regardless of file/alphabetical order. Seetest/plugins/test_ntfy_custom_headers.pyfor the pattern, ordocs/PLUGINS_DEV.md/ thetesting-workflowskill for the full writeup.
Data contract (plugin → DB)
Plugins write pipe-delimited rows to RESULT_FILE via plugin_helper.Plugin_Objects/Plugin_Object — 9 required columns, 4 optional helpVal* ones. Full column spec and validation rules: docs/PLUGINS_DEV_DATA_CONTRACT.md.
Skills
Procedural/how-to knowledge (running tests, resetting the DB, devcontainer management, PR analysis, etc.) lives as paired files in .gemini/skills/<name>/ and .github/skills/<name>/ (see .gemini/skills/skills-index/SKILL.md for the pairing map) — Claude Code should treat both as equally authoritative sources for the same procedures. The pairing convention is "keep body content identical"; a CI job (check-skill-pairs in .github/workflows/code-checks.yml) flags PRs that edit one side of a pair without the other, but it only checks presence, not content — if you edit one side, check whether the other needs the same update.
Code conventions
- DB columns are camelCase, never snake_case (
deviceInstanceId, notdevice_instance_id). - Every
subprocesscall needs an explicit timeout; a nested subprocess call needs its own — an outer timeout doesn't propagate. - Always run MACs through
normalize_mac()(plugin_helper.py) before writing to DB; MAC literals in tests must be lowercase. - No inline imports — everything at module top level.
- Reuse
test/db_test_helpers.pyfor DB mocks/fixtures in tests rather than redefiningDummyDB/make_dblocally. - Keep files under ~500 lines; split rather than grow.