178 Commits

Author SHA1 Message Date
DECNET CI
499836c9e4 chore: auto-release v0.2 [skip ci] 2026-04-13 11:50:02 +00:00
bb9c782c41 Merge pull request 'tofix/merge-testing-to-main' (#6) from tofix/merge-testing-to-main into main
Some checks failed
Release / Auto-tag release (push) Successful in 16s
Release / Build, scan & push conpot (push) Failing after 4m22s
Release / Build, scan & push elasticsearch (push) Failing after 4m37s
Release / Build, scan & push llmnr (push) Failing after 4m32s
Release / Build, scan & push mongodb (push) Failing after 4m35s
Release / Build, scan & push ldap (push) Failing after 4m44s
Release / Build, scan & push docker_api (push) Failing after 4m57s
Release / Build, scan & push imap (push) Failing after 4m50s
Release / Build, scan & push http (push) Failing after 4m59s
Release / Build, scan & push mssql (push) Failing after 4m28s
Release / Build, scan & push mqtt (push) Failing after 4m38s
Release / Build, scan & push ftp (push) Failing after 5m8s
Release / Build, scan & push k8s (push) Failing after 5m3s
Release / Build, scan & push mysql (push) Failing after 1m56s
Release / Build, scan & push redis (push) Has started running
Release / Build, scan & push rdp (push) Has been cancelled
Release / Build, scan & push pop3 (push) Has been cancelled
Release / Build, scan & push postgres (push) Has been cancelled
Release / Build, scan & push sip (push) Has started running
Release / Build, scan & push smb (push) Has started running
Release / Build, scan & push smtp (push) Has started running
Release / Build, scan & push snmp (push) Has started running
Release / Build, scan & push ssh (push) Has started running
Release / Build, scan & push telnet (push) Has started running
Release / Build, scan & push tftp (push) Has started running
Release / Build, scan & push vnc (push) Has started running
Reviewed-on: #6
2026-04-13 13:49:47 +02:00
597854cc06 Merge branch 'merge/testing-to-main' into tofix/merge-testing-to-main
Some checks failed
PR Gate / Lint (ruff) (pull_request) Successful in 17s
PR Gate / SAST (bandit) (pull_request) Successful in 23s
PR Gate / Dependency audit (pip-audit) (pull_request) Successful in 36s
PR Gate / Test (pytest) (3.12) (pull_request) Failing after 1m0s
PR Gate / Test (pytest) (3.11) (pull_request) Failing after 1m10s
2026-04-13 07:48:43 -04:00
3b4b0a1016 merge: resolve conflicts between testing and main (remove tracked settings, fix pyproject deps) 2026-04-13 07:48:37 -04:00
DECNET CI
8ad3350d51 ci: auto-merge dev → testing [skip ci] 2026-04-13 05:55:46 +00:00
0706919469 modified: gitignore to ignore temporary log files
All checks were successful
CI / Lint (ruff) (push) Successful in 17s
CI / SAST (bandit) (push) Successful in 16s
CI / Dependency audit (pip-audit) (push) Successful in 26s
CI / Test (Standard) (3.11) (push) Successful in 2m8s
CI / Test (Standard) (3.12) (push) Successful in 2m12s
CI / Test (Live) (3.11) (push) Successful in 58s
CI / Test (Fuzz) (3.11) (push) Successful in 6m45s
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
CI / Merge dev → testing (push) Successful in 11s
2026-04-13 01:44:52 -04:00
f2cc585d72 fix: align tests with model validation and API error reporting 2026-04-13 01:43:52 -04:00
89abb6ecc6 Merge branch 'dev' of https://git.resacachile.cl/anti/DECNET into dev
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 14s
CI / Dependency audit (pip-audit) (push) Successful in 23s
CI / Test (Standard) (3.11) (push) Successful in 1m33s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Successful in 56s
CI / Test (Fuzz) (3.11) (push) Failing after 4m8s
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
2026-04-12 08:02:06 -04:00
03f5a7826f Fix: resolved sqlite concurrency errors (table users already exists) by moving DDL to explicit async initialize() and implementing lazy singleton dependency. 2026-04-12 08:01:21 -04:00
a5eaa3291e Fix: resolved sqlite concurrency errors (table users already exists) by moving DDL to explicit async initialize() and implementing lazy singleton dependency.
Some checks failed
CI / SAST (bandit) (push) Successful in 15s
CI / Lint (ruff) (push) Failing after 18s
CI / Dependency audit (pip-audit) (push) Successful in 26s
CI / Test (Standard) (3.11) (push) Has been skipped
CI / Test (Standard) (3.12) (push) Has been skipped
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
2026-04-12 07:59:45 -04:00
b2e4706a14 Refactor: implemented Repository Factory and Async Mutator Engine. Decoupled storage logic and enforced Dependency Injection across CLI and Web API. Updated documentation.
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 13s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Failing after 54s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
2026-04-12 07:48:17 -04:00
6095d0d2ed ci: solidify promotion dependencies with explicit test list
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 21s
CI / Test (Standard) (3.11) (push) Successful in 1m9s
CI / Test (Standard) (3.12) (push) Successful in 1m11s
CI / Test (Live) (3.11) (push) Successful in 54s
CI / Merge dev → testing (push) Has been cancelled
CI / Prepare Merge to Main (push) Has been cancelled
CI / Finalize Merge to Main (push) Has been cancelled
CI / Test (Fuzz) (3.11) (push) Has been cancelled
2026-04-12 04:24:29 -04:00
04685ba1c4 ci: reorder heavy tests (Live before Fuzz) 2026-04-12 04:22:33 -04:00
2ce3f7ee90 ci: delegate release tagging and versioning to release.yml 2026-04-12 04:21:28 -04:00
cb4bac4b42 ci: segment pytest into standard, fuzz, and live categories
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Successful in 1m10s
CI / Test (Standard) (3.12) (push) Successful in 1m13s
CI / Test (Live) (3.11) (push) Has been cancelled
CI / Merge dev → testing (push) Has been cancelled
CI / Prepare Merge to Main (push) Has been cancelled
CI / Finalize Merge to Main (push) Has been cancelled
CI / Test (Fuzz) (3.11) (push) Has been cancelled
2026-04-12 04:17:05 -04:00
8d5944f775 ci: implement automated RC flow and finalize optimizations on dev 2026-04-12 04:15:42 -04:00
23ec470988 Merge pull request 'fix/merge-testing-to-main' (#4) from fix/merge-testing-to-main into main
Some checks failed
Release / Auto-tag release (push) Failing after 8s
Release / Build, scan & push cowrie (push) Has been skipped
Release / Build, scan & push docker_api (push) Has been skipped
Release / Build, scan & push elasticsearch (push) Has been skipped
Release / Build, scan & push ftp (push) Has been skipped
Release / Build, scan & push http (push) Has been skipped
Release / Build, scan & push imap (push) Has been skipped
Release / Build, scan & push k8s (push) Has been skipped
Release / Build, scan & push ldap (push) Has been skipped
Release / Build, scan & push llmnr (push) Has been skipped
Release / Build, scan & push mongodb (push) Has been skipped
Release / Build, scan & push mqtt (push) Has been skipped
Release / Build, scan & push mssql (push) Has been skipped
Release / Build, scan & push mysql (push) Has been skipped
Release / Build, scan & push pop3 (push) Has been skipped
Release / Build, scan & push postgres (push) Has been skipped
Release / Build, scan & push rdp (push) Has been skipped
Release / Build, scan & push real_ssh (push) Has been skipped
Release / Build, scan & push redis (push) Has been skipped
Release / Build, scan & push sip (push) Has been skipped
Release / Build, scan & push smb (push) Has been skipped
Release / Build, scan & push smtp (push) Has been skipped
Release / Build, scan & push snmp (push) Has been skipped
Release / Build, scan & push tftp (push) Has been skipped
Release / Build, scan & push vnc (push) Has been skipped
Reviewed-on: #4
2026-04-12 10:10:19 +02:00
4064e19af1 merge: resolve conflicts between testing and main
Some checks failed
PR Gate / Lint (ruff) (pull_request) Failing after 11s
PR Gate / Test (pytest) (3.11) (pull_request) Failing after 10s
PR Gate / Test (pytest) (3.12) (pull_request) Failing after 10s
PR Gate / SAST (bandit) (pull_request) Successful in 12s
PR Gate / Dependency audit (pip-audit) (pull_request) Failing after 13s
2026-04-12 04:09:17 -04:00
ea9f7e734b ci: sequential checks, heavy pytest, and skip ci on auto-merge 2026-04-12 03:55:12 -04:00
DECNET CI
ac4e5e1570 ci: auto-merge dev → testing
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 1m9s
CI / Test (pytest) (3.12) (push) Successful in 1m14s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 21s
CI / Merge dev → testing (push) Has been skipped
CI / Open PR to main (push) Successful in 6s
PR Gate / Lint (ruff) (pull_request) Successful in 11s
PR Gate / Test (pytest) (3.11) (pull_request) Successful in 1m13s
PR Gate / Test (pytest) (3.12) (pull_request) Successful in 1m12s
PR Gate / SAST (bandit) (pull_request) Successful in 13s
PR Gate / Dependency audit (pip-audit) (pull_request) Successful in 21s
2026-04-12 07:53:07 +00:00
fe18575a9c modified: pyproject, moved [live] deps to [dev] deps.
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 1m19s
CI / Test (pytest) (3.12) (push) Successful in 1m22s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 21s
CI / Merge dev → testing (push) Successful in 10s
CI / Open PR to main (push) Has been skipped
2026-04-12 03:49:20 -04:00
0f63820ee6 chore: fix unused imports in tests and update development roadmap
Some checks failed
CI / Lint (ruff) (push) Successful in 16s
CI / Test (pytest) (3.11) (push) Failing after 34s
CI / Test (pytest) (3.12) (push) Failing after 36s
CI / SAST (bandit) (push) Successful in 12s
CI / Merge dev → testing (push) Has been cancelled
CI / Open PR to main (push) Has been cancelled
CI / Dependency audit (pip-audit) (push) Has been cancelled
2026-04-12 03:46:23 -04:00
fdc404760f moved: mermaid graph to development folder 2026-04-12 03:42:43 -04:00
95190946e0 moved: AST graphs into develpment/ folder 2026-04-12 03:42:08 -04:00
1692df7360 deleted: trash vscode stuff 2026-04-12 03:41:15 -04:00
aac39e818e Docs: Generated full coverage report in development/COVERAGE.md 2026-04-12 03:36:13 -04:00
ff38d58508 Testing: Stabilized test suite and achieved 93% total coverage.
- Fixed CLI tests by patching local imports at source (psutil, os, Path).
- Fixed Collector tests by globalizing docker.from_env mock.
- Stabilized SSE stream tests via AsyncMock and immediate generator termination to prevent hangs.
- Achieved >80% coverage on CLI (84%), Collector (97%), and DB Repository (100%).
- Implemented SMTP Relay service tests (100%).
2026-04-12 03:30:06 -04:00
f78104e1c8 fix: resolve all ruff lint errors and SQLite UNIQUE constraint issue
Ruff fixes (20 errors → 0):
- F401: Remove unused imports (DeckyConfig, random_hostname, IniConfig,
  COMPOSE_FILE, sys, patch) across cli.py, mutator/engine.py,
  templates/ftp, templates/rdp, test_mysql.py, test_postgres.py
- F541: Remove extraneous f-prefixes on strings with no placeholders
  in templates/imap, test_ftp_live, test_http_live
- E741: Rename ambiguous variable 'l' to descriptive names (line, entry,
  part) across conftest.py, test_ftp_live, test_http_live,
  test_mongodb_live, test_pop3, test_ssh

SQLite fix:
- Change _initialize_sync() admin seeding from SELECT-then-INSERT to
  INSERT OR IGNORE, preventing IntegrityError when admin user already
  exists from a previous run
2026-04-12 02:17:50 -04:00
99be4e64ad ci: rework pipeline to dev → testing → main promotion
- Add merge-to-testing job: after all CI checks pass on dev, auto-merge
  into testing with --no-ff for clear merge history
- Move open-pr job to trigger on testing branch instead of dev
- PR now opens testing → main instead of dev → main
- Add bandit and pip-audit jobs to pr.yml PR gate for full suite coverage
- PR gate test job now installs dev dependencies consistently
2026-04-12 02:11:24 -04:00
c3c1cd2fa6 modified: .gitignore
Some checks failed
CI / Lint (ruff) (push) Failing after 16s
CI / Test (pytest) (3.11) (push) Failing after 47s
CI / Test (pytest) (3.12) (push) Failing after 49s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 23s
CI / Open PR to main (push) Has been skipped
2026-04-12 02:03:49 -04:00
68b13b8a59 added: decnet_logging.py stub for telnet monitoring 2026-04-12 02:03:06 -04:00
f8bb134d70 added: fixed mssql service 2026-04-12 02:01:45 -04:00
20fba18711 fix(telnet): disable imklog in rsyslog — containers cannot access /proc/kmsg 2026-04-12 01:45:46 -04:00
b325fc8c5f fix(logging): silence Twisted internal logs and Werkzeug startup banner from stdout 2026-04-12 01:43:42 -04:00
1484d2f625 fix(telnet): use busybox-static for telnetd applet, rm stale fifo on restart 2026-04-12 01:39:31 -04:00
f8ae9ce2a6 refactor(deps): move live test deps to pyproject.toml optional-dependencies[live] 2026-04-12 01:35:16 -04:00
662a5e43e8 feat(tests): add live subprocess integration test suite for services
Spins up each service's server.py in a real subprocess via a free ephemeral
port (PORT env var), connects with real protocol clients, and asserts both
correct protocol behavior and RFC 5424 log output.

- 44 live tests across 10 services: http, ftp, smtp, redis, mqtt,
  mysql, postgres, mongodb, pop3, imap
- Shared conftest.py: _ServiceProcess (bg reader thread + queue),
  free_port, live_service fixture, assert_rfc5424 helper
- PORT env var added to all 10 targeted server.py templates
- New pytest marker `live`; excluded from default addopts run
- requirements-live-tests.txt: flask, twisted + protocol clients
2026-04-12 01:34:16 -04:00
d63e396410 fix(protocols): guard against zero/malformed length fields in binary protocol parsers
MongoDB had the same infinite-loop bug as MSSQL (msg_len=0 → buffer never
shrinks in while loop). Postgres, MySQL, and MQTT had related length-field
issues (stuck state, resource exhaustion, overlong remaining-length).

Also fixes an existing MongoDB _op_reply struct.pack format bug (extra 'q'
specifier caused struct.error on any OP_QUERY response).

Adds 53 regression + protocol boundary tests across MSSQL, MongoDB,
Postgres, MySQL, and MQTT, including a _run_with_timeout threading harness
to catch infinite loops and @pytest.mark.fuzz hypothesis tests for each.
2026-04-12 01:01:13 -04:00
65d585569b fix(telnet): replace Cowrie with real busybox telnetd + rsyslog logging
Cowrie was exposing an SSH daemon on port 22 alongside the telnet service
even when COWRIE_SSH_ENABLED=false, contaminating deployments that did not
request an SSH service.

New implementation mirrors the SSH service pattern:
- busybox telnetd in foreground mode on port 23
- /bin/login for real PAM authentication (brute-force attempts logged)
- rsyslog RFC 5424 bridge piped to stdout for Docker log capture
- Configurable root password and hostname via env vars
- No Cowrie dependency
2026-04-12 00:34:45 -04:00
c384a3103a refactor: separate engine, collector, mutator, and fleet into independent subpackages
- decnet/engine/ — container lifecycle (deploy, teardown, status); _kill_api removed
- decnet/collector/ — Docker log streaming (moved from web/collector.py)
- decnet/mutator/ — mutation engine (no longer imports from cli or duplicates deployer code)
- decnet/fleet.py — shared decky-building logic extracted from cli.py

Cross-contamination eliminated:
- web router no longer imports from decnet.cli
- mutator no longer imports from decnet.cli
- cli no longer imports from decnet.web
- _kill_api() moved to cli (process management, not engine concern)
- _compose_with_retry duplicate removed from mutator
2026-04-12 00:26:22 -04:00
c79f96f321 refactor(ssh): consolidate real_ssh into ssh, remove duplication
real_ssh was a separate service name pointing to the same template and
behaviour as ssh. Merged them: ssh is now the single real-OpenSSH service.

- Rename templates/real_ssh/ → templates/ssh/
- Remove decnet/services/real_ssh.py
- Deaddeck archetype updated: services=["ssh"]
- Merge test_real_ssh.py into test_ssh.py (includes deaddeck + logging tests)
- Drop decnet.services.real_ssh from test_build module list
2026-04-11 19:51:41 -04:00
d77def64c4 fix(cli): import Path locally in deploy to fix NameError 2026-04-11 19:46:58 -04:00
ce182652ad fix(cli): add __main__ guard so python -m decnet.cli actually runs the app
The collector subprocess was spawned via 'python3 -m decnet.cli collect'
but cli.py had no 'if __name__ == __main__: app()' guard. Python executed
the module, defined all functions, then exited cleanly with code 0 without
ever calling the collect command. No output, no log file, exit 0 — silent
non-start every time.

Also route collector stderr to <log_file>.collector.log so future crashes
are visible instead of disappearing into DEVNULL.
2026-04-11 19:42:10 -04:00
a6063efbb9 fix(collector): daemonize background subprocesses with start_new_session
Collector and mutator watcher subprocesses were spawned without
start_new_session=True, leaving them in the parent's process group.
SIGHUP (sent when the controlling terminal closes) killed both
processes silently — stdout/stderr were DEVNULL so the crash was
invisible.

Also update test_services and test_composer to reflect the ssh plugin
no longer using Cowrie env vars (replaced with SSH_ROOT_PASSWORD /
SSH_HOSTNAME matching the real_ssh plugin).
2026-04-11 19:36:46 -04:00
d4ac53c0c9 feat(ssh): replace Cowrie with real OpenSSH + rsyslog logging pipeline
Scraps the Cowrie emulation layer. The real_ssh template now runs a
genuine sshd backed by a three-layer logging stack forwarded to stdout
as RFC 5424 for the DECNET collector:

  auth,authpriv.*  → rsyslogd → named pipe → stdout  (logins/failures)
  user.*           → rsyslogd → named pipe → stdout  (PROMPT_COMMAND cmds)
  sudo syslog=auth → rsyslogd → named pipe → stdout  (privilege escalation)
  sudo logfile     → /var/log/sudo.log               (local backup with I/O)

The ssh.py service plugin now points to templates/real_ssh and drops all
COWRIE_* / NODE_NAME env vars, sharing the same compose fragment shape as
real_ssh.py.
2026-04-11 19:12:54 -04:00
9ca3b4691d docs(roadmap): tick completed service implementations 2026-04-11 04:02:50 -04:00
babad5ce65 refactor(collector): use state file for container detection, drop label heuristics
_load_service_container_names() reads decnet-state.json and builds the
exact set of expected container names ({decky}-{service}). is_service_container()
and is_service_event() do a direct set lookup — no regex, no label
inspection, no heuristics.
2026-04-11 03:58:52 -04:00
7abae5571a fix(collector): fix container detection and auto-start on deploy
Two bugs caused the log file to never be written:

1. is_service_container() used regex '^decky-\d+-\w' which only matched
   the old decky-01-smtp naming style. Actual containers are named
   omega-decky-smtp, relay-decky-smtp, etc. Fixed by using Docker Compose
   labels instead: com.docker.compose.project=decnet + non-empty
   depends_on discriminates service containers from base (sleep infinity)
   containers reliably regardless of decky naming convention.
   Added is_service_event() for the Docker events path.

2. The collector was only started when --api was used. Added a 'collect'
   CLI subcommand (decnet collect --log-file <path>) and wired it into
   deploy as an auto-started background process when --api is not in use.
   Default log path: /var/log/decnet/decnet.log
2026-04-11 03:56:53 -04:00
377ba0410c feat(deploy): add --parallel flag for concurrent image builds
When --parallel is set:
- DOCKER_BUILDKIT=1 is injected into the subprocess environment to
  ensure BuildKit is active regardless of host daemon config
- docker compose build runs first (all images built concurrently)
- docker compose up -d follows without --build (no redundant checks)

Without --parallel the original up --build path is preserved.
--parallel and --no-cache compose correctly (build --no-cache).
2026-04-11 03:46:52 -04:00
5ef48d60be fix(conpot): add syslog bridge entrypoint for logging pipeline
Conpot is a third-party app with its own Python logger — it never calls
decnet_logging. Added entrypoint.py as a subprocess wrapper that:
- Launches conpot and captures its stdout/stderr
- Classifies each line (startup/request/warning/error/log)
- Extracts source IPs via regex
- Emits RFC 5424 syslog lines to stdout for Docker/collector pickup

Entrypoint is self-contained (no import of shared decnet_logging.py)
because the conpot base image runs Python 3.6, which cannot parse the
dict[str, Any] / str | None type syntax used in the canonical file.
2026-04-11 03:44:41 -04:00
fe46b8fc0b fix(conpot): use honeynet/conpot:latest base, run as conpot user
The BASE_IMAGE build arg was being unconditionally overwritten by
composer.py with the decky's distro build_base (debian:bookworm-slim),
turning the conpot container into a bare Debian image with no conpot
installation — hence the silent restart loop.

Two fixes:
1. composer.py: use args.setdefault() so services that pre-declare
   BASE_IMAGE in their compose_fragment() win over the distro default.
2. conpot.py: pre-declare BASE_IMAGE=honeynet/conpot:latest in build
   args so it always uses the upstream image regardless of decky distro.

Also removed the USER decnet switch from the conpot Dockerfile. The
upstream image already runs as the non-root 'conpot' user; switching to
'decnet' broke pkg_resources because conpot's eggs live under
/home/conpot/.local and are only on sys.path for that user.
2026-04-11 03:32:11 -04:00
c7713c6228 feat(imap,pop3): full IMAP4rev1 + POP3 bait mailbox implementation
IMAP: extended to full IMAP4rev1 — 10 bait emails (AWS keys, DB creds,
tokens, VPN config, root pw etc.), LIST/LSUB/STATUS/FETCH/UID FETCH/
SEARCH/CLOSE/NOOP, proper SELECT untagged responses (EXISTS, UIDNEXT,
FLAGS, PERMANENTFLAGS), CAPABILITY with IDLE/LITERAL+/AUTH=PLAIN.
FETCH correctly handles sequence sets (1:*, 1:3, *), item dispatch
(FLAGS, ENVELOPE, BODY[], RFC822, RFC822.SIZE), and places body literals
last per RFC 3501.

POP3: extended with same 10 bait emails, fixed banner env var key
(POP3_BANNER not IMAP_BANNER), CAPA fully populated (TOP/UIDL/USER/
RESP-CODES/SASL), TOP (headers + N body lines), UIDL (msg-N format),
DELE/RSET with _deleted set tracking, NOOP. _active_messages() helper
excludes DELE'd messages from STAT/LIST/UIDL.

Both: DEBT-026 stub added (_EMAIL_SEED_PATH env var, documented in
DEBT.md for next-session JSON seed file wiring).

Tests: test_imap.py expanded to 27 cases, test_pop3.py to 22 cases —
860 total tests passing.
2026-04-11 03:12:32 -04:00
1196363d0b feat(os_fingerprint): Phase 2 — add icmp_ratelimit + icmp_ratemask sysctls
Windows: both 0 (no ICMP rate limiting — matches real Windows behavior)
Linux: 1000ms / mask 6168 (kernel defaults)
BSD: 250ms / mask 6168 (FreeBSD default is faster than Linux)
Embedded/Cisco: both 0 (most firmware doesn't rate-limit ICMP)

These affect nmap's IE and U1 probe groups which measure ICMP error
response timing to closed UDP ports. Windows responds to all probes
instantly while Linux throttles to ~1/sec.

Tests: 10 new cases (5 per sysctl). Suite: 822 passed.
2026-04-10 16:41:23 -04:00
62a67f3d1d docs(HARDENING): rewrite roadmap based on live scan findings
Phase 1 is complete. Live testing revealed:
- Window size (64240) is already correct — Phase 2 window mangling unnecessary
- TI=Z (IP ID = 0) is the single remaining blocker for Windows spoofing
- ip_no_pmtu_disc does NOT fix TI=Z (tested and confirmed)

Revised phase plan:
- Phase 2: ICMP tuning (icmp_ratelimit + icmp_ratemask sysctls)
- Phase 3: NFQUEUE daemon for IP ID rewriting (fixes TI=Z)
- Phase 4: diminishing returns, not recommended

Added detailed NFQUEUE architecture, TCPOPTSTRIP notes, and
note clarifying P= field in nmap output.
2026-04-10 16:38:27 -04:00
6df2c9ccbf revert(os_fingerprint): undo ip_no_pmtu_disc=1 for windows — was incorrect
ip_no_pmtu_disc controls PMTU discovery for UDP/ICMP paths only.
TI=Z originates from ip_select_ident() in the kernel TCP stack setting
IP ID=0 for DF=1 TCP packets — a namespace-scoped sysctl cannot change this.
The previous commit was based on incorrect root-cause analysis.
2026-04-10 16:29:44 -04:00
b1f6c3b84a fix(os_fingerprint): set ip_no_pmtu_disc=1 for windows to eliminate TI=Z
When ip_no_pmtu_disc=0 the Linux kernel sets DF=1 on TCP packets and uses
IP ID=0 (RFC 6864). nmap's TI=Z fingerprint has no Windows match in its DB,
causing 91% confidence guesses of 'Linux 2.4/2.6 embedded' regardless of
TTL being 128. Setting ip_no_pmtu_disc=1 allows non-zero IP ID generation.

Trade-off: DF bit is not set on outgoing packets (slightly wrong for Windows)
but TI=Z is far more damaging to the spoof than losing DF accuracy.
2026-04-10 16:19:32 -04:00
5fdfe67f2f fix(cowrie): add missing COPY+chmod for entrypoint.sh in Dockerfile
The entrypoint.sh was present in the build context but never COPYed into
the image, causing 'stat /entrypoint.sh: no such file or directory' at
container start. Added COPY+chmod before the USER decnet instruction so
the script is installed as root and is executable by all users.
2026-04-10 16:15:05 -04:00
4fac9570ec chore: add arche-test.ini OS fingerprint smoke-test fleet 2026-04-10 16:11:18 -04:00
5e83c9e48d feat(os_fingerprint): Phase 1 — extend OS sysctls with 6 new fingerprint knobs
Add tcp_timestamps, tcp_window_scaling, tcp_sack, tcp_ecn, ip_no_pmtu_disc,
and tcp_fin_timeout to every OS profile in OS_SYSCTLS.

All 6 are network-namespace-scoped and safe to set per-container without
--privileged. They directly influence nmap's OPS, WIN, ECN, and T2-T6
probe groups, making OS family detection significantly more convincing.

Key changes:
- tcp_timestamps=0 for windows/embedded/cisco (strongest Windows discriminator)
- tcp_ecn=2 for linux (ECN offer), 0 for all others
- tcp_sack=0 / tcp_window_scaling=0 for embedded/cisco
- ip_no_pmtu_disc=1 for embedded/cisco (DF bit ICMP behaviour)
- Expose _REQUIRED_SYSCTLS frozenset for completeness assertions

Tests: 88 new test cases across all OS families and composer integration.
Total suite: 812 passed.
2026-04-10 16:06:36 -04:00
d8457c57f3 docs: add OS fingerprint spoofing hardening roadmap 2026-04-10 16:02:00 -04:00
38d37f862b docs: Detail attachable Swarm overlay backend in FUTURE.md 2026-04-10 03:00:03 -04:00
fa8b0f3cb5 docs: Add latency simulation to FUTURE.md 2026-04-10 02:53:00 -04:00
db425df6f2 docs: Add FUTURE.md to capture long-term architectural visions 2026-04-10 02:48:28 -04:00
73e68388c0 fix(conpot): Refactor permissions to use dedicated decnet user via chown 2026-04-10 02:27:02 -04:00
682322d564 fix(conpot): Resolve silent crash by running as nobody and ensuring permissions 2026-04-10 02:25:45 -04:00
33885a2eec fix(conpot): Keep container as root to allow port 502 binding and fix user not found error 2026-04-10 02:20:46 -04:00
f583b3d699 fix(services): Resolve protocol realism gaps and update technical debt register
- Add dynamic challenge nonces to Postgres, VNC, and SIP.
- Add basic keyspace lookup and mock data to Redis.
- Correct MSSQL TDS pre-login offset bounds.
- Support MongoDB OP_MSG handshake version checking.
- Suppress Werkzeug HTTP server headers and normalize FTPAnonymousShell response.
- Add tracking for Dynamic Bait Store (DEBT-027) via DEBT.md.
2026-04-10 02:16:42 -04:00
5cb6666d7b docs: Append bug ledger implementation plan to REALISM_AUDIT.md 2026-04-10 01:58:23 -04:00
25b6425496 Update REALISM_AUDIT.md with completed tasks 2026-04-10 01:55:14 -04:00
08242a4d84 Implement ICS/SCADA and IMAP Bait features 2026-04-10 01:50:08 -04:00
63fb477e1f feat: add smtp_relay service; add service_testing/ init
- decnet/services/smtp_relay.py: open relay variant of smtp, same template
  with SMTP_OPEN_RELAY=1 baked into the environment
- tests/service_testing/__init__.py: init so pytest discovers the subdirectory
2026-04-10 01:09:15 -04:00
94f82c9089 feat(smtp): fix DATA state machine; add SMTP_OPEN_RELAY mode
- Buffer DATA body until CRLF.CRLF terminator — fixes 502-on-every-body-line bug
- SMTP_OPEN_RELAY=1: AUTH accepted (235), RCPT TO accepted for any domain,
  full DATA pipeline with queued-as message ID
- Default (SMTP_OPEN_RELAY=0): credential harvester — AUTH rejected (535)
  but connection stays open, RCPT TO returns 554 relay denied
- SASL PLAIN and LOGIN multi-step AUTH both decoded and logged
- RSET clears all per-transaction state
- Add development/SMTP_RELAY.md, IMAP_BAIT.md, ICS_SCADA.md, BUG_FIXES.md
  (live-tested service realism plans)
2026-04-10 01:03:47 -04:00
40cd582253 fix: restore forward_syslog as no-op stub; all service server.py files import it 2026-04-10 00:43:50 -04:00
24f02c3466 fix: resolve all bandit SAST findings in templates/
- Add # nosec B104 to all intentional 0.0.0.0 binds in honeypot servers
  (hardcoded_bind_all_interfaces is by design — deckies must accept attacker connections)
- Add # nosec B101 to assert statements used for protocol validation in ldap/snmp
- Add # nosec B105 to fake SASL placeholder in ldap
- Add # nosec B108 to /tmp usage in smb template
- Exclude root-owned auto-generated decnet_logging.py copies from bandit scan
  via pyproject.toml [tool.bandit] config (synced by _sync_logging_helper at deploy)
2026-04-10 00:24:40 -04:00
25ba3fb56a feat: replace bind-mount log pipeline with Docker log streaming
Services now print RFC 5424 to stdout; Docker captures via json-file driver.
A new host-side collector (decnet.web.collector) streams docker logs from all
running decky service containers and writes RFC 5424 + parsed JSON to the host
log file. The existing ingester continues to tail the .json file unchanged.
rsyslog can consume the .log file independently — no DECNET involvement needed.

Removes: bind-mount volume injection, _LOG_NETWORK bridge, log_target config
field and --log-target CLI flag, TCP syslog forwarding from service templates.
2026-04-10 00:14:14 -04:00
8d023147cc fix: chmod 777 log dir on compose generation so container decnet user can write logs 2026-04-09 19:36:53 -04:00
14f7a535db fix: use model_dump(mode='json') to serialize datetime fields; fixes SSE stream silently dying post-ORM migration 2026-04-09 19:29:27 -04:00
cea6279a08 fix: add Last-Event-ID to CORS allow_headers to unblock SSE reconnects 2026-04-09 19:26:24 -04:00
6b8392102e fix: emit stats/histogram snapshot on SSE connect; remove polling api.get('/stats') from Dashboard 2026-04-09 19:23:24 -04:00
d2a569496d fix: add get_stream_user dependency for SSE endpoint; allow query-string token for EventSource 2026-04-09 19:20:38 -04:00
f20e86826d fix: derive default CORS origin from DECNET_WEB_HOST/PORT instead of hardcoded ports 2026-04-09 19:15:45 -04:00
29da2a75b3 fix: add localhost:9090 to CORS defaults; revert broken relative-URL and proxy changes 2026-04-09 19:14:40 -04:00
3362325479 fix: resolve CORS blocking Vite dev server (add 5173 to defaults, add proxy) 2026-04-09 19:10:10 -04:00
34a57d6f09 fix: make setcap resilient — no-op when Python absent or symlink-only 2026-04-09 19:04:52 -04:00
016115a523 fix: clear all addressable technical debt (DEBT-005 through DEBT-025)
Security:
- DEBT-008: remove query-string token auth; header-only Bearer now enforced
- DEBT-013: add regex constraint ^[a-z0-9\-]{1,64}$ on decky_name path param
- DEBT-015: stop leaking raw exception detail to API clients; log server-side
- DEBT-016: validate search (max_length=512) and datetime params with regex

Reliability:
- DEBT-014: wrap SSE event_generator in try/except; yield error frame on failure
- DEBT-017: emit log.warning/error on DB init retry; silent failures now visible

Observability / Docs:
- DEBT-020: add 401/422 response declarations to all route decorators

Infrastructure:
- DEBT-018: add HEALTHCHECK to all 24 template Dockerfiles
- DEBT-019: add USER decnet + setcap cap_net_bind_service to all 24 Dockerfiles
- DEBT-024: bump Redis template version 7.0.12 → 7.2.7

Config:
- DEBT-012: validate DECNET_API_PORT and DECNET_WEB_PORT range (1-65535)

Code quality:
- DEBT-010: delete 22 duplicate decnet_logging.py copies; deployer injects canonical
- DEBT-022: closed as false positive (print only in module docstring)
- DEBT-009: closed as false positive (templates already use structured syslog_line)

Build:
- DEBT-025: generate requirements.lock via pip freeze

Testing:
- DEBT-005/006/007: comprehensive test suite added across tests/api/
- conftest: in-memory SQLite + StaticPool + monkeypatched session_factory
- fuzz mark added; default run excludes fuzz; -n logical parallelism

DEBT.md updated: 23/25 items closed; DEBT-011 (Alembic) and DEBT-023 (digest pinning) remain
2026-04-09 19:02:51 -04:00
0166d0d559 fix: clean up db layer — model_dump, timezone-aware timestamps, unified histogram, async load_state 2026-04-09 18:46:35 -04:00
dbf6d13b95 fix: use :memory: + StaticPool for test DBs, eliminates file:testdb_* garbage 2026-04-09 18:39:36 -04:00
d15c106b44 test: fix async fixture isolation, add fuzz marks, parallelize with xdist
- Rebuild repo.engine and repo.session_factory per-test using unique
  in-memory SQLite URIs — fixes KeyError: 'access_token' caused by
  stale session_factory pointing at production DB
- Add @pytest.mark.fuzz to all Hypothesis and Schemathesis tests;
  default run excludes them (addopts = -m 'not fuzz')
- Add missing fuzz tests to bounty, fleet, histogram, and repository
- Use tmp_path for state file in patch_state_file/mock_state_file to
  eliminate file-path race conditions under xdist parallelism
- Set default addopts: -v -q -x -n logical (26 tests in ~7s)
2026-04-09 18:32:46 -04:00
6fc1a2a3ea test: refactor suite to use AsyncClient, in-memory DBs, and parallel coverage 2026-04-09 16:43:49 -04:00
de84cc664f refactor: migrate database to SQLModel and implement modular DB structure 2026-04-09 16:43:30 -04:00
1541b4b7e0 docs: close DEBT-002 as by-design 2026-04-09 13:25:40 -04:00
2b7d872ab7 fix: revert DECNET_ADMIN_PASSWORD to default 'admin'; first-login change enforces security 2026-04-09 13:25:29 -04:00
4ae6f4f23d test: expand coverage 64%→76%; add BUGS.md for Gemini migration issues 2026-04-09 12:55:52 -04:00
310c2a1fbe feat: add pytest-asyncio, freezegun, schemathesis, pytest-cov to test toolchain 2026-04-09 12:40:59 -04:00
44de453bb2 refactor: modularize API tests to match router structure 2026-04-09 12:32:31 -04:00
ec66e01f55 fix: add missing __init__.py to tests/api subpackages to fix relative imports 2026-04-09 12:24:09 -04:00
a22f996027 docs: mark DEBT-001–004 as resolved in DEBT.md 2026-04-09 12:14:16 -04:00
b6b046c90b fix: harden startup security — require strong secrets, restrict CORS
- decnet/env.py: DECNET_JWT_SECRET and DECNET_ADMIN_PASSWORD are now
  required env vars; startup raises ValueError if unset or set to a
  known-bad default ("admin", "password", etc.)
- decnet/env.py: add DECNET_CORS_ORIGINS (comma-separated, defaults to
  http://localhost:8080) replacing the previous allow_origins=["*"]
- decnet/web/api.py: use DECNET_CORS_ORIGINS and tighten allow_methods
  and allow_headers to explicit lists
- tests/conftest.py: set required env vars at module level so test
  collection works without real credentials
- tests/test_web_api.py, test_web_api_fuzz.py: use DECNET_ADMIN_PASSWORD
  from env instead of hardcoded "admin"

Closes DEBT-001, DEBT-002, DEBT-004
2026-04-09 12:13:22 -04:00
29a2cf2738 refactor: modularize API routes into separate files and clean up dependencies 2026-04-09 11:58:57 -04:00
551664bc43 fix: stabilize test suite by ensuring proper test DB isolation and initialization 2026-04-09 02:31:14 -04:00
a2d07bd67c fix: refactor Bounty UI to match dashboard style and fix layout 2026-04-09 02:00:49 -04:00
a3b92d4dd6 docs: tag API endpoints for better organization 2026-04-09 01:58:54 -04:00
30edf9a55d feat: add DECNET_DEVELOPER toggle for API documentation 2026-04-09 01:55:31 -04:00
69626d705d feat: implement Bounty Vault for captured credentials and artifacts 2026-04-09 01:52:50 -04:00
0f86f883fe fix: resolve remaining bandit warnings and stabilize lifespan 2026-04-09 01:35:08 -04:00
13f3d15a36 fix: stabilize tests with synchronous DB init and handle Bandit security findings 2026-04-09 01:33:15 -04:00
8c7ec2953e fix: handle bcrypt 72-byte limit and increase JWT secret length 2026-04-09 01:11:32 -04:00
0123e1c69e fix: suppress noisy cleanup warnings in pytest and fix fleet test auth 2026-04-09 01:05:34 -04:00
9dc6ff3887 ui: ensure inputs and buttons inherit Ubuntu Mono font 2026-04-08 21:31:44 -04:00
fe25798425 ui: change main dashboard font to Ubuntu Mono 2026-04-08 21:30:30 -04:00
6c2478ede3 fix: restore missing API endpoints, fix chart rendering, and update date filter formatting 2026-04-08 21:25:59 -04:00
532a4e2dc5 fix: resolve SSE CORS issues and fix date filter format mismatch 2026-04-08 21:15:26 -04:00
ec503b9ec6 feat: implement advanced live logs with KQL search, histogram, and live/historical modes 2026-04-08 21:01:05 -04:00
fe6b349e5e modified: ci.yml, fucked up last time lol
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 1m42s
CI / Test (pytest) (3.12) (push) Successful in 1m45s
CI / SAST (bandit) (push) Failing after 12s
CI / Dependency audit (pip-audit) (push) Successful in 20s
CI / Open PR to main (push) Has been skipped
2026-04-08 15:53:49 -04:00
65b220fdbe modified: ci.yml, pyproject: added missing installs and modified pip install command
Some checks failed
CI / Lint (ruff) (push) Successful in 16s
CI / Test (pytest) (3.11) (push) Failing after 20s
CI / Test (pytest) (3.12) (push) Failing after 20s
CI / SAST (bandit) (push) Failing after 11s
CI / Dependency audit (pip-audit) (push) Successful in 19s
CI / Open PR to main (push) Has been skipped
2026-04-08 15:50:17 -04:00
6f10e7556f chore: deleted trash
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Failing after 18s
CI / Test (pytest) (3.12) (push) Failing after 18s
CI / SAST (bandit) (push) Failing after 11s
CI / Dependency audit (pip-audit) (push) Successful in 18s
CI / Open PR to main (push) Has been skipped
2026-04-08 02:07:11 -04:00
fc99375c62 feat: add systemd service templates for API and Web Dashboard
Some checks failed
CI / Lint (ruff) (push) Successful in 15s
CI / Test (pytest) (3.11) (push) Failing after 21s
CI / Test (pytest) (3.12) (push) Failing after 22s
CI / SAST (bandit) (push) Failing after 13s
CI / Dependency audit (pip-audit) (push) Successful in 19s
CI / Open PR to main (push) Has been skipped
2026-04-08 01:48:05 -04:00
6bdb5922fa fix: ensure shared log volume mount by default and disable container-side rotation 2026-04-08 01:42:05 -04:00
32b06afef6 feat: add .env based configuration for API, Web, and Auth options 2026-04-08 01:27:11 -04:00
31e0c5151b fix: ensure API-deployed deckies inherit the correct log ingestion path 2026-04-08 01:09:48 -04:00
cc3d434c02 feat: add server-side validation for web-based INI deployments 2026-04-08 01:04:59 -04:00
1b5d366b38 ui: add file upload support to web-based INI deployment 2026-04-08 00:59:53 -04:00
168ecf14ab feat: add API-only mode and web-based INI deployment 2026-04-08 00:56:25 -04:00
db9a2699b9 ui: fix dashboard overflow and overlap with sidebar 2026-04-08 00:44:33 -04:00
d139729fa2 docs: revert incorrect roadmap ticks 2026-04-08 00:38:03 -04:00
dd363629ab docs: update roadmap items in DEVELOPMENT.md 2026-04-08 00:35:43 -04:00
c544964f57 feat: migrate dashboard live logs to Server-Sent Events (SSE) 2026-04-08 00:30:31 -04:00
6e19848723 ui: improve mutation feedback and increase timeout for long-running docker ops 2026-04-08 00:22:23 -04:00
e24da92e0f fix: increase timeout for mutate API call to handle slow docker ops 2026-04-08 00:21:16 -04:00
47f0e6da8f fix: correctly iterate over all deckies in _build_deckies_from_ini 2026-04-08 00:19:42 -04:00
18de381a43 feat: implement dynamic decky mutation and fix dot-separated INI sections 2026-04-08 00:16:57 -04:00
eb40be2161 chore: split dev and normal dependencies in pyproject.toml 2026-04-08 00:09:15 -04:00
1f5c6604d6 feat: integrate API lifecycle with teardown and update dependencies 2026-04-07 23:30:08 -04:00
a9c7ddec2b fix: enforce absolute paths for state and database files 2026-04-07 23:21:16 -04:00
eb4be44c9a feat: add dedicated Decoy Fleet inventory page and API 2026-04-07 23:15:20 -04:00
1a2ad27eca test: add comprehensive property-based fuzzing for all API endpoints 2026-04-07 20:14:53 -04:00
b1f09b9c6a chore: move development docs to development/ and clean up project root 2026-04-07 20:07:56 -04:00
3656a89d60 docs: add comprehensive EVENTS.md detailing all service log events 2026-04-07 20:02:54 -04:00
ba2faba5d5 chore: enforce strict typing and internal naming conventions across web components 2026-04-07 19:56:15 -04:00
950280a97b feat: render structured syslog tags and msg in Dashboard 2026-04-07 15:56:45 -04:00
7bc8d75242 feat: parse RFC 5424 fields and msg directly in backend 2026-04-07 15:56:01 -04:00
5f637b5272 feat: switch to JSON-based log ingestion for higher reliability 2026-04-07 15:47:29 -04:00
6ed92d080f fix: invoke uvicorn via sys.executable to handle sudo PATH restrictions 2026-04-07 15:39:32 -04:00
1b593920cd feat: add --api flag to deploy and new web command for dashboard 2026-04-07 15:32:04 -04:00
bad90dfb75 feat: implement background log ingestion from local file 2026-04-07 15:30:44 -04:00
05e71f6d2e feat: frontend support for mandatory password change and react-router integration 2026-04-07 15:16:11 -04:00
52c26a2891 feat: backend support for mandatory password change on first login 2026-04-07 15:15:03 -04:00
81135cb861 fix: switch to direct bcrypt usage for Python 3.14 compatibility 2026-04-07 15:07:46 -04:00
50e53120df feat: initialize React frontend with minimalistic Matrix theme 2026-04-07 15:05:06 -04:00
697929a127 feat: implement Stats endpoints for web dashboard 2026-04-07 14:58:09 -04:00
b46934db46 feat: implement Logs endpoints for web dashboard 2026-04-07 14:56:25 -04:00
5b990743db feat: implement Auth endpoints for web dashboard 2026-04-07 14:54:36 -04:00
fbb16a960c feat: add web dashboard dependencies to support real-time monitoring 2026-04-07 14:51:37 -04:00
0927d9e1e8 Modified: DEVELOPMENT.md 2026-04-06 12:03:36 -04:00
9c81fb4739 revert f64c251a9e
revert revert f8a9f8fc64

revert Added: modified notes. Finished CI/CD pipeline.
2026-04-06 18:02:28 +02:00
e4171789a8 Added: documentation about the deaddeck archetype and how to run it. 2026-04-06 11:51:24 -04:00
c32ad82d0a Modified README: added more examples to the config.ini section and modified instructions for quick setup. 2026-04-06 11:28:29 -04:00
850a6f2ad7 Finished: CI/CD pipeline. 2026-04-06 11:18:10 -04:00
d344e4c8bb revert f8a9f8fc64
revert Added: modified notes. Finished CI/CD pipeline.
2026-04-06 17:17:31 +02:00
f64c251a9e revert f8a9f8fc64
revert Added: modified notes. Finished CI/CD pipeline.
2026-04-06 17:15:32 +02:00
c56c9fe667 Merge pull request 'Auto PR: dev → main' (#2) from dev into main
Some checks failed
Release / Auto-tag release (push) Successful in 14s
Release / Build, scan & push cowrie (push) Failing after 41s
Release / Build, scan & push docker_api (push) Failing after 30s
Release / Build, scan & push elasticsearch (push) Failing after 30s
Release / Build, scan & push ftp (push) Failing after 32s
Release / Build, scan & push http (push) Failing after 32s
Release / Build, scan & push imap (push) Failing after 31s
Release / Build, scan & push k8s (push) Failing after 32s
Release / Build, scan & push ldap (push) Failing after 30s
Release / Build, scan & push llmnr (push) Failing after 33s
Release / Build, scan & push mongodb (push) Failing after 32s
Release / Build, scan & push mqtt (push) Failing after 33s
Release / Build, scan & push mssql (push) Failing after 31s
Release / Build, scan & push mysql (push) Failing after 33s
Release / Build, scan & push pop3 (push) Failing after 33s
Release / Build, scan & push postgres (push) Failing after 32s
Release / Build, scan & push rdp (push) Failing after 32s
Release / Build, scan & push real_ssh (push) Failing after 33s
Release / Build, scan & push redis (push) Failing after 33s
Release / Build, scan & push sip (push) Failing after 33s
Release / Build, scan & push smb (push) Failing after 31s
Release / Build, scan & push smtp (push) Failing after 31s
Release / Build, scan & push snmp (push) Failing after 31s
Release / Build, scan & push tftp (push) Failing after 31s
Release / Build, scan & push vnc (push) Failing after 33s
Reviewed-on: #2
2026-04-06 17:11:54 +02:00
f8a9f8fc64 Added: modified notes. Finished CI/CD pipeline.
All checks were successful
PR Gate / Lint (ruff) (pull_request) Successful in 18s
PR Gate / Test (pytest) (3.11) (pull_request) Successful in 20s
PR Gate / Test (pytest) (3.12) (pull_request) Successful in 22s
2026-04-06 11:10:56 -04:00
a428410c8e Modified README.md: added AI disclosure 2026-04-06 11:09:44 -04:00
e5a6c2d9a7 Skip CI on markdown-only changes
All checks were successful
CI / Lint (ruff) (push) Successful in 16s
CI / Test (pytest) (3.11) (push) Successful in 19s
CI / Test (pytest) (3.12) (push) Successful in 20s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 19s
CI / Open PR to main (push) Successful in 6s
PR Gate / Lint (ruff) (pull_request) Successful in 11s
PR Gate / Test (pytest) (3.11) (pull_request) Successful in 18s
PR Gate / Test (pytest) (3.12) (pull_request) Successful in 20s
2026-04-04 23:07:40 -04:00
897f498bcd Merge dev into main: resolve conflicts, keep tests out of main
Some checks failed
Release / Auto-tag release (push) Successful in 14s
Release / Build, scan & push cowrie (push) Failing after 6m9s
Release / Build, scan & push docker_api (push) Failing after 31s
Release / Build, scan & push elasticsearch (push) Failing after 30s
Release / Build, scan & push ftp (push) Failing after 30s
Release / Build, scan & push http (push) Failing after 33s
Release / Build, scan & push imap (push) Failing after 30s
Release / Build, scan & push k8s (push) Failing after 30s
Release / Build, scan & push ldap (push) Failing after 33s
Release / Build, scan & push llmnr (push) Failing after 29s
Release / Build, scan & push mongodb (push) Failing after 30s
Release / Build, scan & push mqtt (push) Failing after 30s
Release / Build, scan & push mssql (push) Failing after 30s
Release / Build, scan & push mysql (push) Failing after 30s
Release / Build, scan & push pop3 (push) Failing after 32s
Release / Build, scan & push postgres (push) Failing after 29s
Release / Build, scan & push rdp (push) Failing after 29s
Release / Build, scan & push real_ssh (push) Failing after 31s
Release / Build, scan & push redis (push) Failing after 29s
Release / Build, scan & push sip (push) Failing after 30s
Release / Build, scan & push smb (push) Failing after 32s
Release / Build, scan & push smtp (push) Failing after 31s
Release / Build, scan & push snmp (push) Failing after 29s
Release / Build, scan & push tftp (push) Failing after 29s
Release / Build, scan & push vnc (push) Failing after 30s
2026-04-04 18:00:17 -03:00
ea409650fa Trigger CI: token now has repo:write permission
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 18s
CI / Test (pytest) (3.12) (push) Successful in 18s
CI / SAST (bandit) (push) Successful in 11s
CI / Dependency audit (pip-audit) (push) Successful in 19s
CI / Open PR to main (push) Successful in 5s
PR Gate / Lint (ruff) (pull_request) Successful in 10s
PR Gate / Test (pytest) (3.11) (pull_request) Successful in 17s
PR Gate / Test (pytest) (3.12) (pull_request) Successful in 18s
2026-04-04 17:54:37 -03:00
d92aa99b81 Add DEVELOPMENT.md for CI/CD pipeline test
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 17s
CI / Test (pytest) (3.12) (push) Successful in 19s
CI / SAST (bandit) (push) Successful in 11s
CI / Dependency audit (pip-audit) (push) Successful in 19s
CI / Open PR to main (push) Successful in 4s
2026-04-04 17:51:51 -03:00
fc7fca998f Add API response logging to open-pr step for debugging
All checks were successful
CI / Lint (ruff) (push) Successful in 10s
CI / Test (pytest) (3.11) (push) Successful in 19s
CI / Test (pytest) (3.12) (push) Successful in 18s
CI / SAST (bandit) (push) Successful in 11s
CI / Dependency audit (pip-audit) (push) Successful in 18s
CI / Open PR to main (push) Successful in 3s
2026-04-04 17:47:43 -03:00
ed749a8c31 Merge security jobs into CI workflow so open-pr needs all checks
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 18s
CI / Test (pytest) (3.12) (push) Successful in 19s
CI / SAST (bandit) (push) Successful in 12s
CI / Dependency audit (pip-audit) (push) Successful in 18s
CI / Open PR to main (push) Successful in 4s
2026-04-04 17:43:55 -03:00
cf36ebcd84 Auto-open PR to main when CI passes on dev
Some checks failed
CI / Lint (ruff) (push) Successful in 13s
CI / Test (pytest) (3.11) (push) Successful in 19s
CI / Test (pytest) (3.12) (push) Successful in 20s
Security / SAST (bandit) (push) Successful in 12s
CI / Open PR to main (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Successful in 18s
2026-04-04 17:42:42 -03:00
6a5c6f098e Remove accidentally committed artifacts and update .gitignore
All checks were successful
CI / Lint (ruff) (push) Successful in 11s
CI / Test (pytest) (3.11) (push) Successful in 18s
CI / Test (pytest) (3.12) (push) Successful in 18s
Security / SAST (bandit) (push) Successful in 12s
Security / Dependency audit (pip-audit) (push) Successful in 19s
2026-04-04 17:36:35 -03:00
988732f4f9 Fix all ruff lint errors across decnet/, templates/, and tests/
Some checks failed
CI / Test (pytest) (3.11) (push) Has been cancelled
CI / Test (pytest) (3.12) (push) Has been cancelled
Security / SAST (bandit) (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
CI / Lint (ruff) (push) Has been cancelled
2026-04-04 17:36:16 -03:00
4acfa3f779 Fix pip-audit skipping local editable package
Some checks failed
CI / Lint (ruff) (push) Failing after 10s
CI / Test (pytest) (3.11) (push) Successful in 18s
CI / Test (pytest) (3.12) (push) Successful in 19s
Security / SAST (bandit) (push) Successful in 11s
Security / Dependency audit (pip-audit) (push) Successful in 18s
2026-04-04 17:31:16 -03:00
35c67ec34d Fix registry auto-discovery skipping non-service subclasses (CustomService)
Some checks failed
CI / Lint (ruff) (push) Failing after 11s
CI / Test (pytest) (3.11) (push) Successful in 19s
CI / Test (pytest) (3.12) (push) Successful in 18s
Security / SAST (bandit) (push) Successful in 11s
Security / Dependency audit (pip-audit) (push) Successful in 18s
2026-04-04 17:29:30 -03:00
fe7354554f Add bandit, pip-audit and trivy to CI/CD security pipeline
Some checks failed
CI / Lint (ruff) (push) Failing after 10s
CI / Test (pytest) (3.11) (push) Failing after 39s
CI / Test (pytest) (3.12) (push) Failing after 1m4s
Security / SAST (bandit) (push) Successful in 11s
Security / Dependency audit (pip-audit) (push) Successful in 18s
2026-04-04 17:24:43 -03:00
b3b3597011 Add smoke test: verify all modules import cleanly
Some checks failed
CI / Lint (ruff) (push) Failing after 4s
CI / Test (pytest) (3.11) (push) Failing after 3s
CI / Test (pytest) (3.12) (push) Failing after 4s
2026-04-04 17:18:21 -03:00
92e06cb193 Add release workflow for auto-tagging and Docker image builds
Some checks failed
Release / Auto-tag release (push) Failing after 3s
Release / Build & push cowrie (push) Has been skipped
Release / Build & push docker_api (push) Has been skipped
Release / Build & push elasticsearch (push) Has been skipped
Release / Build & push ftp (push) Has been skipped
Release / Build & push http (push) Has been skipped
Release / Build & push imap (push) Has been skipped
Release / Build & push k8s (push) Has been skipped
Release / Build & push ldap (push) Has been skipped
Release / Build & push llmnr (push) Has been skipped
Release / Build & push mongodb (push) Has been skipped
Release / Build & push mqtt (push) Has been skipped
Release / Build & push mssql (push) Has been skipped
Release / Build & push mysql (push) Has been skipped
Release / Build & push pop3 (push) Has been skipped
Release / Build & push postgres (push) Has been skipped
Release / Build & push rdp (push) Has been skipped
Release / Build & push real_ssh (push) Has been skipped
Release / Build & push redis (push) Has been skipped
Release / Build & push sip (push) Has been skipped
Release / Build & push smb (push) Has been skipped
Release / Build & push smtp (push) Has been skipped
Release / Build & push snmp (push) Has been skipped
Release / Build & push tftp (push) Has been skipped
Release / Build & push vnc (push) Has been skipped
2026-04-04 17:16:53 -03:00
7ad7e1e53b main: remove tests and pytest dependency 2026-04-04 16:28:33 -03:00
289 changed files with 23652 additions and 3764 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"mcp__plugin_context-mode_context-mode__ctx_batch_execute"
]
}
}

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# API Options
DECNET_API_HOST=0.0.0.0
DECNET_API_PORT=8000
DECNET_JWT_SECRET=supersecretkey12345678901234567
DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log
# Web Dashboard Options
DECNET_WEB_HOST=0.0.0.0
DECNET_WEB_PORT=8080
DECNET_ADMIN_USER=admin
DECNET_ADMIN_PASSWORD=admin
DECNET_DEVELOPER=False

View File

@@ -2,7 +2,10 @@ name: CI
on: on:
push: push:
branches: [dev, testing] branches: [dev, testing, "temp/merge-*"]
paths-ignore:
- "**/*.md"
- "docs/**"
jobs: jobs:
lint: lint:
@@ -16,9 +19,33 @@ jobs:
- run: pip install ruff - run: pip install ruff
- run: ruff check . - run: ruff check .
test: bandit:
name: Test (pytest) name: SAST (bandit)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install bandit
- run: bandit -r decnet/ -ll -x decnet/services/registry.py
pip-audit:
name: Dependency audit (pip-audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pip-audit
- run: pip install -e .[dev]
- run: pip-audit --skip-editable
test-standard:
name: Test (Standard)
runs-on: ubuntu-latest
needs: [lint, bandit, pip-audit]
strategy: strategy:
matrix: matrix:
python-version: ["3.11", "3.12"] python-version: ["3.11", "3.12"]
@@ -27,5 +54,101 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- run: pip install -e . - run: pip install -e .[dev]
- run: pytest tests/ -v --tb=short - run: pytest
test-live:
name: Test (Live)
runs-on: ubuntu-latest
needs: [test-standard]
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e .[dev]
- run: pytest -m live
test-fuzz:
name: Test (Fuzz)
runs-on: ubuntu-latest
needs: [test-live]
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e .[dev]
- run: pytest -m fuzz
merge-to-testing:
name: Merge dev → testing
runs-on: ubuntu-latest
needs: [test-standard, test-live, test-fuzz]
if: github.ref == 'refs/heads/dev'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.DECNET_PR_TOKEN }}
- name: Configure git
run: |
git config user.name "DECNET CI"
git config user.email "ci@decnet.local"
- name: Merge dev into testing
run: |
git fetch origin testing
git checkout testing
git merge origin/dev --no-ff -m "ci: auto-merge dev → testing [skip ci]"
git push origin testing
prepare-merge-to-main:
name: Prepare Merge to Main
runs-on: ubuntu-latest
needs: [test-standard, test-live, test-fuzz]
if: github.ref == 'refs/heads/testing'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.DECNET_PR_TOKEN }}
- name: Configure git
run: |
git config user.name "DECNET CI"
git config user.email "ci@decnet.local"
- name: Create temp branch and sync with main
run: |
git fetch origin main
git checkout -b temp/merge-testing-to-main
echo "--- Switched to temp branch, merging main into it ---"
git merge origin/main --no-edit || { echo "CONFLICT: Manual resolution required"; exit 1; }
git push origin temp/merge-testing-to-main --force
finalize-merge-to-main:
name: Finalize Merge to Main
runs-on: ubuntu-latest
needs: [test-standard, test-live, test-fuzz]
if: startsWith(github.ref, 'refs/heads/temp/merge-')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.DECNET_PR_TOKEN }}
- name: Configure git
run: |
git config user.name "DECNET CI"
git config user.email "ci@decnet.local"
- name: Merge RC into main
run: |
git fetch origin main
git checkout main
git merge ${{ github.ref }} --no-ff -m "ci: auto-merge testing → main"
git push origin main
echo "--- Cleaning up temp branch ---"
git push origin --delete ${{ github.ref_name }}

View File

@@ -3,6 +3,9 @@ name: PR Gate
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
jobs: jobs:
lint: lint:
@@ -27,5 +30,28 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- run: pip install -e . - run: pip install -e .[dev]
- run: pytest tests/ -v --tb=short - run: pytest tests/ -v --tb=short
bandit:
name: SAST (bandit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install bandit
- run: bandit -r decnet/ -ll -x decnet/services/registry.py
pip-audit:
name: Dependency audit (pip-audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pip-audit
- run: pip install -e .[dev]
- run: pip-audit --skip-editable

View File

@@ -3,6 +3,9 @@ name: Release
on: on:
push: push:
branches: [main] branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
env: env:
REGISTRY: git.resacachile.cl REGISTRY: git.resacachile.cl
@@ -19,37 +22,48 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.DECNET_PR_TOKEN }}
- name: Extract version from pyproject.toml - name: Configure git
run: |
git config user.name "DECNET CI"
git config user.email "ci@decnet.local"
- name: Bump version and Tag
id: version id: version
run: | run: |
VERSION=$(python3 -c "import tomllib; f=open('pyproject.toml','rb'); d=tomllib.load(f); print(d['project']['version'])") # Calculate next version (v0.x)
echo "version=$VERSION" >> $GITHUB_OUTPUT LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0")
NEXT_VER=$(python3 -c "
- name: Create tag if not exists tag = '$LATEST_TAG'.lstrip('v')
id: tag parts = tag.split('.')
run: | major = int(parts[0]) if parts[0] else 0
VERSION=${{ steps.version.outputs.version }} minor = int(parts[1]) if len(parts) > 1 else 0
if git rev-parse "v$VERSION" >/dev/null 2>&1; then print(f'{major}.{minor + 1}')
echo "Tag v$VERSION already exists, skipping." ")
echo "created=false" >> $GITHUB_OUTPUT
else echo "Next version: $NEXT_VER (calculated from $LATEST_TAG)"
git config user.name "gitea-actions"
git config user.email "actions@git.resacachile.cl" # Update pyproject.toml
git tag -a "v$VERSION" -m "Release v$VERSION" sed -i "s/^version = \".*\"/version = \"$NEXT_VER\"/" pyproject.toml
git push origin "v$VERSION"
echo "created=true" >> $GITHUB_OUTPUT git add pyproject.toml
fi git commit -m "chore: auto-release v$NEXT_VER [skip ci]" || echo "No changes to commit"
git tag -a "v$NEXT_VER" -m "Auto-release v$NEXT_VER"
git push origin main --follow-tags
echo "version=$NEXT_VER" >> $GITHUB_OUTPUT
echo "created=true" >> $GITHUB_OUTPUT
docker: docker:
name: Build & push ${{ matrix.service }} name: Build, scan & push ${{ matrix.service }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: tag needs: tag
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
service: service:
- cowrie - conpot
- docker_api - docker_api
- elasticsearch - elasticsearch
- ftp - ftp
@@ -66,16 +80,20 @@ jobs:
- postgres - postgres
- rdp - rdp
- redis - redis
- real_ssh
- sip - sip
- smb - smb
- smtp - smtp
- snmp - snmp
- ssh
- telnet
- tftp - tftp
- vnc - vnc
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea container registry - name: Log in to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -83,7 +101,26 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push - name: Build image locally
uses: docker/build-push-action@v5
with:
context: templates/${{ matrix.service }}
load: true
push: false
tags: decnet-${{ matrix.service }}:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: decnet-${{ matrix.service }}:scan
exit-code: "1"
severity: CRITICAL
ignore-unfixed: true
- name: Push image
if: success()
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: templates/${{ matrix.service }} context: templates/${{ matrix.service }}
@@ -91,3 +128,4 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/decnet-${{ matrix.service }}:latest ${{ env.REGISTRY }}/${{ env.OWNER }}/decnet-${{ matrix.service }}:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/decnet-${{ matrix.service }}:v${{ needs.tag.outputs.version }} ${{ env.REGISTRY }}/${{ env.OWNER }}/decnet-${{ matrix.service }}:v${{ needs.tag.outputs.version }}
cache-from: type=gha

14
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.venv/ .venv/
logs/
.claude/
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
@@ -9,3 +11,15 @@ decnet-compose.yml
decnet-state.json decnet-state.json
*.ini *.ini
.env .env
decnet.log*
*.loggy
*.nmap
linterfails.log
webmail
windows1
*.db
decnet.json
.env
.env.local
.coverage
.hypothesis/

View File

@@ -46,6 +46,7 @@ DECNET is a honeypot/deception network framework. It deploys fake machines (call
- The logging/aggregation network must be isolated from the decoy network. - The logging/aggregation network must be isolated from the decoy network.
- A publicly accessible real server acts as the bridge between the two networks. - A publicly accessible real server acts as the bridge between the two networks.
- Deckies should differ in exposed services and OS fingerprints to appear as a heterogeneous network. - Deckies should differ in exposed services and OS fingerprints to appear as a heterogeneous network.
- **IMPORTANT**: The system now strictly enforces dependency injection for storage. Do not import `SQLiteRepository` directly in new features; instead, use `get_repository()` from the factory or the FastAPI `get_repo` dependency.
## Development and testing ## Development and testing
@@ -54,3 +55,4 @@ DECNET is a honeypot/deception network framework. It deploys fake machines (call
- NEVER pass broken code to the user. - NEVER pass broken code to the user.
- Broken means: not running, not passing 100% tests, etc. - Broken means: not running, not passing 100% tests, etc.
- After tests pass with 100%, always git commit your changes. - After tests pass with 100%, always git commit your changes.
- NEVER add "Co-Authored-By" or any Claude attribution lines to git commit messages.

104
GEMINI.md Normal file
View File

@@ -0,0 +1,104 @@
# DECNET (Deception Network) Project Context
DECNET is a high-fidelity honeypot framework designed to deploy heterogeneous fleets of fake machines (called **deckies**) that appear as real hosts on a local network.
## Project Overview
- **Core Purpose:** To lure, profile, and log attacker interactions within a controlled, deceptive environment.
- **Key Technology:** Linux-native container networking (MACVLAN/IPvlan) combined with Docker to give each decoy its own MAC address, IP, and realistic TCP/IP stack behavior.
- **Main Components:**
- **Deckies:** Group of containers sharing a network namespace (one base container + multiple service containers).
- **Archetypes:** Pre-defined machine profiles (e.g., `windows-workstation`, `linux-server`) that bundle services and OS fingerprints.
- **Services:** Modular honeypot plugins (SSH, SMB, RDP, etc.) built as `BaseService` subclasses.
- **OS Fingerprinting:** Sysctl-based TCP/IP stack tuning to spoof OS detection (nmap).
- **Logging Pipeline:** RFC 5424 syslog forwarding to an isolated SIEM/ELK stack.
## Technical Stack
- **Language:** Python 3.11+
- **CLI Framework:** [Typer](https://typer.tiangolo.com/)
- **Data Validation:** [Pydantic v2](https://docs.pydantic.dev/)
- **Orchestration:** Docker Engine 24+ (via Docker SDK for Python)
- **Networking:** MACVLAN (default) or IPvlan L2 (for WiFi/restricted environments).
- **Testing:** Pytest (100% pass requirement).
- **Formatting/Linting:** Ruff, Bandit (SAST), pip-audit.
## Architecture
```text
Host NIC (eth0)
└── MACVLAN Bridge
├── Decky-01 (192.168.1.10) -> [Base] + [SSH] + [HTTP]
├── Decky-02 (192.168.1.11) -> [Base] + [SMB] + [RDP]
└── ...
```
- **Base Container:** Owns the IP/MAC, sets `sysctls` for OS spoofing, and runs `sleep infinity`.
- **Service Containers:** Use `network_mode: service:<base>` to share the identity and networking of the base container.
- **Isolation:** Decoy traffic is strictly separated from the logging network.
## Key Commands
### Development & Maintenance
- **Install (Dev):**
- `rm .venv -rf`
- `python3 -m venv .venv`
- `source .venv/bin/activate`
- `pip install -e .`
- **Run Tests:** `pytest` (Run before any commit)
- **Linting:** `ruff check .`
- **Security Scan:** `bandit -r decnet/`
- **Web Git:** git.resacachile.cl (Gitea)
### CLI Usage
- **List Services:** `decnet services`
- **List Archetypes:** `decnet archetypes`
- **Dry Run (Compose Gen):** `decnet deploy --deckies 3 --randomize-services --dry-run`
- **Deploy (Full):** `sudo .venv/bin/decnet deploy --interface eth0 --deckies 5 --randomize-services`
- **Status:** `decnet status`
- **Teardown:** `sudo .venv/bin/decnet teardown --all`
## Development Conventions
- **Code Style:**
- Strict adherence to Ruff/PEP8.
- **Always use typed variables**. If any non-types variables are found, they must be corrected.
- The correct way is `x: int = 1`, never `x : int = 1`.
- If assignment is present, always use a space between the type and the equal sign `x: int = 1`.
- **Never** use lowercase L (l), uppercase o (O) or uppercase i (i) in single-character names.
- **Internal vars are to be declared with an underscore** (_internal_variable_name).
- **Internal to internal vars are to be declared with double underscore** (__internal_variable_name).
- Always use snake_case for code.
- Always use PascalCase for classes and generics.
- **Testing:** New features MUST include a `pytest` case. 100% test pass rate is mandatory before merging.
- **Plugin System:**
- New services go in `decnet/services/<name>.py`.
- Subclass `decnet.services.base.BaseService`.
- The registry uses auto-discovery; no manual registration required.
- **Configuration:**
- Use Pydantic models in `decnet/config.py` for any new settings.
- INI file parsing is handled in `decnet/ini_loader.py`.
- **State Management:**
- Runtime state is persisted in `decnet-state.json`.
- Do not modify this file manually.
- **General Development Guidelines**:
- **Never** commit broken code, or before running `pytest`s or `bandit` at the project level.
- **No matter how small** the changes, they must be committed.
- **If new features are addedd** new tests must be added, too.
- **Never present broken code to the user**. Test, validate, then present.
- **Extensive testing** for every function must be created.
- **Always develop in the `dev` branch, never in `main`.**
- **Test in the `testing` branch.**
- **IMPORTANT**: The system now strictly enforces dependency injection for storage. Do not import `SQLiteRepository` directly in new features; instead, use `get_repository()` from the factory or the FastAPI `get_repo` dependency.
## Directory Structure
- `decnet/`: Main source code.
- `services/`: Honeypot service implementations.
- `logging/`: Syslog formatting and forwarding logic.
- `correlation/`: (In Progress) Logic for grouping attacker events.
- `templates/`: Dockerfiles and entrypoint scripts for services.
- `tests/`: Pytest suite.
- `pyproject.toml`: Dependency and entry point definitions.
- `CLAUDE.md`: Claude-specific environment guidance.
- `DEVELOPMENT.md`: Roadmap and TODOs.

View File

@@ -69,7 +69,7 @@ From the outside a decky looks identical to a real machine: it has its own MAC a
## Installation ## Installation
```bash ```bash
git clone <repo-url> DECNET git clone https://git.resacachile.cl/anti/DECNET
cd DECNET cd DECNET
pip install -e . pip install -e .
``` ```
@@ -180,6 +180,7 @@ Archetypes are pre-packaged machine identities. One slug sets services, preferre
| Slug | Services | OS Fingerprint | Description | | Slug | Services | OS Fingerprint | Description |
|---|---|---|---| |---|---|---|---|
| `deaddeck` | ssh | linux | Initial machine to be exploited. Real SSH container. |
| `windows-workstation` | smb, rdp | windows | Corporate Windows desktop | | `windows-workstation` | smb, rdp | windows | Corporate Windows desktop |
| `windows-server` | smb, rdp, ldap | windows | Windows domain member | | `windows-server` | smb, rdp, ldap | windows | Windows domain member |
| `domain-controller` | ldap, smb, rdp, llmnr | windows | Active Directory DC | | `domain-controller` | ldap, smb, rdp, llmnr | windows | Active Directory DC |
@@ -207,6 +208,26 @@ sudo decnet deploy --deckies 4 --archetype windows-workstation
[corp-workstations] [corp-workstations]
archetype = windows-workstation archetype = windows-workstation
amount = 4 amount = 4
[win-fileserver]
services = ftp
nmap_os = windows
os_version = Windows Server 2019
[dbsrv01]
ip = 192.168.1.112
services = mysql, http
nmap_os = linux
[dbsrv01.http]
server_header = Apache/2.4.54 (Debian)
response_code = 200
fake_app = wordpress
[dbsrv01.mysql]
mysql_version = 5.7.38-log
mysql_banner = MySQL Community Server
``` ```
--- ---
@@ -250,6 +271,11 @@ List live at any time with `decnet services`.
Most services accept persona configuration to make honeypot responses more convincing. Config is passed via INI subsections (`[decky-name.service]`) or the `service_config` field in code. Most services accept persona configuration to make honeypot responses more convincing. Config is passed via INI subsections (`[decky-name.service]`) or the `service_config` field in code.
```ini ```ini
[deaddeck-1]
amount=1
archetype=deaddeck
ssh.password=admin
[decky-webmail.http] [decky-webmail.http]
server_header = Apache/2.4.54 (Debian) server_header = Apache/2.4.54 (Debian)
fake_app = wordpress fake_app = wordpress
@@ -454,7 +480,7 @@ Key/value pairs are passed directly to the service plugin as persona config. Com
| `mongodb` | `mongo_version` | | `mongodb` | `mongo_version` |
| `elasticsearch` | `es_version`, `cluster_name` | | `elasticsearch` | `es_version`, `cluster_name` |
| `ldap` | `base_dn`, `domain` | | `ldap` | `base_dn`, `domain` |
| `snmp` | `snmp_community`, `sys_descr` | | `snmp` | `snmp_community`, `sys_descr`, `snmp_archetype` (picks predefined sysDescr for `water_plant`, `hospital`, etc.) |
| `mqtt` | `mqtt_version` | | `mqtt` | `mqtt_version` |
| `sip` | `sip_server`, `sip_domain` | | `sip` | `sip_server`, `sip_domain` |
| `k8s` | `k8s_version` | | `k8s` | `k8s_version` |
@@ -470,6 +496,30 @@ See [`test-full.ini`](test-full.ini) — covers all 25 services across 10 role-t
--- ---
## Environment Configuration (.env)
DECNET supports loading configuration from `.env.local` and `.env` files located in the project root. This is useful for securing secrets like the JWT key and configuring default ports without passing flags every time.
An example `.env.example` is provided:
```ini
# API Options
DECNET_API_HOST=0.0.0.0
DECNET_API_PORT=8000
DECNET_JWT_SECRET=supersecretkey12345
DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log
# Web Dashboard Options
DECNET_WEB_HOST=0.0.0.0
DECNET_WEB_PORT=8080
DECNET_ADMIN_USER=admin
DECNET_ADMIN_PASSWORD=admin
```
Copy `.env.example` to `.env.local` and modify it to suit your environment.
---
## Logging ## Logging
All attacker interactions are forwarded off the decoy network to an isolated logging sink. The log pipeline lives on a separate internal Docker bridge (`decnet_logs`) that is not reachable from the fake LAN. All attacker interactions are forwarded off the decoy network to an isolated logging sink. The log pipeline lives on a separate internal Docker bridge (`decnet_logs`) that is not reachable from the fake LAN.
@@ -631,3 +681,9 @@ The test suite covers:
| `test_cli_service_pool.py` | CLI service resolution | | `test_cli_service_pool.py` | CLI service resolution |
Every new feature requires passing tests before merging. Every new feature requires passing tests before merging.
# AI Disclosure
This project has been made with lots, and I mean lots of help from AIs. While most of the design was made by me, most of the coding was done by AI models.
Nevertheless, this project will be kept under high scrutiny by humans.

1
decnet.collector.log Normal file
View File

@@ -0,0 +1 @@
Collector starting → /home/anti/Tools/DECNET/decnet.log

View File

@@ -1,159 +0,0 @@
<134>1 2026-04-04T07:40:53.045660+00:00 decky-devops k8s - startup - Kubernetes API server starting as decky-devops
<134>1 2026-04-04T07:40:53.058000+00:00 decky-devops docker_api - startup - Docker API server starting as decky-devops
<134>1 2026-04-04T07:40:53.147349+00:00 decky-legacy vnc - startup - VNC server starting as decky-legacy
<134>1 2026-04-04T07:40:53.224094+00:00 decky-fileserv tftp - startup - TFTP server starting as decky-fileserv
<134>1 2026-04-04T07:40:53.231313+00:00 decky-fileserv ftp - startup - FTP server starting as decky-fileserv on port 21
<134>1 2026-04-04T07:40:53.237175+00:00 decky-fileserv smb - startup - SMB server starting as decky-fileserv
<134>1 2026-04-04T07:40:53.331998+00:00 decky-webmail imap - startup - IMAP server starting as decky-webmail
<134>1 2026-04-04T07:40:53.441710+00:00 decky-webmail http - startup - HTTP server starting as decky-webmail
<134>1 2026-04-04T07:40:53.482287+00:00 decky-webmail smtp - startup - SMTP server starting as decky-webmail
<134>1 2026-04-04T07:40:53.487752+00:00 decky-webmail pop3 - startup - POP3 server starting as decky-webmail
<134>1 2026-04-04T07:40:53.493478+00:00 decky-iot mqtt - startup - MQTT server starting as decky-iot
<134>1 2026-04-04T07:40:53.519136+00:00 decky-iot snmp - startup - SNMP server starting as decky-iot
<134>1 2026-04-04T07:40:53.586186+00:00 decky-voip sip - startup - SIP server starting as decky-voip
<134>1 2026-04-04T07:40:53.734237+00:00 decky-dbsrv02 postgres - startup - PostgreSQL server starting as decky-dbsrv02
<134>1 2026-04-04T07:40:53.746573+00:00 decky-voip llmnr - startup - LLMNR/mDNS server starting as decky-voip
<134>1 2026-04-04T07:40:53.792767+00:00 decky-dbsrv02 elasticsearch - startup - Elasticsearch server starting as decky-dbsrv02
<134>1 2026-04-04T07:40:53.817558+00:00 decky-dbsrv02 mongodb - startup - MongoDB server starting as decky-dbsrv02
<134>1 2026-04-04T07:40:53.848912+00:00 decky-ldapdc ldap - startup - LDAP server starting as decky-ldapdc
<134>1 2026-04-04T07:40:53.860378+00:00 decky-winbox rdp - startup - RDP server starting as decky-winbox on port 3389
<134>1 2026-04-04T07:40:53.911084+00:00 decky-winbox mssql - startup - MSSQL server starting as decky-winbox
<134>1 2026-04-04T07:40:53.978994+00:00 decky-winbox smb - startup - SMB server starting as decky-winbox
<134>1 2026-04-04T07:41:07.439918+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="46462"]
<134>1 2026-04-04T07:41:07.439922+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="54734"]
<134>1 2026-04-04T07:41:07.439868+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="54606"]
<134>1 2026-04-04T07:41:07.440333+00:00 decky-fileserv ftp - connection [decnet@55555 src_ip="192.168.1.5" src_port="39736"]
<134>1 2026-04-04T07:41:07.442465+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:13.446744+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="GET / HTTP/1.0"]
<134>1 2026-04-04T07:41:13.446743+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd=""]
<134>1 2026-04-04T07:41:13.447251+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd=""]
<134>1 2026-04-04T07:41:13.446995+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{}" body=""]
<134>1 2026-04-04T07:41:13.447556+00:00 decky-fileserv ftp - disconnect [decnet@55555 src_ip="192.168.1.5" src_port="39736"]
<134>1 2026-04-04T07:41:18.451412+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:18.451529+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:18.451729+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="55996"]
<134>1 2026-04-04T07:41:18.451746+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="36592"]
<134>1 2026-04-04T07:41:18.451844+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="OPTIONS / HTTP/1.0"]
<134>1 2026-04-04T07:41:18.451928+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd=""]
<134>1 2026-04-04T07:41:23.456442+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:23.456408+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.734697+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="36604"]
<134>1 2026-04-04T07:41:24.736542+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="36606"]
<134>1 2026-04-04T07:41:24.737069+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="56204"]
<134>1 2026-04-04T07:41:24.737449+00:00 decky-fileserv ftp - connection [decnet@55555 src_ip="192.168.1.5" src_port="48992"]
<134>1 2026-04-04T07:41:24.737834+00:00 decky-fileserv ftp - connection [decnet@55555 src_ip="192.168.1.5" src_port="48994"]
<134>1 2026-04-04T07:41:24.738282+00:00 decky-fileserv ftp - connection [decnet@55555 src_ip="192.168.1.5" src_port="49002"]
<134>1 2026-04-04T07:41:24.738760+00:00 decky-fileserv ftp - connection [decnet@55555 src_ip="192.168.1.5" src_port="49004"]
<134>1 2026-04-04T07:41:24.739240+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="36622"]
<134>1 2026-04-04T07:41:24.741300+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="STLS"]
<134>1 2026-04-04T07:41:24.741346+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="STLS"]
<134>1 2026-04-04T07:41:24.741319+00:00 decky-webmail smtp - ehlo [decnet@55555 src="192.168.1.5" domain="nmap.scanme.org"]
<134>1 2026-04-04T07:41:24.741391+00:00 decky-fileserv ftp - user [decnet@55555 username="anonymous"]
<134>1 2026-04-04T07:41:24.741474+00:00 decky-fileserv ftp - user [decnet@55555 username="anonymous"]
<134>1 2026-04-04T07:41:24.741374+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/nmaplowercheck1775288484" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.741566+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/.git/HEAD" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.741988+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.742327+00:00 decky-webmail http - request [decnet@55555 method="PROPFIND" path="/" remote_addr="192.168.1.5" headers="{'Depth': '0', 'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.742608+00:00 decky-webmail http - request [decnet@55555 method="POST" path="/" remote_addr="192.168.1.5" headers="{'Content-Length': '88', 'Connection': 'close', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Content-Type': 'application/x-www-form-urlencoded', 'Host': '192.168.1.110'}" body="<methodCall> <methodName>system.listMethods</methodName> <params></params> </methodCall>"]
<134>1 2026-04-04T07:41:24.742807+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.741701+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{}" body=""]
<134>1 2026-04-04T07:41:24.742699+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.742135+00:00 decky-webmail http - request [decnet@55555 method="POST" path="/sdk" remote_addr="192.168.1.5" headers="{'Content-Length': '441', 'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body="<soap:Envelope xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"><soap:Header><operationID>00000001-00000001</operationID></soap:Header><soap:Body><RetrieveServiceContent xmlns=\"urn:internalvim25\"><_this xsi:type=\"ManagedObjectReference\" type=\"ServiceInstance\">ServiceInstance</_this></RetrieveServiceContent></soap:Body></soap:Envelope>"]
<134>1 2026-04-04T07:41:24.742460+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'HEAD', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:24.745408+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.745793+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.745837+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="AUTH NTLM"]
<134>1 2026-04-04T07:41:24.745797+00:00 decky-fileserv ftp - user [decnet@55555 username="anonymous"]
<134>1 2026-04-04T07:41:24.745960+00:00 decky-fileserv ftp - auth_attempt [decnet@55555 username="anonymous" password="IEUser@"]
<134>1 2026-04-04T07:41:24.745842+00:00 decky-webmail http - request [decnet@55555 method="FGDH" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.746083+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="56216"]
<134>1 2026-04-04T07:41:24.746041+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="56008"]
<134>1 2026-04-04T07:41:24.745961+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'GET', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:24.746514+00:00 decky-fileserv ftp - auth_attempt [decnet@55555 username="anonymous" password="IEUser@"]
<134>1 2026-04-04T07:41:24.746245+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/NmapUpperCheck1775288484" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.746723+00:00 decky-fileserv ftp - disconnect [decnet@55555 src_ip="192.168.1.5" src_port="48994"]
<134>1 2026-04-04T07:41:24.746073+00:00 decky-webmail http - request [decnet@55555 method="PROPFIND" path="/" remote_addr="192.168.1.5" headers="{'Content-Length': '0', 'Connection': 'close', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Host': '192.168.1.110', 'Depth': '1'}" body=""]
<134>1 2026-04-04T07:41:24.795603+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="TlRMTVNTUAABAAAAB4IIoAAAAAAAAAAAAAAAAAAAAAA="]
<134>1 2026-04-04T07:41:24.795629+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.795621+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="56016"]
<134>1 2026-04-04T07:41:24.795604+00:00 decky-fileserv ftp - auth_attempt [decnet@55555 username="anonymous" password="IEUser@"]
<134>1 2026-04-04T07:41:24.795738+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.795928+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/robots.txt" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.796118+00:00 decky-webmail http - request [decnet@55555 method="PROPFIND" path="/" remote_addr="192.168.1.5" headers="{'Depth': '0', 'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.845180+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="56226"]
<134>1 2026-04-04T07:41:24.845355+00:00 decky-webmail smtp - ehlo [decnet@55555 src="192.168.1.5" domain="nmap.scanme.org"]
<134>1 2026-04-04T07:41:24.845379+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'POST', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:24.894554+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.894871+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/Nmap/folder/check1775288484" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.895133+00:00 decky-webmail http - request [decnet@55555 method="POST" path="/" remote_addr="192.168.1.5" headers="{'Content-Length': '0', 'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:24.944224+00:00 decky-webmail smtp - ehlo [decnet@55555 src="192.168.1.5" domain="nmap.scanme.org"]
<134>1 2026-04-04T07:41:24.944215+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="56032"]
<134>1 2026-04-04T07:41:24.944346+00:00 decky-webmail smtp - unknown_command [decnet@55555 src="192.168.1.5" command="HELP"]
<134>1 2026-04-04T07:41:24.994175+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:24.994238+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="56234"]
<134>1 2026-04-04T07:41:24.994534+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'PUT', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:25.044450+00:00 decky-webmail smtp - auth_attempt [decnet@55555 src="192.168.1.5" command="AUTH NTLM"]
<134>1 2026-04-04T07:41:25.044450+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="000b AUTHENTICATE NTLM"]
<134>1 2026-04-04T07:41:25.044580+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:25.044674+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:25.093812+00:00 decky-webmail smtp - ehlo [decnet@55555 src="192.168.1.5" domain="nmap.scanme.org"]
<134>1 2026-04-04T07:41:25.094022+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/favicon.ico" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Connection': 'close'}" body=""]
<134>1 2026-04-04T07:41:25.142989+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="TlRMTVNTUAABAAAAB4IIoAAAAAAAAAAAAAAAAAAAAAA="]
<134>1 2026-04-04T07:41:25.143126+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'DELETE', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:25.241565+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:25.241690+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:25.290930+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:25.291070+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'TRACE', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:25.438930+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'OPTIONS', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:25.586609+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'CONNECT', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:25.734144+00:00 decky-webmail http - request [decnet@55555 method="OPTIONS" path="/" remote_addr="192.168.1.5" headers="{'Connection': 'close', 'Origin': 'example.com', 'User-Agent': 'Mozilla/5.0 (compatible; Nmap Scripting Engine; https://nmap.org/book/nse.html)', 'Access-Control-Request-Method': 'PATCH', 'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:29.778527+00:00 decky-fileserv ftp - disconnect [decnet@55555 src_ip="192.168.1.5" src_port="49004"]
<134>1 2026-04-04T07:41:31.976898+00:00 decky-fileserv ftp - disconnect [decnet@55555 src_ip="192.168.1.5" src_port="48992"]
<134>1 2026-04-04T07:41:33.746244+00:00 decky-fileserv ftp - disconnect [decnet@55555 src_ip="192.168.1.5" src_port="49002"]
<134>1 2026-04-04T07:41:33.747544+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="39972"]
<134>1 2026-04-04T07:41:33.748339+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{}" body=""]
<134>1 2026-04-04T07:41:33.748742+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="39984"]
<134>1 2026-04-04T07:41:33.748916+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="($<03>i<EFBFBD><69>jÁ{Bк<>F<EFBFBD><46><02><>(ri[;z <09>s~_?<3F> <20>+Ō,7n/.<0F><><14>P<EFBFBD>PO<50><4F>3=<3D>\\<5C>0RS<19>r395/<2F>,<2C>0<00>̨̩̪<CCA8><CCAA><EFBFBD><EFBFBD><EFBFBD>\]<5D>a<EFBFBD>S<EFBFBD>+<2B>/<00><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>\\<5C>`<60>R<EFBFBD>$"]
<134>1 2026-04-04T07:41:33.748959+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<22><00><> <09>E<00><><EFBFBD><EFBFBD>Q<00><><EFBFBD><EFBFBD>P=<00><<00><00>Ai<> "]
<134>1 2026-04-04T07:41:33.748983+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<11><11><11>#
(&    "]
<134>1 2026-04-04T07:41:33.749009+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd=" +-3<04><04><11><04>aq<61>څv<DA85>+DS[\\<5C><><EFBFBD>c-'4R<34>(<28><>a<EFBFBD>J<08><>L<>2^7<><37>luѡ<75><D1A1>v<EFBFBD>^<05>g%Y<><59><EFBFBD><EFBFBD>Sx<53>r<EFBFBD>-jR<12><>C#b<><62><EFBFBD>r<EFBFBD><72>"]
<134>1 2026-04-04T07:41:33.749035+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<22>i<EFBFBD><69><EFBFBD>TLػ<4C><13>A<EFBFBD>1<EFBFBD>s<EFBFBD><73>'"]
<134>1 2026-04-04T07:41:33.749060+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<22>4,<2C><> <0C>
<EFBFBD>G<1B><>q<EFBFBD>–B仠<42><01> K7O<37>Y<EFBFBD>rq<><71><EFBFBD>3VtzD<7A><44>̨"]
<134>1 2026-04-04T07:41:33.749041+00:00 decky-webmail http - request [decnet@55555 method="GET" path="/" remote_addr="192.168.1.5" headers="{'Host': '192.168.1.110'}" body=""]
<134>1 2026-04-04T07:41:33.749083+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="Ѓu<0F>Y<EFBFBD><59><EFBFBD><EFBFBD>-<2D>\"<22><>eSp*Zֹ L<><4C>{ <09>#<23>:<3A><><EFBFBD><EFBFBD>9!ɂCm<43>I<EFBFBD>$ݦ1ϻo-H<><48><EFBFBD>*<2A><17>X<EFBFBD><58>{<7B><><EFBFBD><EFBFBD>p<EFBFBD>ޚ|W<><57>ƫf <16><>T<EFBFBD>%<25>F5<46>8<EFBFBD><38><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>WU<57>a<EFBFBD><61>c ><3E><> u\]<5D><>i~<7E>V<EFBFBD><56><EFBFBD>&<26>z"]
<134>1 2026-04-04T07:41:33.749104+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<22>1<16>\\<5C><>Wc<57>C<EFBFBD><1B><><76>6z<36> <20><>0<EFBFBD>$iS<69> 3'<27>8<<3C>"]
<134>1 2026-04-04T07:41:33.749122+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<10><>2<EFBFBD><32>"]
<134>1 2026-04-04T07:41:33.749138+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="<22><>\"/<2F> <0B>E<08><><EFBFBD>tv!"]
<134>1 2026-04-04T07:41:33.749160+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="񋞸<>)<29><>[j}<7D>`<60><>\\V|k<><6B>ԣy<D4A3>Y<EFBFBD><59>?<05>2<EFBFBD>`<17>w¬ܶ#<23>X}<7D><>[cg3<67>W8E<38>tl<74>y<<3C>Z<1B>ʇ<EFBFBD><CA87><EFBFBD>% dQBk9=+<2B><07>ȳ<EFBFBD><16><>(<28>y<EFBFBD><79><EFBFBD><EFBFBD>*[8<><38><EFBFBD>qyN`<60><><EFBFBD>5>j<><6A> 825<13>f<EFBFBD><66>2. s\\dLar"]
<134>1 2026-04-04T07:41:33.749238+00:00 decky-webmail imap - connect [decnet@55555 src="192.168.1.5" src_port="39996"]
<134>1 2026-04-04T07:41:33.749290+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="WSi<><69><EFBFBD>,g<>O<EFBFBD>(T<>YC<59><01>ѢO<D1A2>Ę<EFBFBD><16><><EFBFBD><EFBFBD>"]
<134>1 2026-04-04T07:41:33.749328+00:00 decky-webmail imap - command [decnet@55555 src="192.168.1.5" cmd="/"]
<134>1 2026-04-04T07:41:33.749369+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.749411+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.749441+00:00 decky-webmail imap - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.749484+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="47822"]
<134>1 2026-04-04T07:41:33.749708+00:00 decky-webmail smtp - ehlo [decnet@55555 src="192.168.1.5" domain="nmap.scanme.org"]
<134>1 2026-04-04T07:41:33.749852+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.749936+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="47834"]
<134>1 2026-04-04T07:41:33.750118+00:00 decky-webmail smtp - connect [decnet@55555 src="192.168.1.5" src_port="47846"]
<134>1 2026-04-04T07:41:33.750202+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.750261+00:00 decky-webmail smtp - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.750423+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="48678"]
<134>1 2026-04-04T07:41:33.750684+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="STLS"]
<134>1 2026-04-04T07:41:33.750772+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.750852+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="48684"]
<134>1 2026-04-04T07:41:33.750920+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="($h_\\n<>W<EFBFBD>f 6~<10><><EFBFBD>'U<><55>ԥ\"{<7B><><EFBFBD>jg<04> <20>*M<>$<24><><EFBFBD>at}5gq<67><71>)<29>X<7F>w<EFBFBD>7<EFBFBD><37>_<>r395/<2F>,<2C>0<00>̨̩̪<CCA8><CCAA><EFBFBD><EFBFBD><EFBFBD>\]<5D>a<EFBFBD>S<EFBFBD>+<2B>/<00><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>\\<5C>`<60>R<EFBFBD>"]
<134>1 2026-04-04T07:41:33.750964+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="<22><00><> <09>E<00><><EFBFBD><EFBFBD>Q<00><><EFBFBD><EFBFBD>P=<00><<00><00>Ai<> "]
<134>1 2026-04-04T07:41:33.750997+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="<11><11><11>#
(&    "]
<134>1 2026-04-04T07:41:33.751027+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd=" +-3<04><04><11><04><>pEt<45>\"g3<67>Ff` c<>FY4<59>2<EFBFBD>$3<>t<EFBFBD><74>Q<EFBFBD>QKR/ <20>+5<><35><EFBFBD><EFBFBD> q
<EFBFBD>&<26>@<40><><EFBFBD><EFBFBD><07><><1F>B<EFBFBD><42>(?<3F>3<EFBFBD>R/
<EFBFBD>3<EFBFBD>qr<EFBFBD>! <20>"]
<134>1 2026-04-04T07:41:33.751096+00:00 decky-webmail pop3 - connect [decnet@55555 src="192.168.1.5" src_port="48698"]
<134>1 2026-04-04T07:41:33.751153+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="WSi<><69><EFBFBD>{<7B><><EFBFBD>5<EFBFBD><35><01>R<EFBFBD><52>!<21>;jj
<EFBFBD><EFBFBD><EFBFBD> "]
<134>1 2026-04-04T07:41:33.751197+00:00 decky-webmail pop3 - command [decnet@55555 src="192.168.1.5" cmd="/"]
<134>1 2026-04-04T07:41:33.751245+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]
<134>1 2026-04-04T07:41:33.751285+00:00 decky-webmail pop3 - disconnect [decnet@55555 src="192.168.1.5"]

View File

@@ -148,7 +148,7 @@ ARCHETYPES: dict[str, Archetype] = {
slug="deaddeck", slug="deaddeck",
display_name="Deaddeck (Entry Point)", display_name="Deaddeck (Entry Point)",
description="Internet-facing entry point with real interactive SSH — no honeypot emulation", description="Internet-facing entry point with real interactive SSH — no honeypot emulation",
services=["real_ssh"], services=["ssh"],
preferred_distros=["debian", "ubuntu22"], preferred_distros=["debian", "ubuntu22"],
nmap_os="linux", nmap_os="linux",
), ),
@@ -167,4 +167,4 @@ def all_archetypes() -> dict[str, Archetype]:
def random_archetype() -> Archetype: def random_archetype() -> Archetype:
return random.choice(list(ARCHETYPES.values())) return random.choice(list(ARCHETYPES.values())) # nosec B311

View File

@@ -8,21 +8,27 @@ Usage:
decnet services decnet services
""" """
import random import signal
from typing import Optional from typing import Optional
import typer import typer
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from decnet.env import (
DECNET_API_HOST,
DECNET_API_PORT,
DECNET_INGEST_LOG_FILE,
DECNET_WEB_HOST,
DECNET_WEB_PORT,
)
from decnet.archetypes import Archetype, all_archetypes, get_archetype from decnet.archetypes import Archetype, all_archetypes, get_archetype
from decnet.config import ( from decnet.config import (
DeckyConfig,
DecnetConfig, DecnetConfig,
random_hostname,
) )
from decnet.distros import all_distros, get_distro, random_distro from decnet.distros import all_distros, get_distro
from decnet.ini_loader import IniConfig, load_ini from decnet.fleet import all_service_names, build_deckies, build_deckies_from_ini
from decnet.ini_loader import load_ini
from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip
from decnet.services.registry import all_services from decnet.services.registry import all_services
@@ -33,167 +39,56 @@ app = typer.Typer(
) )
console = Console() console = Console()
def _all_service_names() -> list[str]:
"""Return all registered service names from the live plugin registry.""" def _kill_api() -> None:
return sorted(all_services().keys()) """Find and kill any running DECNET API (uvicorn) or mutator processes."""
import psutil
import os
_killed: bool = False
for _proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
_cmd = _proc.info['cmdline']
if not _cmd:
continue
if "uvicorn" in _cmd and "decnet.web.api:app" in _cmd:
console.print(f"[yellow]Stopping DECNET API (PID {_proc.info['pid']})...[/]")
os.kill(_proc.info['pid'], signal.SIGTERM)
_killed = True
elif "decnet.cli" in _cmd and "mutate" in _cmd and "--watch" in _cmd:
console.print(f"[yellow]Stopping DECNET Mutator Watcher (PID {_proc.info['pid']})...[/]")
os.kill(_proc.info['pid'], signal.SIGTERM)
_killed = True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if _killed:
console.print("[green]Background processes stopped.[/]")
def _resolve_distros( @app.command()
distros_explicit: list[str] | None, def api(
randomize_distros: bool, port: int = typer.Option(DECNET_API_PORT, "--port", help="Port for the backend API"),
n: int, host: str = typer.Option(DECNET_API_HOST, "--host", help="Host IP for the backend API"),
archetype: Archetype | None = None, log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Path to the DECNET log file to monitor"),
) -> list[str]: ) -> None:
"""Return a list of n distro slugs based on CLI flags or archetype preference.""" """Run the DECNET API and Web Dashboard in standalone mode."""
if distros_explicit: import subprocess # nosec B404
return [distros_explicit[i % len(distros_explicit)] for i in range(n)] import sys
if randomize_distros: import os
return [random_distro().slug for _ in range(n)]
if archetype:
pool = archetype.preferred_distros
return [pool[i % len(pool)] for i in range(n)]
# Default: cycle through all distros to maximize heterogeneity
slugs = list(all_distros().keys())
return [slugs[i % len(slugs)] for i in range(n)]
console.print(f"[green]Starting DECNET API on {host}:{port}...[/]")
def _build_deckies( _env: dict[str, str] = os.environ.copy()
n: int, _env["DECNET_INGEST_LOG_FILE"] = str(log_file)
ips: list[str], try:
services_explicit: list[str] | None, subprocess.run( # nosec B603 B404
randomize_services: bool, [sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", host, "--port", str(port)],
distros_explicit: list[str] | None = None, env=_env
randomize_distros: bool = False,
archetype: Archetype | None = None,
) -> list[DeckyConfig]:
deckies = []
used_combos: set[frozenset] = set()
distro_slugs = _resolve_distros(distros_explicit, randomize_distros, n, archetype)
for i, ip in enumerate(ips):
name = f"decky-{i + 1:02d}"
distro = get_distro(distro_slugs[i])
hostname = random_hostname(distro.slug)
if services_explicit:
svc_list = services_explicit
elif archetype:
svc_list = list(archetype.services)
elif randomize_services:
svc_pool = _all_service_names()
attempts = 0
while True:
count = random.randint(1, min(3, len(svc_pool)))
chosen = frozenset(random.sample(svc_pool, count))
attempts += 1
if chosen not in used_combos or attempts > 20:
break
svc_list = list(chosen)
used_combos.add(chosen)
else:
typer.echo("Error: provide --services, --archetype, or --randomize-services.", err=True)
raise typer.Exit(1)
deckies.append(
DeckyConfig(
name=name,
ip=ip,
services=svc_list,
distro=distro.slug,
base_image=distro.image,
build_base=distro.build_base,
hostname=hostname,
archetype=archetype.slug if archetype else None,
nmap_os=archetype.nmap_os if archetype else "linux",
)
) )
return deckies except KeyboardInterrupt:
pass
except (FileNotFoundError, subprocess.SubprocessError):
def _build_deckies_from_ini( console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")
ini: IniConfig,
subnet_cidr: str,
gateway: str,
host_ip: str,
randomize: bool,
) -> list[DeckyConfig]:
"""Build DeckyConfig list from an IniConfig, auto-allocating missing IPs."""
from ipaddress import IPv4Address, IPv4Network
explicit_ips: set[IPv4Address] = {
IPv4Address(s.ip) for s in ini.deckies if s.ip
}
net = IPv4Network(subnet_cidr, strict=False)
reserved = {
net.network_address,
net.broadcast_address,
IPv4Address(gateway),
IPv4Address(host_ip),
} | explicit_ips
auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved)
deckies: list[DeckyConfig] = []
for spec in ini.deckies:
# Resolve archetype (if any) — explicit services/distro override it
arch: Archetype | None = None
if spec.archetype:
try:
arch = get_archetype(spec.archetype)
except ValueError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(1)
# Distro: archetype preferred list → random → global cycle
distro_pool = arch.preferred_distros if arch else list(all_distros().keys())
distro = get_distro(distro_pool[len(deckies) % len(distro_pool)])
hostname = random_hostname(distro.slug)
ip = spec.ip or next(auto_pool, None)
if ip is None:
raise RuntimeError(
f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'."
)
if spec.services:
known = set(_all_service_names())
unknown = [s for s in spec.services if s not in known]
if unknown:
console.print(
f"[red]Unknown service(s) in [{spec.name}]: {unknown}. "
f"Available: {_all_service_names()}[/]"
)
raise typer.Exit(1)
svc_list = spec.services
elif arch:
svc_list = list(arch.services)
elif randomize:
svc_pool = _all_service_names()
count = random.randint(1, min(3, len(svc_pool)))
svc_list = random.sample(svc_pool, count)
else:
console.print(
f"[red]Decky '[{spec.name}]' has no services= in config. "
"Add services=, archetype=, or use --randomize-services.[/]"
)
raise typer.Exit(1)
# nmap_os priority: explicit INI key > archetype default > "linux"
resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux")
deckies.append(DeckyConfig(
name=spec.name,
ip=ip,
services=svc_list,
distro=distro.slug,
base_image=distro.image,
build_base=distro.build_base,
hostname=hostname,
archetype=arch.slug if arch else None,
service_config=spec.service_config,
nmap_os=resolved_nmap_os,
))
return deckies
@app.command() @app.command()
@@ -207,15 +102,19 @@ def deploy(
randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"), randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"),
distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"), distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"),
randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"), randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"),
log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"), log_file: Optional[str] = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Host path for the collector to write RFC 5424 logs (e.g. /var/log/decnet/decnet.log)"),
log_file: Optional[str] = typer.Option(None, "--log-file", help="Write RFC 5424 syslog to this path inside containers (e.g. /var/log/decnet/decnet.log)"),
archetype_name: Optional[str] = typer.Option(None, "--archetype", "-a", help="Machine archetype slug (e.g. linux-server, windows-workstation)"), archetype_name: Optional[str] = typer.Option(None, "--archetype", "-a", help="Machine archetype slug (e.g. linux-server, windows-workstation)"),
mutate_interval: Optional[int] = typer.Option(30, "--mutate-interval", help="Automatically rotate services every N minutes"),
dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"), dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"),
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"), no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"),
parallel: bool = typer.Option(False, "--parallel", help="Build all images concurrently (enables BuildKit, separates build from up)"),
ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"), ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"),
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"), config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"),
api_port: int = typer.Option(8000, "--api-port", help="Port for the backend API"),
) -> None: ) -> None:
"""Deploy deckies to the LAN.""" """Deploy deckies to the LAN."""
import os
if mode not in ("unihost", "swarm"): if mode not in ("unihost", "swarm"):
console.print("[red]--mode must be 'unihost' or 'swarm'[/]") console.print("[red]--mode must be 'unihost' or 'swarm'[/]")
raise typer.Exit(1) raise typer.Exit(1)
@@ -230,7 +129,6 @@ def deploy(
console.print(f"[red]{e}[/]") console.print(f"[red]{e}[/]")
raise typer.Exit(1) raise typer.Exit(1)
# CLI flags override INI values when explicitly provided
iface = interface or ini.interface or detect_interface() iface = interface or ini.interface or detect_interface()
subnet_cidr = subnet or ini.subnet subnet_cidr = subnet or ini.subnet
effective_gateway = ini.gateway effective_gateway = ini.gateway
@@ -244,7 +142,6 @@ def deploy(
f"[dim]Subnet:[/] {subnet_cidr} [dim]Gateway:[/] {effective_gateway} " f"[dim]Subnet:[/] {subnet_cidr} [dim]Gateway:[/] {effective_gateway} "
f"[dim]Host IP:[/] {host_ip}") f"[dim]Host IP:[/] {host_ip}")
# Register bring-your-own services from INI before validation
if ini.custom_services: if ini.custom_services:
from decnet.custom_service import CustomService from decnet.custom_service import CustomService
from decnet.services.registry import register_custom_service from decnet.services.registry import register_custom_service
@@ -258,11 +155,14 @@ def deploy(
) )
) )
effective_log_target = log_target or ini.log_target
effective_log_file = log_file effective_log_file = log_file
decky_configs = _build_deckies_from_ini( try:
ini, subnet_cidr, effective_gateway, host_ip, randomize_services decky_configs = build_deckies_from_ini(
) ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval
)
except ValueError as e:
console.print(f"[red]{e}[/]")
raise typer.Exit(1)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Classic CLI path # # Classic CLI path #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -273,13 +173,12 @@ def deploy(
services_list = [s.strip() for s in services.split(",")] if services else None services_list = [s.strip() for s in services.split(",")] if services else None
if services_list: if services_list:
known = set(_all_service_names()) known = set(all_service_names())
unknown = [s for s in services_list if s not in known] unknown = [s for s in services_list if s not in known]
if unknown: if unknown:
console.print(f"[red]Unknown service(s): {unknown}. Available: {_all_service_names()}[/]") console.print(f"[red]Unknown service(s): {unknown}. Available: {all_service_names()}[/]")
raise typer.Exit(1) raise typer.Exit(1)
# Resolve archetype if provided
arch: Archetype | None = None arch: Archetype | None = None
if archetype_name: if archetype_name:
try: try:
@@ -313,39 +212,117 @@ def deploy(
raise typer.Exit(1) raise typer.Exit(1)
ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start) ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start)
decky_configs = _build_deckies( decky_configs = build_deckies(
deckies, ips, services_list, randomize_services, deckies, ips, services_list, randomize_services,
distros_explicit=distros_list, randomize_distros=randomize_distros, distros_explicit=distros_list, randomize_distros=randomize_distros,
archetype=arch, archetype=arch, mutate_interval=mutate_interval,
) )
effective_log_target = log_target
effective_log_file = log_file effective_log_file = log_file
if api and not effective_log_file:
effective_log_file = os.path.join(os.getcwd(), "decnet.log")
console.print(f"[cyan]API mode enabled: defaulting log-file to {effective_log_file}[/]")
config = DecnetConfig( config = DecnetConfig(
mode=mode, mode=mode,
interface=iface, interface=iface,
subnet=subnet_cidr, subnet=subnet_cidr,
gateway=effective_gateway, gateway=effective_gateway,
deckies=decky_configs, deckies=decky_configs,
log_target=effective_log_target,
log_file=effective_log_file, log_file=effective_log_file,
ipvlan=ipvlan, ipvlan=ipvlan,
mutate_interval=mutate_interval,
) )
if effective_log_target and not dry_run: from decnet.engine import deploy as _deploy
from decnet.logging.forwarder import probe_log_target _deploy(config, dry_run=dry_run, no_cache=no_cache, parallel=parallel)
if not probe_log_target(effective_log_target):
console.print(f"[yellow]Warning: log target {effective_log_target} is unreachable. "
"Logs will be lost if it stays down.[/]")
from decnet.deployer import deploy as _deploy if mutate_interval is not None and not dry_run:
_deploy(config, dry_run=dry_run, no_cache=no_cache) import subprocess # nosec B404
import sys
console.print(f"[green]Starting DECNET Mutator watcher in the background (interval: {mutate_interval}m)...[/]")
try:
subprocess.Popen( # nosec B603
[sys.executable, "-m", "decnet.cli", "mutate", "--watch"],
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
start_new_session=True,
)
except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start mutator watcher.[/]")
if effective_log_file and not dry_run and not api:
import subprocess # nosec B404
import sys
from pathlib import Path as _Path
_collector_err = _Path(effective_log_file).with_suffix(".collector.log")
console.print(f"[bold cyan]Starting log collector[/] → {effective_log_file}")
subprocess.Popen( # nosec B603
[sys.executable, "-m", "decnet.cli", "collect", "--log-file", str(effective_log_file)],
stdin=subprocess.DEVNULL,
stdout=open(_collector_err, "a"), # nosec B603
stderr=subprocess.STDOUT,
start_new_session=True,
)
if api and not dry_run:
import subprocess # nosec B404
import sys
console.print(f"[green]Starting DECNET API on port {api_port}...[/]")
_env: dict[str, str] = os.environ.copy()
_env["DECNET_INGEST_LOG_FILE"] = str(effective_log_file or "")
try:
subprocess.Popen( # nosec B603
[sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", DECNET_API_HOST, "--port", str(api_port)],
env=_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT
)
console.print(f"[dim]API running at http://{DECNET_API_HOST}:{api_port}[/]")
except (FileNotFoundError, subprocess.SubprocessError):
console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]")
@app.command()
def collect(
log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path to write RFC 5424 syslog lines and .json records"),
) -> None:
"""Stream Docker logs from all running decky service containers to a log file."""
import asyncio
from decnet.collector import log_collector_worker
console.print(f"[bold cyan]Collector starting[/] → {log_file}")
asyncio.run(log_collector_worker(log_file))
@app.command()
def mutate(
watch: bool = typer.Option(False, "--watch", "-w", help="Run continuously and mutate deckies according to their interval"),
decky_name: Optional[str] = typer.Option(None, "--decky", "-d", help="Force mutate a specific decky immediately"),
force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"),
) -> None:
"""Manually trigger or continuously watch for decky mutation."""
import asyncio
from decnet.mutator import mutate_decky, mutate_all, run_watch_loop
from decnet.web.dependencies import repo
async def _run() -> None:
await repo.initialize()
if watch:
await run_watch_loop(repo)
elif decky_name:
await mutate_decky(decky_name, repo)
elif force_all:
await mutate_all(force=True, repo=repo)
else:
await mutate_all(force=False, repo=repo)
asyncio.run(_run())
@app.command() @app.command()
def status() -> None: def status() -> None:
"""Show running deckies and their status.""" """Show running deckies and their status."""
from decnet.deployer import status as _status from decnet.engine import status as _status
_status() _status()
@@ -359,9 +336,12 @@ def teardown(
console.print("[red]Specify --all or --id <name>.[/]") console.print("[red]Specify --all or --id <name>.[/]")
raise typer.Exit(1) raise typer.Exit(1)
from decnet.deployer import teardown as _teardown from decnet.engine import teardown as _teardown
_teardown(decky_id=id_) _teardown(decky_id=id_)
if all_:
_kill_api()
@app.command(name="services") @app.command(name="services")
def list_services() -> None: def list_services() -> None:
@@ -459,3 +439,40 @@ def list_archetypes() -> None:
arch.description, arch.description,
) )
console.print(table) console.print(table)
@app.command(name="web")
def serve_web(
web_port: int = typer.Option(DECNET_WEB_PORT, "--web-port", help="Port to serve the DECNET Web Dashboard"),
host: str = typer.Option(DECNET_WEB_HOST, "--host", help="Host IP to serve the Web Dashboard"),
) -> None:
"""Serve the DECNET Web Dashboard frontend."""
import http.server
import socketserver
from pathlib import Path
dist_dir = Path(__file__).parent.parent / "decnet_web" / "dist"
if not dist_dir.exists():
console.print(f"[red]Frontend build not found at {dist_dir}. Make sure you run 'npm run build' inside 'decnet_web'.[/]")
raise typer.Exit(1)
class SPAHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
path = self.translate_path(self.path)
if not Path(path).exists() or Path(path).is_dir():
self.path = "/index.html"
return super().do_GET()
import os
os.chdir(dist_dir)
with socketserver.TCPServer((host, web_port), SPAHTTPRequestHandler) as httpd:
console.print(f"[green]Serving DECNET Web Dashboard on http://{host}:{web_port}[/]")
try:
httpd.serve_forever()
except KeyboardInterrupt:
console.print("\n[dim]Shutting down dashboard server.[/]")
if __name__ == '__main__': # pragma: no cover
app()

View File

@@ -0,0 +1,13 @@
from decnet.collector.worker import (
is_service_container,
is_service_event,
log_collector_worker,
parse_rfc5424,
)
__all__ = [
"is_service_container",
"is_service_event",
"log_collector_worker",
"parse_rfc5424",
]

200
decnet/collector/worker.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Host-side Docker log collector.
Streams stdout from all running decky service containers via the Docker SDK,
writes RFC 5424 lines to <log_file> and parsed JSON records to <log_file>.json.
The ingester tails the .json file; rsyslog can consume the .log file independently.
"""
import asyncio
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger("decnet.collector")
# ─── RFC 5424 parser ──────────────────────────────────────────────────────────
_RFC5424_RE = re.compile(
r"^<\d+>1 "
r"(\S+) " # 1: TIMESTAMP
r"(\S+) " # 2: HOSTNAME (decky name)
r"(\S+) " # 3: APP-NAME (service)
r"- " # PROCID always NILVALUE
r"(\S+) " # 4: MSGID (event_type)
r"(.+)$", # 5: SD element + optional MSG
)
_SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip")
def parse_rfc5424(line: str) -> Optional[dict[str, Any]]:
"""
Parse an RFC 5424 DECNET log line into a structured dict.
Returns None if the line does not match the expected format.
"""
m = _RFC5424_RE.match(line)
if not m:
return None
ts_raw, decky, service, event_type, sd_rest = m.groups()
fields: dict[str, str] = {}
msg: str = ""
if sd_rest.startswith("-"):
msg = sd_rest[1:].lstrip()
elif sd_rest.startswith("["):
block = _SD_BLOCK_RE.search(sd_rest)
if block:
for k, v in _PARAM_RE.findall(block.group(1)):
fields[k] = v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
msg_match = re.search(r'\]\s+(.+)$', sd_rest)
if msg_match:
msg = msg_match.group(1).strip()
else:
msg = sd_rest
attacker_ip = "Unknown"
for fname in _IP_FIELDS:
if fname in fields:
attacker_ip = fields[fname]
break
try:
ts_formatted = datetime.fromisoformat(ts_raw).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
ts_formatted = ts_raw
return {
"timestamp": ts_formatted,
"decky": decky,
"service": service,
"event_type": event_type,
"attacker_ip": attacker_ip,
"fields": fields,
"msg": msg,
"raw_line": line,
}
# ─── Container helpers ────────────────────────────────────────────────────────
def _load_service_container_names() -> set[str]:
"""
Return the exact set of service container names from decnet-state.json.
Format: {decky_name}-{service_name}, e.g. 'omega-decky-smtp'.
Returns an empty set if no state file exists.
"""
from decnet.config import load_state
state = load_state()
if state is None:
return set()
config, _ = state
names: set[str] = set()
for decky in config.deckies:
for svc in decky.services:
names.add(f"{decky.name}-{svc.replace('_', '-')}")
return names
def is_service_container(container) -> bool:
"""Return True if this Docker container is a known DECNET service container."""
name = (container if isinstance(container, str) else container.name).lstrip("/")
return name in _load_service_container_names()
def is_service_event(attrs: dict) -> bool:
"""Return True if a Docker start event is for a known DECNET service container."""
name = attrs.get("name", "").lstrip("/")
return name in _load_service_container_names()
# ─── Blocking stream worker (runs in a thread) ────────────────────────────────
def _stream_container(container_id: str, log_path: Path, json_path: Path) -> None:
"""Stream logs from one container and append to the host log files."""
import docker # type: ignore[import]
try:
client = docker.from_env()
container = client.containers.get(container_id)
log_stream = container.logs(stream=True, follow=True, stdout=True, stderr=False)
buf = ""
with (
open(log_path, "a", encoding="utf-8") as lf,
open(json_path, "a", encoding="utf-8") as jf,
):
for chunk in log_stream:
buf += chunk.decode("utf-8", errors="replace")
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.rstrip()
if not line:
continue
lf.write(line + "\n")
lf.flush()
parsed = parse_rfc5424(line)
if parsed:
jf.write(json.dumps(parsed) + "\n")
jf.flush()
except Exception as exc:
logger.debug("Log stream ended for container %s: %s", container_id, exc)
# ─── Async collector ──────────────────────────────────────────────────────────
async def log_collector_worker(log_file: str) -> None:
"""
Background task: streams Docker logs from all running decky service
containers, writing RFC 5424 lines to log_file and parsed JSON records
to log_file.json for the ingester to consume.
Watches Docker events to pick up containers started after initial scan.
"""
import docker # type: ignore[import]
log_path = Path(log_file)
json_path = log_path.with_suffix(".json")
log_path.parent.mkdir(parents=True, exist_ok=True)
active: dict[str, asyncio.Task[None]] = {}
loop = asyncio.get_running_loop()
def _spawn(container_id: str, container_name: str) -> None:
if container_id not in active or active[container_id].done():
active[container_id] = asyncio.ensure_future(
asyncio.to_thread(_stream_container, container_id, log_path, json_path),
loop=loop,
)
logger.info("Collecting logs from container: %s", container_name)
try:
client = docker.from_env()
for container in client.containers.list():
if is_service_container(container):
_spawn(container.id, container.name.lstrip("/"))
def _watch_events() -> None:
for event in client.events(
decode=True,
filters={"type": "container", "event": "start"},
):
attrs = event.get("Actor", {}).get("Attributes", {})
cid = event.get("id", "")
name = attrs.get("name", "")
if cid and is_service_event(attrs):
loop.call_soon_threadsafe(_spawn, cid, name)
await asyncio.to_thread(_watch_events)
except asyncio.CancelledError:
for task in active.values():
task.cancel()
raise
except Exception as exc:
logger.error("Collector error: %s", exc)

View File

@@ -6,6 +6,12 @@ Network model:
All service containers for that decky share the base's network namespace All service containers for that decky share the base's network namespace
via `network_mode: "service:<base>"`. From the outside, every service on via `network_mode: "service:<base>"`. From the outside, every service on
a given decky appears to come from the same IP — exactly like a real host. a given decky appears to come from the same IP — exactly like a real host.
Logging model:
Service containers write RFC 5424 lines to stdout. Docker captures them
via the json-file driver. The host-side collector (decnet.web.collector)
streams those logs and writes them to the host log file for the ingester
and rsyslog to consume. No bind mounts or shared volumes are needed.
""" """
from pathlib import Path from pathlib import Path
@@ -17,35 +23,19 @@ from decnet.network import MACVLAN_NETWORK_NAME
from decnet.os_fingerprint import get_os_sysctls from decnet.os_fingerprint import get_os_sysctls
from decnet.services.registry import get_service from decnet.services.registry import get_service
_CONTAINER_LOG_DIR = "/var/log/decnet" _DOCKER_LOGGING = {
"driver": "json-file",
_LOG_NETWORK = "decnet_logs" "options": {
"max-size": "10m",
"max-file": "5",
def _resolve_log_file(log_file: str) -> tuple[str, str]: },
""" }
Return (host_dir, container_log_path) for a user-supplied log file path.
The host path is resolved to absolute so Docker can bind-mount it.
All containers share the same host directory, mounted at _CONTAINER_LOG_DIR.
"""
host_path = Path(log_file).resolve()
host_dir = str(host_path.parent)
container_path = f"{_CONTAINER_LOG_DIR}/{host_path.name}"
return host_dir, container_path
def generate_compose(config: DecnetConfig) -> dict: def generate_compose(config: DecnetConfig) -> dict:
"""Build and return the full docker-compose data structure.""" """Build and return the full docker-compose data structure."""
services: dict = {} services: dict = {}
log_host_dir: str | None = None
log_container_path: str | None = None
if config.log_file:
log_host_dir, log_container_path = _resolve_log_file(config.log_file)
# Ensure the host log directory exists so Docker doesn't create it as root-owned
Path(log_host_dir).mkdir(parents=True, exist_ok=True)
for decky in config.deckies: for decky in config.deckies:
base_key = decky.name # e.g. "decky-01" base_key = decky.name # e.g. "decky-01"
@@ -62,8 +52,6 @@ def generate_compose(config: DecnetConfig) -> dict:
} }
}, },
} }
if config.log_target:
base["networks"][_LOG_NETWORK] = {}
# Inject TCP/IP stack sysctls to spoof the claimed OS fingerprint. # Inject TCP/IP stack sysctls to spoof the claimed OS fingerprint.
# Only the base container needs this — service containers inherit the # Only the base container needs this — service containers inherit the
@@ -77,23 +65,18 @@ def generate_compose(config: DecnetConfig) -> dict:
for svc_name in decky.services: for svc_name in decky.services:
svc = get_service(svc_name) svc = get_service(svc_name)
svc_cfg = decky.service_config.get(svc_name, {}) svc_cfg = decky.service_config.get(svc_name, {})
fragment = svc.compose_fragment( fragment = svc.compose_fragment(decky.name, service_cfg=svc_cfg)
decky.name, log_target=config.log_target, service_cfg=svc_cfg
)
# Inject the per-decky base image into build services so containers # Inject the per-decky base image into build services so containers
# vary by distro and don't all fingerprint as debian:bookworm-slim. # vary by distro and don't all fingerprint as debian:bookworm-slim.
# Services that need a fixed upstream image (e.g. conpot) can pre-set
# build.args.BASE_IMAGE in their compose_fragment() to opt out.
if "build" in fragment: if "build" in fragment:
fragment["build"].setdefault("args", {})["BASE_IMAGE"] = decky.build_base args = fragment["build"].setdefault("args", {})
args.setdefault("BASE_IMAGE", decky.build_base)
fragment.setdefault("environment", {}) fragment.setdefault("environment", {})
fragment["environment"]["HOSTNAME"] = decky.hostname fragment["environment"]["HOSTNAME"] = decky.hostname
if log_host_dir and log_container_path:
fragment["environment"]["DECNET_LOG_FILE"] = log_container_path
fragment.setdefault("volumes", [])
mount = f"{log_host_dir}:{_CONTAINER_LOG_DIR}"
if mount not in fragment["volumes"]:
fragment["volumes"].append(mount)
# Share the base container's network — no own IP needed # Share the base container's network — no own IP needed
fragment["network_mode"] = f"service:{base_key}" fragment["network_mode"] = f"service:{base_key}"
@@ -103,6 +86,9 @@ def generate_compose(config: DecnetConfig) -> dict:
fragment.pop("hostname", None) fragment.pop("hostname", None)
fragment.pop("networks", None) fragment.pop("networks", None)
# Rotate Docker logs so disk usage is bounded
fragment["logging"] = _DOCKER_LOGGING
services[f"{decky.name}-{svc_name}"] = fragment services[f"{decky.name}-{svc_name}"] = fragment
# Network definitions # Network definitions
@@ -111,8 +97,6 @@ def generate_compose(config: DecnetConfig) -> dict:
"external": True, # created by network.py before compose up "external": True, # created by network.py before compose up
} }
} }
if config.log_target:
networks[_LOG_NETWORK] = {"driver": "bridge", "internal": True}
return { return {
"version": "3.8", "version": "3.8",

View File

@@ -4,61 +4,87 @@ State is persisted to decnet-state.json in the working directory.
""" """
import json import json
import logging
import os
import socket as _socket
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Literal
from pydantic import BaseModel, field_validator from decnet.models import DeckyConfig, DecnetConfig # noqa: F401
from decnet.distros import random_hostname as _random_hostname from decnet.distros import random_hostname as _random_hostname
STATE_FILE = Path("decnet-state.json") # ---------------------------------------------------------------------------
# RFC 5424 syslog formatter
# ---------------------------------------------------------------------------
# Severity mapping: Python level → syslog severity (RFC 5424 §6.2.1)
_SYSLOG_SEVERITY: dict[int, int] = {
logging.CRITICAL: 2, # Critical
logging.ERROR: 3, # Error
logging.WARNING: 4, # Warning
logging.INFO: 6, # Informational
logging.DEBUG: 7, # Debug
}
_FACILITY_LOCAL0 = 16 # local0 (RFC 5424 §6.2.1 / POSIX)
class Rfc5424Formatter(logging.Formatter):
"""Formats log records as RFC 5424 syslog messages.
Output:
<PRIVAL>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
Example:
<134>1 2026-04-12T21:48:03.123456+00:00 host decnet 1234 decnet.config - Dev mode active
"""
_hostname: str = _socket.gethostname()
_app: str = "decnet"
def format(self, record: logging.LogRecord) -> str:
severity = _SYSLOG_SEVERITY.get(record.levelno, 6)
prival = (_FACILITY_LOCAL0 * 8) + severity
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec="microseconds")
msg = record.getMessage()
if record.exc_info:
msg += "\n" + self.formatException(record.exc_info)
return (
f"<{prival}>1 {ts} {self._hostname} {self._app}"
f" {os.getpid()} {record.name} - {msg}"
)
def _configure_logging(dev: bool) -> None:
"""Install the RFC 5424 handler on the root logger (idempotent)."""
root = logging.getLogger()
# Avoid adding duplicate handlers on re-import (e.g. during testing)
if any(isinstance(h, logging.StreamHandler) and isinstance(h.formatter, Rfc5424Formatter)
for h in root.handlers):
return
handler = logging.StreamHandler()
handler.setFormatter(Rfc5424Formatter())
root.setLevel(logging.DEBUG if dev else logging.INFO)
root.addHandler(handler)
_dev = os.environ.get("DECNET_DEVELOPER", "").lower() == "true"
_configure_logging(_dev)
log = logging.getLogger(__name__)
if _dev:
log.debug("Developer mode: debug logging active")
# Calculate absolute path to the project root (where the config file resides)
_ROOT: Path = Path(__file__).parent.parent.absolute()
STATE_FILE: Path = _ROOT / "decnet-state.json"
DEFAULT_MUTATE_INTERVAL: int = 30 # default rotation interval in minutes
def random_hostname(distro_slug: str = "debian") -> str: def random_hostname(distro_slug: str = "debian") -> str:
return _random_hostname(distro_slug) return _random_hostname(distro_slug)
class DeckyConfig(BaseModel):
name: str
ip: str
services: list[str]
distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22"
base_image: str # Docker image for the base/IP-holder container
build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles
hostname: str
archetype: str | None = None # archetype slug if spawned from an archetype profile
service_config: dict[str, dict] = {} # optional per-service persona config
nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py)
@field_validator("services")
@classmethod
def services_not_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("A decky must have at least one service.")
return v
class DecnetConfig(BaseModel):
mode: Literal["unihost", "swarm"]
interface: str
subnet: str
gateway: str
deckies: list[DeckyConfig]
log_target: str | None = None # "ip:port" or None
log_file: str | None = None # path for RFC 5424 syslog file output
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
@field_validator("log_target")
@classmethod
def validate_log_target(cls, v: str | None) -> str | None:
if v is None:
return v
parts = v.rsplit(":", 1)
if len(parts) != 2 or not parts[1].isdigit():
raise ValueError("log_target must be in ip:port format, e.g. 192.168.1.5:5140")
return v
def save_state(config: DecnetConfig, compose_path: Path) -> None: def save_state(config: DecnetConfig, compose_path: Path) -> None:
payload = { payload = {
"config": config.model_dump(), "config": config.model_dump(),

View File

@@ -5,9 +5,9 @@ from decnet.correlation.graph import AttackerTraversal, TraversalHop
from decnet.correlation.parser import LogEvent, parse_line from decnet.correlation.parser import LogEvent, parse_line
__all__ = [ __all__ = [
"CorrelationEngine",
"AttackerTraversal", "AttackerTraversal",
"TraversalHop", "CorrelationEngine",
"LogEvent", "LogEvent",
"TraversalHop",
"parse_line", "parse_line",
] ]

View File

@@ -22,7 +22,6 @@ Usage
from __future__ import annotations from __future__ import annotations
import json
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path

View File

@@ -17,7 +17,7 @@ The attacker IP may appear under several field names depending on service:
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime from datetime import datetime
# RFC 5424 line structure # RFC 5424 line structure

View File

@@ -97,8 +97,8 @@ def random_hostname(distro_slug: str = "debian") -> str:
"""Generate a plausible hostname for the given distro style.""" """Generate a plausible hostname for the given distro style."""
profile = DISTROS.get(distro_slug) profile = DISTROS.get(distro_slug)
style = profile.hostname_style if profile else "generic" style = profile.hostname_style if profile else "generic"
word = random.choice(_NAME_WORDS) word = random.choice(_NAME_WORDS) # nosec B311
num = random.randint(10, 99) num = random.randint(10, 99) # nosec B311
if style == "rhel": if style == "rhel":
# RHEL/CentOS/Fedora convention: word+num.localdomain # RHEL/CentOS/Fedora convention: word+num.localdomain
@@ -107,7 +107,7 @@ def random_hostname(distro_slug: str = "debian") -> str:
return f"{word}-{num}" return f"{word}-{num}"
elif style == "rolling": elif style == "rolling":
# Kali/Arch: just a word, no suffix # Kali/Arch: just a word, no suffix
return f"{word}-{random.choice(_NAME_WORDS)}" return f"{word}-{random.choice(_NAME_WORDS)}" # nosec B311
else: else:
# Debian/Ubuntu: SRV-WORD-nn # Debian/Ubuntu: SRV-WORD-nn
return f"SRV-{word.upper()}-{num}" return f"SRV-{word.upper()}-{num}"
@@ -122,7 +122,7 @@ def get_distro(slug: str) -> DistroProfile:
def random_distro() -> DistroProfile: def random_distro() -> DistroProfile:
return random.choice(list(DISTROS.values())) return random.choice(list(DISTROS.values())) # nosec B311
def all_distros() -> dict[str, DistroProfile]: def all_distros() -> dict[str, DistroProfile]:

15
decnet/engine/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
from decnet.engine.deployer import (
COMPOSE_FILE,
_compose_with_retry,
deploy,
status,
teardown,
)
__all__ = [
"COMPOSE_FILE",
"_compose_with_retry",
"deploy",
"status",
"teardown",
]

View File

@@ -2,7 +2,8 @@
Deploy, teardown, and status via Docker SDK + subprocess docker compose. Deploy, teardown, and status via Docker SDK + subprocess docker compose.
""" """
import subprocess import shutil
import subprocess # nosec B404
import time import time
from pathlib import Path from pathlib import Path
@@ -14,11 +15,8 @@ from decnet.config import DecnetConfig, clear_state, load_state, save_state
from decnet.composer import write_compose from decnet.composer import write_compose
from decnet.network import ( from decnet.network import (
MACVLAN_NETWORK_NAME, MACVLAN_NETWORK_NAME,
allocate_ips,
create_ipvlan_network, create_ipvlan_network,
create_macvlan_network, create_macvlan_network,
detect_interface,
detect_subnet,
get_host_ip, get_host_ip,
ips_to_range, ips_to_range,
remove_macvlan_network, remove_macvlan_network,
@@ -30,11 +28,32 @@ from decnet.network import (
console = Console() console = Console()
COMPOSE_FILE = Path("decnet-compose.yml") COMPOSE_FILE = Path("decnet-compose.yml")
_CANONICAL_LOGGING = Path(__file__).parent.parent.parent / "templates" / "decnet_logging.py"
def _compose(*args: str, compose_file: Path = COMPOSE_FILE) -> None: def _sync_logging_helper(config: DecnetConfig) -> None:
"""Copy the canonical decnet_logging.py into every active template build context."""
from decnet.services.registry import get_service
seen: set[Path] = set()
for decky in config.deckies:
for svc_name in decky.services:
svc = get_service(svc_name)
if svc is None:
continue
ctx = svc.dockerfile_context()
if ctx is None or ctx in seen:
continue
seen.add(ctx)
dest = ctx / "decnet_logging.py"
if not dest.exists() or dest.read_bytes() != _CANONICAL_LOGGING.read_bytes():
shutil.copy2(_CANONICAL_LOGGING, dest)
def _compose(*args: str, compose_file: Path = COMPOSE_FILE, env: dict | None = None) -> None:
import os
cmd = ["docker", "compose", "-f", str(compose_file), *args] cmd = ["docker", "compose", "-f", str(compose_file), *args]
subprocess.run(cmd, check=True) merged = {**os.environ, **(env or {})}
subprocess.run(cmd, check=True, env=merged) # nosec B603
_PERMANENT_ERRORS = ( _PERMANENT_ERRORS = (
@@ -51,12 +70,15 @@ def _compose_with_retry(
compose_file: Path = COMPOSE_FILE, compose_file: Path = COMPOSE_FILE,
retries: int = 3, retries: int = 3,
delay: float = 5.0, delay: float = 5.0,
env: dict | None = None,
) -> None: ) -> None:
"""Run a docker compose command, retrying on transient failures.""" """Run a docker compose command, retrying on transient failures."""
import os
last_exc: subprocess.CalledProcessError | None = None last_exc: subprocess.CalledProcessError | None = None
cmd = ["docker", "compose", "-f", str(compose_file), *args] cmd = ["docker", "compose", "-f", str(compose_file), *args]
merged = {**os.environ, **(env or {})}
for attempt in range(1, retries + 1): for attempt in range(1, retries + 1):
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True, env=merged) # nosec B603
if result.returncode == 0: if result.returncode == 0:
if result.stdout: if result.stdout:
print(result.stdout, end="") print(result.stdout, end="")
@@ -83,10 +105,9 @@ def _compose_with_retry(
raise last_exc raise last_exc
def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) -> None: def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False, parallel: bool = False) -> None:
client = docker.from_env() client = docker.from_env()
# --- Network setup ---
ip_list = [d.ip for d in config.deckies] ip_list = [d.ip for d in config.deckies]
decky_range = ips_to_range(ip_list) decky_range = ips_to_range(ip_list)
host_ip = get_host_ip(config.interface) host_ip = get_host_ip(config.interface)
@@ -113,7 +134,8 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
) )
setup_host_macvlan(config.interface, host_ip, decky_range) setup_host_macvlan(config.interface, host_ip, decky_range)
# --- Compose generation --- _sync_logging_helper(config)
compose_path = write_compose(config, COMPOSE_FILE) compose_path = write_compose(config, COMPOSE_FILE)
console.print(f"[bold cyan]Compose file written[/] → {compose_path}") console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
@@ -121,16 +143,24 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
console.print("[yellow]Dry run — no containers started.[/]") console.print("[yellow]Dry run — no containers started.[/]")
return return
# --- Save state before bring-up ---
save_state(config, compose_path) save_state(config, compose_path)
# --- Bring up --- build_env = {"DOCKER_BUILDKIT": "1"} if parallel else {}
console.print("[bold cyan]Building images and starting deckies...[/]")
if no_cache: console.print("[bold cyan]Building images and starting deckies...[/]")
_compose_with_retry("build", "--no-cache", compose_file=compose_path) build_args = ["build"]
_compose_with_retry("up", "--build", "-d", compose_file=compose_path) if no_cache:
build_args.append("--no-cache")
if parallel:
console.print("[bold cyan]Parallel build enabled — building all images concurrently...[/]")
_compose_with_retry(*build_args, compose_file=compose_path, env=build_env)
_compose_with_retry("up", "-d", compose_file=compose_path, env=build_env)
else:
if no_cache:
_compose_with_retry("build", "--no-cache", compose_file=compose_path)
_compose_with_retry("up", "--build", "-d", compose_file=compose_path)
# --- Status summary ---
_print_status(config) _print_status(config)
@@ -144,7 +174,6 @@ def teardown(decky_id: str | None = None) -> None:
client = docker.from_env() client = docker.from_env()
if decky_id: if decky_id:
# Bring down only the services matching this decky
svc_names = [f"{decky_id}-{svc}" for svc in [d.services for d in config.deckies if d.name == decky_id]] svc_names = [f"{decky_id}-{svc}" for svc in [d.services for d in config.deckies if d.name == decky_id]]
if not svc_names: if not svc_names:
console.print(f"[red]Decky '{decky_id}' not found in current deployment.[/]") console.print(f"[red]Decky '{decky_id}' not found in current deployment.[/]")
@@ -162,6 +191,7 @@ def teardown(decky_id: str | None = None) -> None:
teardown_host_macvlan(decky_range) teardown_host_macvlan(decky_range)
remove_macvlan_network(client) remove_macvlan_network(client)
clear_state() clear_state()
net_driver = "IPvlan" if config.ipvlan else "MACVLAN" net_driver = "IPvlan" if config.ipvlan else "MACVLAN"
console.print(f"[green]All deckies torn down. {net_driver} network removed.[/]") console.print(f"[green]All deckies torn down. {net_driver} network removed.[/]")
@@ -182,7 +212,7 @@ def status() -> None:
table.add_column("Hostname") table.add_column("Hostname")
table.add_column("Status") table.add_column("Status")
running = {c.name: c.status for c in client.containers.list(all=True)} running = {c.name: c.status for c in client.containers.list(all=True, ignore_removed=True)}
for decky in config.deckies: for decky in config.deckies:
statuses = [] statuses = []

69
decnet/env.py Normal file
View File

@@ -0,0 +1,69 @@
import os
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# Calculate absolute path to the project root
_ROOT: Path = Path(__file__).parent.parent.absolute()
# Load .env.local first, then fallback to .env
load_dotenv(_ROOT / ".env.local")
load_dotenv(_ROOT / ".env")
def _port(name: str, default: int) -> int:
raw = os.environ.get(name, str(default))
try:
value = int(raw)
except ValueError:
raise ValueError(f"Environment variable '{name}' must be an integer, got '{raw}'.")
if not (1 <= value <= 65535):
raise ValueError(f"Environment variable '{name}' must be 165535, got {value}.")
return value
def _require_env(name: str) -> str:
"""Return the env var value or raise at startup if it is unset or a known-bad default."""
_KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"}
value = os.environ.get(name)
if not value:
raise ValueError(
f"Required environment variable '{name}' is not set. "
f"Set it in .env.local or export it before starting DECNET."
)
if any(k.startswith("PYTEST") for k in os.environ):
return value
if value.lower() in _KNOWN_BAD:
raise ValueError(
f"Environment variable '{name}' is set to an insecure default ('{value}'). "
f"Choose a strong, unique value before starting DECNET."
)
return value
# API Options
DECNET_API_HOST: str = os.environ.get("DECNET_API_HOST", "0.0.0.0") # nosec B104
DECNET_API_PORT: int = _port("DECNET_API_PORT", 8000)
DECNET_JWT_SECRET: str = _require_env("DECNET_JWT_SECRET")
DECNET_INGEST_LOG_FILE: str | None = os.environ.get("DECNET_INGEST_LOG_FILE", "/var/log/decnet/decnet.log")
# Web Dashboard Options
DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "0.0.0.0") # nosec B104
DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080)
DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin")
DECNET_ADMIN_PASSWORD: str = os.environ.get("DECNET_ADMIN_PASSWORD", "admin")
DECNET_DEVELOPER: bool = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true"
# Database Options
DECNET_DB_TYPE: str = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
DECNET_DB_URL: Optional[str] = os.environ.get("DECNET_DB_URL")
# CORS — comma-separated list of allowed origins for the web dashboard API.
# Defaults to the configured web host/port. Override with DECNET_CORS_ORIGINS if needed.
# Example: DECNET_CORS_ORIGINS=http://192.168.1.50:9090,https://dashboard.example.com
_web_hostname: str = "localhost" if DECNET_WEB_HOST in ("0.0.0.0", "127.0.0.1", "::") else DECNET_WEB_HOST # nosec B104
_cors_default: str = f"http://{_web_hostname}:{DECNET_WEB_PORT}"
_cors_raw: str = os.environ.get("DECNET_CORS_ORIGINS", _cors_default)
DECNET_CORS_ORIGINS: list[str] = [o.strip() for o in _cors_raw.split(",") if o.strip()]

174
decnet/fleet.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Fleet builder — shared logic for constructing DeckyConfig lists.
Used by both the CLI and the web API router to build deckies from
flags or INI config. Lives here (not in cli.py) so that the web layer
and the mutation engine can import it without depending on the CLI.
"""
import random
from typing import Optional
from decnet.archetypes import Archetype, get_archetype
from decnet.config import DeckyConfig, random_hostname
from decnet.distros import all_distros, get_distro, random_distro
from decnet.models import IniConfig
from decnet.services.registry import all_services
def all_service_names() -> list[str]:
"""Return all registered service names from the live plugin registry."""
return sorted(all_services().keys())
def resolve_distros(
distros_explicit: list[str] | None,
randomize_distros: bool,
n: int,
archetype: Archetype | None = None,
) -> list[str]:
"""Return a list of n distro slugs based on flags or archetype preference."""
if distros_explicit:
return [distros_explicit[i % len(distros_explicit)] for i in range(n)]
if randomize_distros:
return [random_distro().slug for _ in range(n)]
if archetype:
pool = archetype.preferred_distros
return [pool[i % len(pool)] for i in range(n)]
slugs = list(all_distros().keys())
return [slugs[i % len(slugs)] for i in range(n)]
def build_deckies(
n: int,
ips: list[str],
services_explicit: list[str] | None,
randomize_services: bool,
distros_explicit: list[str] | None = None,
randomize_distros: bool = False,
archetype: Archetype | None = None,
mutate_interval: Optional[int] = None,
) -> list[DeckyConfig]:
"""Build a list of DeckyConfigs from CLI-style flags."""
deckies = []
used_combos: set[frozenset] = set()
distro_slugs = resolve_distros(distros_explicit, randomize_distros, n, archetype)
for i, ip in enumerate(ips):
name = f"decky-{i + 1:02d}"
distro = get_distro(distro_slugs[i])
hostname = random_hostname(distro.slug)
if services_explicit:
svc_list = services_explicit
elif archetype:
svc_list = list(archetype.services)
elif randomize_services:
svc_pool = all_service_names()
attempts = 0
while True:
count = random.randint(1, min(3, len(svc_pool))) # nosec B311
chosen = frozenset(random.sample(svc_pool, count)) # nosec B311
attempts += 1
if chosen not in used_combos or attempts > 20:
break
svc_list = list(chosen)
used_combos.add(chosen)
else:
raise ValueError("Provide services_explicit, archetype, or randomize_services=True.")
deckies.append(
DeckyConfig(
name=name,
ip=ip,
services=svc_list,
distro=distro.slug,
base_image=distro.image,
build_base=distro.build_base,
hostname=hostname,
archetype=archetype.slug if archetype else None,
nmap_os=archetype.nmap_os if archetype else "linux",
mutate_interval=mutate_interval,
)
)
return deckies
def build_deckies_from_ini(
ini: IniConfig,
subnet_cidr: str,
gateway: str,
host_ip: str,
randomize: bool,
cli_mutate_interval: int | None = None,
) -> list[DeckyConfig]:
"""Build DeckyConfig list from an IniConfig, auto-allocating missing IPs."""
from ipaddress import IPv4Address, IPv4Network
import time
now = time.time()
explicit_ips: set[IPv4Address] = {
IPv4Address(s.ip) for s in ini.deckies if s.ip
}
net = IPv4Network(subnet_cidr, strict=False)
reserved = {
net.network_address,
net.broadcast_address,
IPv4Address(gateway),
IPv4Address(host_ip),
} | explicit_ips
auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved)
deckies: list[DeckyConfig] = []
for spec in ini.deckies:
arch: Archetype | None = None
if spec.archetype:
arch = get_archetype(spec.archetype)
distro_pool = arch.preferred_distros if arch else list(all_distros().keys())
distro = get_distro(distro_pool[len(deckies) % len(distro_pool)])
hostname = random_hostname(distro.slug)
ip = spec.ip or next(auto_pool, None)
if ip is None:
raise ValueError(f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'.")
if spec.services:
known = set(all_service_names())
unknown = [s for s in spec.services if s not in known]
if unknown:
raise ValueError(
f"Unknown service(s) in [{spec.name}]: {unknown}. "
f"Available: {all_service_names()}"
)
svc_list = spec.services
elif arch:
svc_list = list(arch.services)
elif randomize or (not spec.services and not arch):
svc_pool = all_service_names()
count = random.randint(1, min(3, len(svc_pool))) # nosec B311
svc_list = random.sample(svc_pool, count) # nosec B311
resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux")
decky_mutate_interval = cli_mutate_interval
if decky_mutate_interval is None:
decky_mutate_interval = spec.mutate_interval if spec.mutate_interval is not None else ini.mutate_interval
deckies.append(DeckyConfig(
name=spec.name,
ip=ip,
services=svc_list,
distro=distro.slug,
base_image=distro.image,
build_base=distro.build_base,
hostname=hostname,
archetype=arch.slug if arch else None,
service_config=spec.service_config,
nmap_os=resolved_nmap_os,
mutate_interval=decky_mutate_interval,
last_mutated=now,
))
return deckies

View File

@@ -6,7 +6,6 @@ Format:
net=192.168.1.0/24 net=192.168.1.0/24
gw=192.168.1.1 gw=192.168.1.1
interface=wlp6s0 interface=wlp6s0
log_target=192.168.1.5:5140 # optional
[hostname-1] [hostname-1]
ip=192.168.1.82 # optional ip=192.168.1.82 # optional
@@ -42,37 +41,8 @@ Format:
""" """
import configparser import configparser
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from decnet.models import IniConfig, DeckySpec, CustomServiceSpec, validate_ini_string # noqa: F401
@dataclass
class DeckySpec:
name: str
ip: str | None = None
services: list[str] | None = None
archetype: str | None = None
service_config: dict[str, dict] = field(default_factory=dict)
nmap_os: str | None = None # explicit OS family override (linux/windows/bsd/embedded/cisco)
@dataclass
class CustomServiceSpec:
"""Spec for a user-defined (bring-your-own) service."""
name: str # service slug, e.g. "myservice" (section is "custom-myservice")
image: str # Docker image to use
exec_cmd: str # command to run inside the container
ports: list[int] = field(default_factory=list)
@dataclass
class IniConfig:
subnet: str | None = None
gateway: str | None = None
interface: str | None = None
log_target: str | None = None
deckies: list[DeckySpec] = field(default_factory=list)
custom_services: list[CustomServiceSpec] = field(default_factory=list)
def load_ini(path: str | Path) -> IniConfig: def load_ini(path: str | Path) -> IniConfig:
@@ -81,7 +51,21 @@ def load_ini(path: str | Path) -> IniConfig:
read = cp.read(str(path)) read = cp.read(str(path))
if not read: if not read:
raise FileNotFoundError(f"Config file not found: {path}") raise FileNotFoundError(f"Config file not found: {path}")
return _parse_configparser(cp)
def load_ini_from_string(content: str) -> IniConfig:
"""Parse a DECNET INI string and return an IniConfig."""
# Normalize line endings (CRLF → LF, bare CR → LF) so the validator
# and configparser both see the same line boundaries.
content = content.replace('\r\n', '\n').replace('\r', '\n')
validate_ini_string(content)
cp = configparser.ConfigParser(strict=False)
cp.read_string(content)
return _parse_configparser(cp)
def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig:
cfg = IniConfig() cfg = IniConfig()
if cp.has_section("general"): if cp.has_section("general"):
@@ -89,14 +73,24 @@ def load_ini(path: str | Path) -> IniConfig:
cfg.subnet = g.get("net") cfg.subnet = g.get("net")
cfg.gateway = g.get("gw") cfg.gateway = g.get("gw")
cfg.interface = g.get("interface") cfg.interface = g.get("interface")
cfg.log_target = g.get("log_target") or g.get("log-target")
from decnet.services.registry import all_services
known_services = set(all_services().keys())
# First pass: collect decky sections and custom service definitions # First pass: collect decky sections and custom service definitions
for section in cp.sections(): for section in cp.sections():
if section == "general": if section == "general":
continue continue
# A service sub-section is identified if the section name has at least one dot
# AND the last segment is a known service name.
# e.g. "decky-01.ssh" -> sub-section
# e.g. "decky.webmail" -> decky section (if "webmail" is not a service)
if "." in section: if "." in section:
continue # subsections handled in second pass _, _, last_segment = section.rpartition(".")
if last_segment in known_services:
continue # sub-section handled in second pass
if section.startswith("custom-"): if section.startswith("custom-"):
# Bring-your-own service definition # Bring-your-own service definition
s = cp[section] s = cp[section]
@@ -115,17 +109,30 @@ def load_ini(path: str | Path) -> IniConfig:
services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None
archetype = s.get("archetype") archetype = s.get("archetype")
nmap_os = s.get("nmap_os") or s.get("nmap-os") or None nmap_os = s.get("nmap_os") or s.get("nmap-os") or None
mi_raw = s.get("mutate_interval") or s.get("mutate-interval")
mutate_interval = None
if mi_raw:
try:
mutate_interval = int(mi_raw)
except ValueError:
raise ValueError(f"[{section}] mutate_interval= must be an integer, got '{mi_raw}'")
amount_raw = s.get("amount", "1") amount_raw = s.get("amount", "1")
try: try:
amount = int(amount_raw) amount = int(amount_raw)
if amount < 1: if amount < 1:
raise ValueError raise ValueError
except ValueError: if amount > 100:
raise ValueError(f"[{section}] amount={amount} exceeds maximum allowed (100).")
except ValueError as e:
if "exceeds maximum" in str(e):
raise e
raise ValueError(f"[{section}] amount= must be a positive integer, got '{amount_raw}'") raise ValueError(f"[{section}] amount= must be a positive integer, got '{amount_raw}'")
if amount == 1: if amount == 1:
cfg.deckies.append(DeckySpec( cfg.deckies.append(DeckySpec(
name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, mutate_interval=mutate_interval,
)) ))
else: else:
# Expand into N deckies; explicit ip is ignored (can't share one IP) # Expand into N deckies; explicit ip is ignored (can't share one IP)
@@ -141,6 +148,7 @@ def load_ini(path: str | Path) -> IniConfig:
services=services, services=services,
archetype=archetype, archetype=archetype,
nmap_os=nmap_os, nmap_os=nmap_os,
mutate_interval=mutate_interval,
)) ))
# Second pass: collect per-service subsections [decky-name.service] # Second pass: collect per-service subsections [decky-name.service]
@@ -149,7 +157,11 @@ def load_ini(path: str | Path) -> IniConfig:
for section in cp.sections(): for section in cp.sections():
if "." not in section: if "." not in section:
continue continue
decky_name, _, svc_name = section.partition(".")
decky_name, dot, svc_name = section.rpartition(".")
if svc_name not in known_services:
continue # not a service sub-section
svc_cfg = {k: v for k, v in cp[section].items()} svc_cfg = {k: v for k, v in cp[section].items()}
if decky_name in decky_map: if decky_name in decky_map:
# Direct match — single decky # Direct match — single decky

View File

@@ -1,4 +1,3 @@
from __future__ import annotations
""" """
Rotating file handler for DECNET syslog output. Rotating file handler for DECNET syslog output.
@@ -7,6 +6,8 @@ Path is controlled by the DECNET_LOG_FILE environment variable
(default: /var/log/decnet/decnet.log). (default: /var/log/decnet/decnet.log).
""" """
from __future__ import annotations
import logging import logging
import logging.handlers import logging.handlers
import os import os
@@ -48,11 +49,10 @@ def _get_logger() -> logging.Logger:
def write_syslog(line: str) -> None: def write_syslog(line: str) -> None:
"""Write a single RFC 5424 syslog line to the rotating log file.""" """Write a single RFC 5424 syslog line to the rotating log file."""
try: try:
_get_logger().info(line) _get_logger().info(line)
except Exception: except Exception: # nosec B110
pass pass
def get_log_path() -> Path: def get_log_path() -> Path:
"""Return the configured log file path (for tests/inspection).""" """Return the configured log file path (for tests/inspection)."""
return Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) return Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE))

View File

@@ -1,4 +1,3 @@
from __future__ import annotations
""" """
RFC 5424 syslog formatter for DECNET. RFC 5424 syslog formatter for DECNET.
@@ -9,6 +8,8 @@ Facility: local0 (16)
PEN for structured data: decnet@55555 PEN for structured data: decnet@55555
""" """
from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any

120
decnet/models.py Normal file
View File

@@ -0,0 +1,120 @@
"""
DECNET Domain Models.
Centralized repository for all Pydantic specifications used throughout the project.
This file ensures that core domain logic has no dependencies on the web or database layers.
"""
from typing import Optional, List, Dict, Literal, Annotated, Any
from pydantic import BaseModel, ConfigDict, Field as PydanticField, field_validator, BeforeValidator
import configparser
# --- INI Specification Models ---
def validate_ini_string(v: Any) -> str:
"""Structural validator for DECNET INI strings using configparser."""
if not isinstance(v, str):
# This remains an internal type mismatch (caught by Pydantic usually)
raise ValueError("INI content must be a string")
# 512KB limit to prevent DoS/OOM
if len(v) > 512 * 1024:
raise ValueError("INI content is too large (max 512KB)")
if not v.strip():
# Using exact phrasing expected by tests
raise ValueError("INI content is empty")
parser = configparser.ConfigParser(interpolation=None, allow_no_value=True, strict=False)
try:
parser.read_string(v)
if not parser.sections():
raise ValueError("The provided INI content must contain at least one section (no sections found)")
except configparser.Error as e:
# If it's a generic parsing error, we check if it's effectively a "missing sections" error
if "no section headers" in str(e).lower():
raise ValueError("Invalid INI format: no sections found")
raise ValueError(f"Invalid INI format: {str(e)}")
return v
# Reusable type that enforces INI structure during initialization.
# Removed min_length=1 to make empty strings schema-compliant yet semantically invalid (mapped to 409).
IniContent = Annotated[str, BeforeValidator(validate_ini_string)]
class DeckySpec(BaseModel):
"""Configuration spec for a single decky as defined in the INI file."""
model_config = ConfigDict(strict=True, extra="forbid")
name: str = PydanticField(..., max_length=128, pattern=r"^[A-Za-z0-9\-_.]+$")
ip: Optional[str] = None
services: Optional[List[str]] = None
archetype: Optional[str] = None
service_config: Dict[str, Dict] = PydanticField(default_factory=dict)
nmap_os: Optional[str] = None
mutate_interval: Optional[int] = PydanticField(None, ge=1)
class CustomServiceSpec(BaseModel):
"""Spec for a user-defined (bring-your-own) service."""
model_config = ConfigDict(strict=True, extra="forbid")
name: str
image: str
exec_cmd: str
ports: List[int] = PydanticField(default_factory=list)
class IniConfig(BaseModel):
"""The complete structured representation of a DECNET INI file."""
model_config = ConfigDict(strict=True, extra="forbid")
subnet: Optional[str] = None
gateway: Optional[str] = None
interface: Optional[str] = None
mutate_interval: Optional[int] = PydanticField(None, ge=1)
deckies: List[DeckySpec] = PydanticField(default_factory=list, min_length=1)
custom_services: List[CustomServiceSpec] = PydanticField(default_factory=list)
@field_validator("deckies")
@classmethod
def at_least_one_decky(cls, v: List[DeckySpec]) -> List[DeckySpec]:
"""Ensure that an INI deployment always contains at least one machine."""
if not v:
raise ValueError("INI must contain at least one decky section")
return v
# --- Runtime Configuration Models ---
class DeckyConfig(BaseModel):
"""Full operational configuration for a deployed decky container."""
model_config = ConfigDict(strict=True, extra="forbid")
name: str
ip: str
services: list[str] = PydanticField(..., min_length=1)
distro: str # slug from distros.DISTROS, e.g. "debian", "ubuntu22"
base_image: str # Docker image for the base/IP-holder container
build_base: str = "debian:bookworm-slim" # apt-compatible image for service Dockerfiles
hostname: str
archetype: str | None = None # archetype slug if spawned from an archetype profile
service_config: dict[str, dict] = PydanticField(default_factory=dict)
nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py)
mutate_interval: int | None = None # automatic rotation interval in minutes
last_mutated: float = 0.0 # timestamp of last mutation
last_login_attempt: float = 0.0 # timestamp of most recent interaction
@field_validator("services")
@classmethod
def services_not_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("A decky must have at least one service.")
return v
class DecnetConfig(BaseModel):
"""Root configuration for the entire DECNET fleet deployment."""
mode: Literal["unihost", "swarm"]
interface: str
subnet: str
gateway: str
deckies: list[DeckyConfig] = PydanticField(..., min_length=1)
log_file: str | None = None # host path where the collector writes the log file
ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly)
mutate_interval: int | None = 30 # global automatic rotation interval in minutes

View File

@@ -0,0 +1,3 @@
from decnet.mutator.engine import mutate_all, mutate_decky, run_watch_loop
__all__ = ["mutate_all", "mutate_decky", "run_watch_loop"]

130
decnet/mutator/engine.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Mutation Engine for DECNET.
Handles dynamic rotation of exposed honeypot services over time.
"""
import random
import time
from typing import Optional
from rich.console import Console
from decnet.archetypes import get_archetype
from decnet.fleet import all_service_names
from decnet.composer import write_compose
from decnet.config import DeckyConfig, DecnetConfig
from decnet.engine import _compose_with_retry
from pathlib import Path
import anyio
import asyncio
from decnet.web.db.repository import BaseRepository
console = Console()
async def mutate_decky(decky_name: str, repo: BaseRepository) -> bool:
"""
Perform an Intra-Archetype Shuffle for a specific decky.
Returns True if mutation succeeded, False otherwise.
"""
state_dict = await repo.get_state("deployment")
if state_dict is None:
console.print("[red]No active deployment found in database.[/]")
return False
config = DecnetConfig(**state_dict["config"])
compose_path = Path(state_dict["compose_path"])
decky: Optional[DeckyConfig] = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
console.print(f"[red]Decky '{decky_name}' not found in state.[/]")
return False
if decky.archetype:
try:
arch = get_archetype(decky.archetype)
svc_pool = list(arch.services)
except ValueError:
svc_pool = all_service_names()
else:
svc_pool = all_service_names()
if not svc_pool:
console.print(f"[yellow]No services available for mutating '{decky_name}'.[/]")
return False
current_services = set(decky.services)
attempts = 0
while True:
count = random.randint(1, min(3, len(svc_pool))) # nosec B311
chosen = set(random.sample(svc_pool, count)) # nosec B311
attempts += 1
if chosen != current_services or attempts > 20:
break
decky.services = list(chosen)
decky.last_mutated = time.time()
# Save to DB
await repo.set_state("deployment", {"config": config.model_dump(), "compose_path": str(compose_path)})
# Still writes files for Docker to use
write_compose(config, compose_path)
console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]")
try:
# Wrap blocking call in thread
await anyio.to_thread.run_sync(_compose_with_retry, "up", "-d", "--remove-orphans", compose_path)
except Exception as e:
console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]")
return False
return True
async def mutate_all(repo: BaseRepository, force: bool = False) -> None:
"""
Check all deckies and mutate those that are due.
If force=True, mutates all deckies regardless of schedule.
"""
state_dict = await repo.get_state("deployment")
if state_dict is None:
console.print("[red]No active deployment found.[/]")
return
config = DecnetConfig(**state_dict["config"])
now = time.time()
mutated_count = 0
for decky in config.deckies:
interval_mins = decky.mutate_interval or config.mutate_interval
if interval_mins is None and not force:
continue
if force:
due = True
else:
elapsed_secs = now - decky.last_mutated
due = elapsed_secs >= (interval_mins * 60)
if due:
success = await mutate_decky(decky.name, repo=repo)
if success:
mutated_count += 1
if mutated_count == 0 and not force:
console.print("[dim]No deckies are due for mutation.[/]")
async def run_watch_loop(repo: BaseRepository, poll_interval_secs: int = 10) -> None:
"""Run an infinite loop checking for deckies that need mutation."""
console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]")
try:
while True:
await mutate_all(force=False, repo=repo)
await asyncio.sleep(poll_interval_secs)
except KeyboardInterrupt:
console.print("\n[dim]Mutator watcher stopped.[/]")

View File

@@ -8,11 +8,8 @@ Handles:
- IP allocation (sequential, skipping reserved addresses) - IP allocation (sequential, skipping reserved addresses)
""" """
import ipaddress
import os import os
import shutil import subprocess # nosec B404
import socket
import subprocess
from ipaddress import IPv4Address, IPv4Interface, IPv4Network from ipaddress import IPv4Address, IPv4Interface, IPv4Network
import docker import docker
@@ -27,7 +24,7 @@ HOST_IPVLAN_IFACE = "decnet_ipvlan0"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, check=check) return subprocess.run(cmd, capture_output=True, text=True, check=check) # nosec B603 B404
def detect_interface() -> str: def detect_interface() -> str:

View File

@@ -5,17 +5,31 @@ Maps an nmap OS family slug to a dict of Linux kernel sysctls that, when applied
to a container's network namespace, make its TCP/IP stack behaviour resemble the to a container's network namespace, make its TCP/IP stack behaviour resemble the
claimed OS as closely as possible within the Linux kernel's constraints. claimed OS as closely as possible within the Linux kernel's constraints.
All sysctls listed here are network-namespace-scoped and safe to set per-container
without --privileged (beyond the NET_ADMIN capability already granted).
Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL) Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL)
Linux → 64 Linux → 64
Windows → 128 Windows → 128
BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux) BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux)
Embedded / network → 255 Embedded / network → 255
Secondary tuning (TCP behaviour): Secondary discriminators (nmap OPS / WIN / ECN / T2T6 probe groups):
net.ipv4.tcp_syn_retries SYN retransmits before giving up net.ipv4.tcp_syn_retries SYN retransmits before giving up
net.ipv4.tcp_timestamps TCP timestamp option (OPS probes); Windows = off
net.ipv4.tcp_window_scaling Window scale option; embedded/Cisco typically off
net.ipv4.tcp_sack Selective ACK option; absent on most embedded stacks
net.ipv4.tcp_ecn ECN negotiation; Linux offers (2), Windows off (0)
net.ipv4.ip_no_pmtu_disc DF bit in ICMP replies (IE probes); embedded on
net.ipv4.tcp_fin_timeout FIN_WAIT_2 seconds (T2T6 timing); Windows shorter
ICMP tuning (nmap IE / U1 probe groups):
net.ipv4.icmp_ratelimit Min ms between ICMP error replies; Windows = 0 (none)
net.ipv4.icmp_ratemask Bitmask of ICMP types subject to rate limiting
Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be
set per-container without --privileged; it is intentionally excluded. set per-container without --privileged; TCP window size is already correct for
Windows (64240) from the kernel's default tcp_rmem settings.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,27 +38,69 @@ OS_SYSCTLS: dict[str, dict[str, str]] = {
"linux": { "linux": {
"net.ipv4.ip_default_ttl": "64", "net.ipv4.ip_default_ttl": "64",
"net.ipv4.tcp_syn_retries": "6", "net.ipv4.tcp_syn_retries": "6",
"net.ipv4.tcp_timestamps": "1",
"net.ipv4.tcp_window_scaling": "1",
"net.ipv4.tcp_sack": "1",
"net.ipv4.tcp_ecn": "2",
"net.ipv4.ip_no_pmtu_disc": "0",
"net.ipv4.tcp_fin_timeout": "60",
"net.ipv4.icmp_ratelimit": "1000",
"net.ipv4.icmp_ratemask": "6168",
}, },
"windows": { "windows": {
"net.ipv4.ip_default_ttl": "128", "net.ipv4.ip_default_ttl": "128",
"net.ipv4.tcp_syn_retries": "2", "net.ipv4.tcp_syn_retries": "2",
"net.ipv4.tcp_timestamps": "0",
"net.ipv4.tcp_window_scaling": "1",
"net.ipv4.tcp_sack": "1",
"net.ipv4.tcp_ecn": "0",
"net.ipv4.ip_no_pmtu_disc": "0",
"net.ipv4.tcp_fin_timeout": "30",
"net.ipv4.icmp_ratelimit": "0",
"net.ipv4.icmp_ratemask": "0",
}, },
"bsd": { "bsd": {
"net.ipv4.ip_default_ttl": "64", "net.ipv4.ip_default_ttl": "64",
"net.ipv4.tcp_syn_retries": "6", "net.ipv4.tcp_syn_retries": "6",
"net.ipv4.tcp_timestamps": "1",
"net.ipv4.tcp_window_scaling": "1",
"net.ipv4.tcp_sack": "1",
"net.ipv4.tcp_ecn": "0",
"net.ipv4.ip_no_pmtu_disc": "0",
"net.ipv4.tcp_fin_timeout": "60",
"net.ipv4.icmp_ratelimit": "250",
"net.ipv4.icmp_ratemask": "6168",
}, },
"embedded": { "embedded": {
"net.ipv4.ip_default_ttl": "255", "net.ipv4.ip_default_ttl": "255",
"net.ipv4.tcp_syn_retries": "3", "net.ipv4.tcp_syn_retries": "3",
"net.ipv4.tcp_timestamps": "0",
"net.ipv4.tcp_window_scaling": "0",
"net.ipv4.tcp_sack": "0",
"net.ipv4.tcp_ecn": "0",
"net.ipv4.ip_no_pmtu_disc": "1",
"net.ipv4.tcp_fin_timeout": "15",
"net.ipv4.icmp_ratelimit": "0",
"net.ipv4.icmp_ratemask": "0",
}, },
"cisco": { "cisco": {
"net.ipv4.ip_default_ttl": "255", "net.ipv4.ip_default_ttl": "255",
"net.ipv4.tcp_syn_retries": "2", "net.ipv4.tcp_syn_retries": "2",
"net.ipv4.tcp_timestamps": "0",
"net.ipv4.tcp_window_scaling": "0",
"net.ipv4.tcp_sack": "0",
"net.ipv4.tcp_ecn": "0",
"net.ipv4.ip_no_pmtu_disc": "1",
"net.ipv4.tcp_fin_timeout": "15",
"net.ipv4.icmp_ratelimit": "0",
"net.ipv4.icmp_ratemask": "0",
}, },
} }
_DEFAULT_OS = "linux" _DEFAULT_OS = "linux"
_REQUIRED_SYSCTLS: frozenset[str] = frozenset(OS_SYSCTLS["linux"].keys())
def get_os_sysctls(nmap_os: str) -> dict[str, str]: def get_os_sysctls(nmap_os: str) -> dict[str, str]:
"""Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs.""" """Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs."""
@@ -54,3 +110,4 @@ def get_os_sysctls(nmap_os: str) -> dict[str, str]:
def all_os_families() -> list[str]: def all_os_families() -> list[str]:
"""Return all registered nmap OS family slugs.""" """Return all registered nmap OS family slugs."""
return list(OS_SYSCTLS.keys()) return list(OS_SYSCTLS.keys())

View File

@@ -1,26 +1,35 @@
from pathlib import Path
from decnet.services.base import BaseService from decnet.services.base import BaseService
class ConpotService(BaseService): class ConpotService(BaseService):
"""ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80). """ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80).
Uses the official honeynet/conpot image which ships a default ICS profile Uses a custom build context wrapping the official honeynet/conpot image
that emulates a Siemens S7-200 PLC. to fix Modbus binding to port 502.
""" """
name = "conpot" name = "conpot"
ports = [502, 161, 80] ports = [502, 161, 80]
default_image = "honeynet/conpot" default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
env = {
"CONPOT_TEMPLATE": "default",
"NODE_NAME": decky_name,
}
if log_target:
env["LOG_TARGET"] = log_target
return { return {
"image": "honeynet/conpot", "build": {
"context": str(self.dockerfile_context()),
"args": {"BASE_IMAGE": "honeynet/conpot:latest"},
},
"container_name": f"{decky_name}-conpot", "container_name": f"{decky_name}-conpot",
"restart": "unless-stopped", "restart": "unless-stopped",
"environment": { "environment": env,
"CONPOT_TEMPLATE": "default",
},
} }
def dockerfile_context(self): def dockerfile_context(self):
return None return Path(__file__).parent.parent.parent / "templates" / "conpot"

View File

@@ -1,46 +0,0 @@
from pathlib import Path
from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "real_ssh"
class RealSSHService(BaseService):
"""
Fully interactive OpenSSH server — no honeypot emulation.
Used for the deaddeck (entry-point machine). Attackers get a real shell.
Credentials are intentionally weak to invite exploitation.
service_cfg keys:
password Root password (default: "admin")
hostname Override container hostname
"""
name = "real_ssh"
ports = [22]
default_image = "build"
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
env: dict = {
"SSH_ROOT_PASSWORD": cfg.get("password", "admin"),
}
if "hostname" in cfg:
env["SSH_HOSTNAME"] = cfg["hostname"]
return {
"build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-real-ssh",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": env,
}
def dockerfile_context(self) -> Path:
return TEMPLATES_DIR

View File

@@ -26,6 +26,8 @@ def _load_plugins() -> None:
continue continue
importlib.import_module(f"decnet.services.{module_info.name}") importlib.import_module(f"decnet.services.{module_info.name}")
for cls in BaseService.__subclasses__(): for cls in BaseService.__subclasses__():
if not cls.__module__.startswith("decnet.services."):
continue
instance = cls() instance = cls()
_registry[instance.name] = instance _registry[instance.name] = instance
_loaded = True _loaded = True

View File

@@ -0,0 +1,43 @@
from pathlib import Path
from decnet.services.base import BaseService
# Reuses the same template as the smtp service — only difference is
# SMTP_OPEN_RELAY=1 in the environment, which enables the open relay persona.
_TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "smtp"
class SMTPRelayService(BaseService):
"""SMTP open relay bait — accepts any RCPT TO and delivers messages."""
name = "smtp_relay"
ports = [25, 587]
default_image = "build"
def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
fragment: dict = {
"build": {"context": str(_TEMPLATES_DIR)},
"container_name": f"{decky_name}-smtp_relay",
"restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"],
"environment": {
"NODE_NAME": decky_name,
"SMTP_OPEN_RELAY": "1",
},
}
if log_target:
fragment["environment"]["LOG_TARGET"] = log_target
if "banner" in cfg:
fragment["environment"]["SMTP_BANNER"] = cfg["banner"]
if "mta" in cfg:
fragment["environment"]["SMTP_MTA"] = cfg["mta"]
return fragment
def dockerfile_context(self) -> Path:
return _TEMPLATES_DIR

View File

@@ -1,12 +1,26 @@
from pathlib import Path from pathlib import Path
from decnet.services.base import BaseService from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "cowrie" TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "ssh"
class SSHService(BaseService): class SSHService(BaseService):
"""
Interactive OpenSSH server for general-purpose deckies.
Replaced Cowrie emulation with a real sshd so fingerprinting tools and
experienced attackers cannot trivially identify the honeypot. Auth events,
sudo activity, and interactive commands are all forwarded to stdout as
RFC 5424 via the rsyslog bridge baked into the image.
service_cfg keys:
password Root password (default: "admin")
hostname Override container hostname
"""
name = "ssh" name = "ssh"
ports = [22, 2222] ports = [22]
default_image = "build" default_image = "build"
def compose_fragment( def compose_fragment(
@@ -17,28 +31,10 @@ class SSHService(BaseService):
) -> dict: ) -> dict:
cfg = service_cfg or {} cfg = service_cfg or {}
env: dict = { env: dict = {
"NODE_NAME": decky_name, "SSH_ROOT_PASSWORD": cfg.get("password", "admin"),
"COWRIE_HOSTNAME": decky_name,
"COWRIE_HONEYPOT_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
"COWRIE_SSH_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0",
} }
if log_target: if "hostname" in cfg:
host, port = log_target.rsplit(":", 1) env["SSH_HOSTNAME"] = cfg["hostname"]
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
# Optional persona overrides
if "kernel_version" in cfg:
env["COWRIE_HONEYPOT_KERNEL_VERSION"] = cfg["kernel_version"]
if "kernel_build_string" in cfg:
env["COWRIE_HONEYPOT_KERNEL_BUILD_STRING"] = cfg["kernel_build_string"]
if "hardware_platform" in cfg:
env["COWRIE_HONEYPOT_HARDWARE_PLATFORM"] = cfg["hardware_platform"]
if "ssh_banner" in cfg:
env["COWRIE_SSH_VERSION"] = cfg["ssh_banner"]
if "users" in cfg:
env["COWRIE_USERDB_ENTRIES"] = cfg["users"]
return { return {
"build": {"context": str(TEMPLATES_DIR)}, "build": {"context": str(TEMPLATES_DIR)},

View File

@@ -1,31 +1,47 @@
from pathlib import Path
from decnet.services.base import BaseService from decnet.services.base import BaseService
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "telnet"
class TelnetService(BaseService): class TelnetService(BaseService):
"""
Real telnetd using busybox telnetd + rsyslog logging pipeline.
Replaced Cowrie emulation (which also started an SSH daemon on port 22)
with a real busybox telnetd so only port 23 is exposed and auth events
are logged as RFC 5424 via the same rsyslog bridge used by the SSH service.
service_cfg keys:
password Root password (default: "admin")
hostname Override container hostname
"""
name = "telnet" name = "telnet"
ports = [23] ports = [23]
default_image = "cowrie/cowrie" default_image = "build"
def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: def compose_fragment(
self,
decky_name: str,
log_target: str | None = None,
service_cfg: dict | None = None,
) -> dict:
cfg = service_cfg or {}
env: dict = { env: dict = {
"COWRIE_HONEYPOT_HOSTNAME": decky_name, "TELNET_ROOT_PASSWORD": cfg.get("password", "admin"),
"COWRIE_TELNET_ENABLED": "true",
"COWRIE_TELNET_LISTEN_ENDPOINTS": "tcp:23:interface=0.0.0.0",
# Disable SSH so this container is telnet-only
"COWRIE_SSH_ENABLED": "false",
} }
if log_target: if "hostname" in cfg:
host, port = log_target.rsplit(":", 1) env["TELNET_HOSTNAME"] = cfg["hostname"]
env["COWRIE_OUTPUT_TCP_ENABLED"] = "true"
env["COWRIE_OUTPUT_TCP_HOST"] = host
env["COWRIE_OUTPUT_TCP_PORT"] = port
return { return {
"image": "cowrie/cowrie", "build": {"context": str(TEMPLATES_DIR)},
"container_name": f"{decky_name}-telnet", "container_name": f"{decky_name}-telnet",
"restart": "unless-stopped", "restart": "unless-stopped",
"cap_add": ["NET_BIND_SERVICE"], "cap_add": ["NET_BIND_SERVICE"],
"environment": env, "environment": env,
} }
def dockerfile_context(self): def dockerfile_context(self) -> Path:
return None return TEMPLATES_DIR

170
decnet/web/api.py Normal file
View File

@@ -0,0 +1,170 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Optional
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from fastapi.middleware.cors import CORSMiddleware
from decnet.env import DECNET_CORS_ORIGINS, DECNET_DEVELOPER, DECNET_INGEST_LOG_FILE
from decnet.web.dependencies import repo
from decnet.collector import log_collector_worker
from decnet.web.ingester import log_ingestion_worker
from decnet.web.router import api_router
log = logging.getLogger(__name__)
ingestion_task: Optional[asyncio.Task[Any]] = None
collector_task: Optional[asyncio.Task[Any]] = None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
global ingestion_task, collector_task
for attempt in range(1, 6):
try:
await repo.initialize()
break
except Exception as exc:
log.warning("DB init attempt %d/5 failed: %s", attempt, exc)
if attempt == 5:
log.error("DB failed to initialize after 5 attempts — startup may be degraded")
await asyncio.sleep(0.5)
# Start background tasks only if not in contract test mode
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
# Start background ingestion task
if ingestion_task is None or ingestion_task.done():
ingestion_task = asyncio.create_task(log_ingestion_worker(repo))
# Start Docker log collector (writes to log file; ingester reads from it)
_log_file = os.environ.get("DECNET_INGEST_LOG_FILE", DECNET_INGEST_LOG_FILE)
if _log_file and (collector_task is None or collector_task.done()):
collector_task = asyncio.create_task(log_collector_worker(_log_file))
elif not _log_file:
log.warning("DECNET_INGEST_LOG_FILE not set — Docker log collection disabled.")
else:
log.info("Contract Test Mode: skipping background worker startup")
yield
# Shutdown background tasks
for task in (ingestion_task, collector_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as exc:
log.warning("Task shutdown error: %s", exc)
app: FastAPI = FastAPI(
title="DECNET Web Dashboard API",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if DECNET_DEVELOPER else None,
redoc_url="/redoc" if DECNET_DEVELOPER else None,
openapi_url="/openapi.json" if DECNET_DEVELOPER else None
)
app.add_middleware(
CORSMiddleware,
allow_origins=DECNET_CORS_ORIGINS,
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Last-Event-ID"],
)
# Include the modular API router
app.include_router(api_router, prefix="/api/v1")
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""
Handle validation errors with targeted status codes to satisfy contract tests.
Tiered Prioritization:
1. 400 Bad Request: For structural schema violations (extra fields, wrong types, missing fields).
This satisfies Schemathesis 'Negative Data' checks.
2. 409 Conflict: For semantic/structural INI content violations in valid strings.
This satisfies Schemathesis 'Positive Data' checks.
3. 422 Unprocessable: Default for other validation edge cases.
"""
errors = exc.errors()
# 1. Prioritize Structural Format Violations (Negative Data)
# This catches: sending an object instead of a string, extra unknown properties, or empty-string length violations.
is_structural_violation = any(
err.get("type") in ("type_error", "extra_forbidden", "missing", "string_too_short", "string_type") or
"must be a string" in err.get("msg", "") # Catch our validator's type check
for err in errors
)
if is_structural_violation:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "Bad Request: Schema structural violation (wrong type, extra fields, or invalid length)."},
)
# 2. Targeted INI Error Rejections
# We distinguishes between different failure modes for precise contract compliance.
# Empty INI content (Valid string but semantically empty)
is_ini_empty = any("INI content is empty" in err.get("msg", "") for err in errors)
if is_ini_empty:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "Configuration conflict: INI content is empty."},
)
# Invalid characters/syntax (Valid-length string but invalid INI syntax)
# Mapping to 409 for Positive Data compliance.
is_invalid_characters = any("Invalid INI format" in err.get("msg", "") for err in errors)
if is_invalid_characters:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "Configuration conflict: INI syntax or characters are invalid."},
)
# Logical invalidity (Valid string, valid syntax, but missing required DECNET logic like sections)
is_ini_invalid_logic = any("at least one section" in err.get("msg", "") for err in errors)
if is_ini_invalid_logic:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "Invalid INI config structure: No decky sections found."},
)
# Developer Mode fallback
if DECNET_DEVELOPER:
from fastapi.exception_handlers import request_validation_exception_handler
return await request_validation_exception_handler(request, exc)
# Production/Strict mode fallback: Sanitize remaining 422s
message = "Invalid request parameters"
if "/deckies/deploy" in request.url.path:
message = "Invalid INI config"
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": message},
)
@app.exception_handler(ValidationError)
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse:
"""
Handle Pydantic errors that occur during manual model instantiation (e.g. state hydration).
Prevents 500 errors when the database contains inconsistent or outdated schema data.
"""
log.error("Internal Pydantic validation error: %s", exc)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"detail": "Internal data consistency error",
"type": "internal_validation_error"
},
)

38
decnet/web/auth.py Normal file
View File

@@ -0,0 +1,38 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, Any
import jwt
import bcrypt
from decnet.env import DECNET_JWT_SECRET
SECRET_KEY: str = DECNET_JWT_SECRET
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8")[:72],
hashed_password.encode("utf-8")
)
def get_password_hash(password: str) -> str:
# Use a cost factor of 12 (default for passlib/bcrypt)
_salt: bytes = bcrypt.gensalt(rounds=12)
_hashed: bytes = bcrypt.hashpw(password.encode("utf-8")[:72], _salt)
return _hashed.decode("utf-8")
def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
_to_encode: dict[str, Any] = data.copy()
_expire: datetime
if expires_delta:
_expire = datetime.now(timezone.utc) + expires_delta
else:
_expire = datetime.now(timezone.utc) + timedelta(minutes=15)
_to_encode.update({"exp": _expire})
_to_encode.update({"iat": datetime.now(timezone.utc)})
_encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM)
return _encoded_jwt

18
decnet/web/db/factory.py Normal file
View File

@@ -0,0 +1,18 @@
from typing import Any
from decnet.env import os
from decnet.web.db.repository import BaseRepository
def get_repository(**kwargs: Any) -> BaseRepository:
"""Factory function to instantiate the correct repository implementation based on environment."""
db_type = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
if db_type == "sqlite":
from decnet.web.db.sqlite.repository import SQLiteRepository
return SQLiteRepository(**kwargs)
elif db_type == "mysql":
# Placeholder for future implementation
# from decnet.web.db.mysql.repository import MySQLRepository
# return MySQLRepository()
raise NotImplementedError("MySQL support is planned but not yet implemented.")
else:
raise ValueError(f"Unsupported database type: {db_type}")

95
decnet/web/db/models.py Normal file
View File

@@ -0,0 +1,95 @@
from datetime import datetime, timezone
from typing import Optional, Any, List, Annotated
from sqlmodel import SQLModel, Field
from pydantic import BaseModel, ConfigDict, Field as PydanticField, BeforeValidator
from decnet.models import IniContent
def _normalize_null(v: Any) -> Any:
if isinstance(v, str) and v.lower() in ("null", "undefined", ""):
return None
return v
NullableDatetime = Annotated[Optional[datetime], BeforeValidator(_normalize_null)]
NullableString = Annotated[Optional[str], BeforeValidator(_normalize_null)]
# --- Database Tables (SQLModel) ---
class User(SQLModel, table=True):
__tablename__ = "users"
uuid: str = Field(primary_key=True)
username: str = Field(index=True, unique=True)
password_hash: str
role: str = Field(default="viewer")
must_change_password: bool = Field(default=False)
class Log(SQLModel, table=True):
__tablename__ = "logs"
id: Optional[int] = Field(default=None, primary_key=True)
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
decky: str = Field(index=True)
service: str = Field(index=True)
event_type: str = Field(index=True)
attacker_ip: str = Field(index=True)
raw_line: str
fields: str
msg: Optional[str] = None
class Bounty(SQLModel, table=True):
__tablename__ = "bounty"
id: Optional[int] = Field(default=None, primary_key=True)
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)
decky: str = Field(index=True)
service: str = Field(index=True)
attacker_ip: str = Field(index=True)
bounty_type: str = Field(index=True)
payload: str
class State(SQLModel, table=True):
__tablename__ = "state"
key: str = Field(primary_key=True)
value: str # Stores JSON serialized DecnetConfig or other state blobs
# --- API Request/Response Models (Pydantic) ---
class Token(BaseModel):
access_token: str
token_type: str
must_change_password: bool = False
class LoginRequest(BaseModel):
username: str
password: str = PydanticField(..., max_length=72)
class ChangePasswordRequest(BaseModel):
old_password: str = PydanticField(..., max_length=72)
new_password: str = PydanticField(..., max_length=72)
class LogsResponse(BaseModel):
total: int
limit: int
offset: int
data: List[dict[str, Any]]
class BountyResponse(BaseModel):
total: int
limit: int
offset: int
data: List[dict[str, Any]]
class StatsResponse(BaseModel):
total_logs: int
unique_attackers: int
active_deckies: int
deployed_deckies: int
class MutateIntervalRequest(BaseModel):
# Human-readable duration: <number><unit> where unit is m(inutes), d(ays), M(onths), y/Y(ears).
# Minimum granularity is 1 minute. Seconds are not accepted.
mutate_interval: Optional[str] = PydanticField(None, pattern=r"^[1-9]\d*[mdMyY]$")
class DeployIniRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
# This field now enforces strict INI structure during Pydantic initialization.
# The OpenAPI schema correctly shows it as a required string.
ini_content: IniContent = PydanticField(..., description="A valid INI formatted string")

View File

@@ -0,0 +1,92 @@
from abc import ABC, abstractmethod
from typing import Any, Optional
class BaseRepository(ABC):
"""Abstract base class for DECNET web dashboard data storage."""
@abstractmethod
async def initialize(self) -> None:
"""Initialize the database schema."""
pass
@abstractmethod
async def add_log(self, log_data: dict[str, Any]) -> None:
"""Add a new log entry to the database."""
pass
@abstractmethod
async def get_logs(
self,
limit: int = 50,
offset: int = 0,
search: Optional[str] = None
) -> list[dict[str, Any]]:
"""Retrieve paginated log entries."""
pass
@abstractmethod
async def get_total_logs(self, search: Optional[str] = None) -> int:
"""Retrieve the total count of logs, optionally filtered by search."""
pass
@abstractmethod
async def get_stats_summary(self) -> dict[str, Any]:
"""Retrieve high-level dashboard metrics."""
pass
@abstractmethod
async def get_deckies(self) -> list[dict[str, Any]]:
"""Retrieve the list of currently deployed deckies."""
pass
@abstractmethod
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
"""Retrieve a user by their username."""
pass
@abstractmethod
async def get_user_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
"""Retrieve a user by their UUID."""
pass
@abstractmethod
async def create_user(self, user_data: dict[str, Any]) -> None:
"""Create a new dashboard user."""
pass
@abstractmethod
async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None:
"""Update a user's password and change the must_change_password flag."""
pass
@abstractmethod
async def add_bounty(self, bounty_data: dict[str, Any]) -> None:
"""Add a new harvested artifact (bounty) to the database."""
pass
@abstractmethod
async def get_bounties(
self,
limit: int = 50,
offset: int = 0,
bounty_type: Optional[str] = None,
search: Optional[str] = None
) -> list[dict[str, Any]]:
"""Retrieve paginated bounty entries."""
pass
@abstractmethod
async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int:
"""Retrieve the total count of bounties, optionally filtered."""
pass
@abstractmethod
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
"""Retrieve a specific state entry by key."""
pass
@abstractmethod
async def set_state(self, key: str, value: Any) -> None:
"""Store a specific state entry by key."""
pass

View File

@@ -0,0 +1,36 @@
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy import create_engine, Engine
from sqlmodel import SQLModel
from typing import AsyncGenerator
# We need both sync and async engines for SQLite
# Sync for initialization (DDL) and async for standard queries
def get_async_engine(db_path: str) -> AsyncEngine:
# If it's a memory URI, don't add the extra slash that turns it into a relative file
prefix = "sqlite+aiosqlite:///"
if db_path.startswith(":memory:"):
prefix = "sqlite+aiosqlite://"
return create_async_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
def get_sync_engine(db_path: str) -> Engine:
prefix = "sqlite:///"
if db_path.startswith(":memory:"):
prefix = "sqlite://"
return create_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True})
def init_db(db_path: str) -> None:
"""Synchronously create all tables."""
engine = get_sync_engine(db_path)
# Ensure WAL mode is set
with engine.connect() as conn:
conn.exec_driver_sql("PRAGMA journal_mode=WAL")
conn.exec_driver_sql("PRAGMA synchronous=NORMAL")
SQLModel.metadata.create_all(engine)
async def get_session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]:
async_session = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session

View File

@@ -0,0 +1,378 @@
import asyncio
import json
import uuid
from datetime import datetime
from typing import Any, Optional, List
from sqlalchemy import func, select, desc, asc, text, or_, update, literal_column
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlmodel.sql.expression import SelectOfScalar
from decnet.config import load_state, _ROOT
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
from decnet.web.auth import get_password_hash
from decnet.web.db.repository import BaseRepository
from decnet.web.db.models import User, Log, Bounty, State
from decnet.web.db.sqlite.database import get_async_engine
class SQLiteRepository(BaseRepository):
"""SQLite implementation using SQLModel and SQLAlchemy Async."""
def __init__(self, db_path: str = str(_ROOT / "decnet.db")) -> None:
self.db_path = db_path
self.engine = get_async_engine(db_path)
self.session_factory = async_sessionmaker(
self.engine, class_=AsyncSession, expire_on_commit=False
)
async def initialize(self) -> None:
"""Async warm-up / verification. Creates tables if they don't exist."""
from sqlmodel import SQLModel
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with self.session_factory() as session:
# Check if admin exists
result = await session.execute(
select(User).where(User.username == DECNET_ADMIN_USER)
)
if not result.scalar_one_or_none():
session.add(User(
uuid=str(uuid.uuid4()),
username=DECNET_ADMIN_USER,
password_hash=get_password_hash(DECNET_ADMIN_PASSWORD),
role="admin",
must_change_password=True,
))
await session.commit()
async def reinitialize(self) -> None:
"""Initialize the database schema asynchronously (useful for tests)."""
from sqlmodel import SQLModel
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with self.session_factory() as session:
result = await session.execute(
select(User).where(User.username == DECNET_ADMIN_USER)
)
if not result.scalar_one_or_none():
session.add(User(
uuid=str(uuid.uuid4()),
username=DECNET_ADMIN_USER,
password_hash=get_password_hash(DECNET_ADMIN_PASSWORD),
role="admin",
must_change_password=True,
))
await session.commit()
# ------------------------------------------------------------------ logs
async def add_log(self, log_data: dict[str, Any]) -> None:
data = log_data.copy()
if "fields" in data and isinstance(data["fields"], dict):
data["fields"] = json.dumps(data["fields"])
if "timestamp" in data and isinstance(data["timestamp"], str):
try:
data["timestamp"] = datetime.fromisoformat(
data["timestamp"].replace("Z", "+00:00")
)
except ValueError:
pass
async with self.session_factory() as session:
session.add(Log(**data))
await session.commit()
def _apply_filters(
self,
statement: SelectOfScalar,
search: Optional[str],
start_time: Optional[str],
end_time: Optional[str],
) -> SelectOfScalar:
import re
import shlex
if start_time:
statement = statement.where(Log.timestamp >= start_time)
if end_time:
statement = statement.where(Log.timestamp <= end_time)
if search:
try:
tokens = shlex.split(search)
except ValueError:
tokens = search.split()
core_fields = {
"decky": Log.decky,
"service": Log.service,
"event": Log.event_type,
"attacker": Log.attacker_ip,
"attacker-ip": Log.attacker_ip,
"attacker_ip": Log.attacker_ip,
}
for token in tokens:
if ":" in token:
key, val = token.split(":", 1)
if key in core_fields:
statement = statement.where(core_fields[key] == val)
else:
key_safe = re.sub(r"[^a-zA-Z0-9_]", "", key)
if key_safe:
statement = statement.where(
text(f"json_extract(fields, '$.{key_safe}') = :val")
).params(val=val)
else:
lk = f"%{token}%"
statement = statement.where(
or_(
Log.raw_line.like(lk),
Log.decky.like(lk),
Log.service.like(lk),
Log.attacker_ip.like(lk),
)
)
return statement
async def get_logs(
self,
limit: int = 50,
offset: int = 0,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> List[dict]:
statement = (
select(Log)
.order_by(desc(Log.timestamp))
.offset(offset)
.limit(limit)
)
statement = self._apply_filters(statement, search, start_time, end_time)
async with self.session_factory() as session:
results = await session.execute(statement)
return [log.model_dump(mode='json') for log in results.scalars().all()]
async def get_max_log_id(self) -> int:
async with self.session_factory() as session:
result = await session.execute(select(func.max(Log.id)))
val = result.scalar()
return val if val is not None else 0
async def get_logs_after_id(
self,
last_id: int,
limit: int = 50,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> List[dict]:
statement = (
select(Log).where(Log.id > last_id).order_by(asc(Log.id)).limit(limit)
)
statement = self._apply_filters(statement, search, start_time, end_time)
async with self.session_factory() as session:
results = await session.execute(statement)
return [log.model_dump(mode='json') for log in results.scalars().all()]
async def get_total_logs(
self,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> int:
statement = select(func.count()).select_from(Log)
statement = self._apply_filters(statement, search, start_time, end_time)
async with self.session_factory() as session:
result = await session.execute(statement)
return result.scalar() or 0
async def get_log_histogram(
self,
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
interval_minutes: int = 15,
) -> List[dict]:
bucket_seconds = max(interval_minutes, 1) * 60
bucket_expr = literal_column(
f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')"
).label("bucket_time")
statement = select(bucket_expr, func.count().label("count")).select_from(Log)
statement = self._apply_filters(statement, search, start_time, end_time)
statement = statement.group_by(literal_column("bucket_time")).order_by(
literal_column("bucket_time")
)
async with self.session_factory() as session:
results = await session.execute(statement)
return [{"time": r[0], "count": r[1]} for r in results.all()]
async def get_stats_summary(self) -> dict[str, Any]:
async with self.session_factory() as session:
total_logs = (
await session.execute(select(func.count()).select_from(Log))
).scalar() or 0
unique_attackers = (
await session.execute(
select(func.count(func.distinct(Log.attacker_ip)))
)
).scalar() or 0
active_deckies = (
await session.execute(
select(func.count(func.distinct(Log.decky)))
)
).scalar() or 0
_state = await asyncio.to_thread(load_state)
deployed_deckies = len(_state[0].deckies) if _state else 0
return {
"total_logs": total_logs,
"unique_attackers": unique_attackers,
"active_deckies": active_deckies,
"deployed_deckies": deployed_deckies,
}
async def get_deckies(self) -> List[dict]:
_state = await asyncio.to_thread(load_state)
return [_d.model_dump() for _d in _state[0].deckies] if _state else []
# ------------------------------------------------------------------ users
async def get_user_by_username(self, username: str) -> Optional[dict]:
async with self.session_factory() as session:
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalar_one_or_none()
return user.model_dump() if user else None
async def get_user_by_uuid(self, uuid: str) -> Optional[dict]:
async with self.session_factory() as session:
result = await session.execute(
select(User).where(User.uuid == uuid)
)
user = result.scalar_one_or_none()
return user.model_dump() if user else None
async def create_user(self, user_data: dict[str, Any]) -> None:
async with self.session_factory() as session:
session.add(User(**user_data))
await session.commit()
async def update_user_password(
self, uuid: str, password_hash: str, must_change_password: bool = False
) -> None:
async with self.session_factory() as session:
await session.execute(
update(User)
.where(User.uuid == uuid)
.values(
password_hash=password_hash,
must_change_password=must_change_password,
)
)
await session.commit()
# ---------------------------------------------------------------- bounties
async def add_bounty(self, bounty_data: dict[str, Any]) -> None:
data = bounty_data.copy()
if "payload" in data and isinstance(data["payload"], dict):
data["payload"] = json.dumps(data["payload"])
async with self.session_factory() as session:
session.add(Bounty(**data))
await session.commit()
def _apply_bounty_filters(
self,
statement: SelectOfScalar,
bounty_type: Optional[str],
search: Optional[str]
) -> SelectOfScalar:
if bounty_type:
statement = statement.where(Bounty.bounty_type == bounty_type)
if search:
lk = f"%{search}%"
statement = statement.where(
or_(
Bounty.decky.like(lk),
Bounty.service.like(lk),
Bounty.attacker_ip.like(lk),
Bounty.payload.like(lk),
)
)
return statement
async def get_bounties(
self,
limit: int = 50,
offset: int = 0,
bounty_type: Optional[str] = None,
search: Optional[str] = None,
) -> List[dict]:
statement = (
select(Bounty)
.order_by(desc(Bounty.timestamp))
.offset(offset)
.limit(limit)
)
statement = self._apply_bounty_filters(statement, bounty_type, search)
async with self.session_factory() as session:
results = await session.execute(statement)
final = []
for item in results.scalars().all():
d = item.model_dump(mode='json')
try:
d["payload"] = json.loads(d["payload"])
except (json.JSONDecodeError, TypeError):
pass
final.append(d)
return final
async def get_total_bounties(
self, bounty_type: Optional[str] = None, search: Optional[str] = None
) -> int:
statement = select(func.count()).select_from(Bounty)
statement = self._apply_bounty_filters(statement, bounty_type, search)
async with self.session_factory() as session:
result = await session.execute(statement)
return result.scalar() or 0
async def get_state(self, key: str) -> Optional[dict[str, Any]]:
async with self.session_factory() as session:
statement = select(State).where(State.key == key)
result = await session.execute(statement)
state = result.scalar_one_or_none()
if state:
return json.loads(state.value)
return None
async def set_state(self, key: str, value: Any) -> None: # noqa: ANN401
async with self.session_factory() as session:
# Check if exists
statement = select(State).where(State.key == key)
result = await session.execute(statement)
state = result.scalar_one_or_none()
value_json = json.dumps(value)
if state:
state.value = value_json
session.add(state)
else:
new_state = State(key=key, value=value_json)
session.add(new_state)
await session.commit()

View File

@@ -0,0 +1,98 @@
from typing import Any, Optional
import jwt
from fastapi import HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from decnet.web.auth import ALGORITHM, SECRET_KEY
from decnet.web.db.repository import BaseRepository
from decnet.web.db.factory import get_repository
# Shared repository singleton
_repo: Optional[BaseRepository] = None
def get_repo() -> BaseRepository:
"""FastAPI dependency to inject the configured repository."""
global _repo
if _repo is None:
_repo = get_repository()
return _repo
repo = get_repo()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_stream_user(request: Request, token: Optional[str] = None) -> str:
"""Auth dependency for SSE endpoints — accepts Bearer header OR ?token= query param.
EventSource does not support custom headers, so the query-string fallback is intentional here only.
"""
_credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
auth_header = request.headers.get("Authorization")
resolved: str | None = (
auth_header.split(" ", 1)[1]
if auth_header and auth_header.startswith("Bearer ")
else token
)
if not resolved:
raise _credentials_exception
try:
_payload: dict[str, Any] = jwt.decode(resolved, SECRET_KEY, algorithms=[ALGORITHM])
_user_uuid: Optional[str] = _payload.get("uuid")
if _user_uuid is None:
raise _credentials_exception
return _user_uuid
except jwt.PyJWTError:
raise _credentials_exception
async def _decode_token(request: Request) -> str:
"""Decode and validate a Bearer JWT, returning the user UUID."""
_credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
auth_header = request.headers.get("Authorization")
token: str | None = (
auth_header.split(" ", 1)[1]
if auth_header and auth_header.startswith("Bearer ")
else None
)
if not token:
raise _credentials_exception
try:
_payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
_user_uuid: Optional[str] = _payload.get("uuid")
if _user_uuid is None:
raise _credentials_exception
return _user_uuid
except jwt.PyJWTError:
raise _credentials_exception
async def get_current_user(request: Request) -> str:
"""Auth dependency — enforces must_change_password."""
_user_uuid = await _decode_token(request)
_user = await repo.get_user_by_uuid(_user_uuid)
if _user and _user.get("must_change_password"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change required before accessing this resource",
)
return _user_uuid
async def get_current_user_unchecked(request: Request) -> str:
"""Auth dependency — skips must_change_password enforcement.
Use only for endpoints that must remain reachable with the flag set (e.g. change-password).
"""
return await _decode_token(request)

99
decnet/web/ingester.py Normal file
View File

@@ -0,0 +1,99 @@
import asyncio
import os
import logging
import json
from typing import Any
from pathlib import Path
from decnet.web.db.repository import BaseRepository
logger: logging.Logger = logging.getLogger("decnet.web.ingester")
async def log_ingestion_worker(repo: BaseRepository) -> None:
"""
Background task that tails the DECNET_INGEST_LOG_FILE.json and
inserts structured JSON logs into the SQLite repository.
"""
_base_log_file: str | None = os.environ.get("DECNET_INGEST_LOG_FILE")
if not _base_log_file:
logger.warning("DECNET_INGEST_LOG_FILE not set. Log ingestion disabled.")
return
_json_log_path: Path = Path(_base_log_file).with_suffix(".json")
_position: int = 0
logger.info(f"Starting JSON log ingestion from {_json_log_path}")
while True:
try:
if not _json_log_path.exists():
await asyncio.sleep(2)
continue
_stat: os.stat_result = _json_log_path.stat()
if _stat.st_size < _position:
# File rotated or truncated
_position = 0
if _stat.st_size == _position:
# No new data
await asyncio.sleep(1)
continue
with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f:
_f.seek(_position)
while True:
_line: str = _f.readline()
if not _line:
break # EOF reached
if not _line.endswith('\n'):
# Partial line read, don't process yet, don't advance position
break
try:
_log_data: dict[str, Any] = json.loads(_line.strip())
await repo.add_log(_log_data)
await _extract_bounty(repo, _log_data)
except json.JSONDecodeError:
logger.error(f"Failed to decode JSON log line: {_line}")
continue
# Update position after successful line read
_position = _f.tell()
except Exception as _e:
_err_str = str(_e).lower()
if "no such table" in _err_str or "no active connection" in _err_str or "connection closed" in _err_str:
logger.error(f"Post-shutdown or fatal DB error in ingester: {_e}")
break # Exit worker — DB is gone or uninitialized
logger.error(f"Error in log ingestion worker: {_e}")
await asyncio.sleep(5)
await asyncio.sleep(1)
async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> None:
"""Detect and extract valuable artifacts (bounties) from log entries."""
_fields = log_data.get("fields")
if not isinstance(_fields, dict):
return
# 1. Credentials (User/Pass)
_user = _fields.get("username")
_pass = _fields.get("password")
if _user and _pass:
await repo.add_bounty({
"decky": log_data.get("decky"),
"service": log_data.get("service"),
"attacker_ip": log_data.get("attacker_ip"),
"bounty_type": "credential",
"payload": {
"username": _user,
"password": _pass
}
})
# 2. Add more extractors here later (e.g. file hashes, crypto keys)

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter
from .auth.api_login import router as login_router
from .auth.api_change_pass import router as change_pass_router
from .logs.api_get_logs import router as logs_router
from .logs.api_get_histogram import router as histogram_router
from .bounty.api_get_bounties import router as bounty_router
from .stats.api_get_stats import router as stats_router
from .fleet.api_get_deckies import router as get_deckies_router
from .fleet.api_mutate_decky import router as mutate_decky_router
from .fleet.api_mutate_interval import router as mutate_interval_router
from .fleet.api_deploy_deckies import router as deploy_deckies_router
from .stream.api_stream_events import router as stream_router
api_router = APIRouter()
# Authentication
api_router.include_router(login_router)
api_router.include_router(change_pass_router)
# Logs & Analytics
api_router.include_router(logs_router)
api_router.include_router(histogram_router)
# Bounty Vault
api_router.include_router(bounty_router)
# Fleet Management
api_router.include_router(get_deckies_router)
api_router.include_router(mutate_decky_router)
api_router.include_router(mutate_interval_router)
api_router.include_router(deploy_deckies_router)
# Observability
api_router.include_router(stats_router)
api_router.include_router(stream_router)

View File

@@ -0,0 +1,31 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from decnet.web.auth import get_password_hash, verify_password
from decnet.web.dependencies import get_current_user_unchecked, repo
from decnet.web.db.models import ChangePasswordRequest
router = APIRouter()
@router.post(
"/auth/change-password",
tags=["Authentication"],
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
422: {"description": "Validation error"}
},
)
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user_unchecked)) -> dict[str, str]:
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
if not _user or not verify_password(request.old_password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect old password",
)
_new_hash: str = get_password_hash(request.new_password)
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
return {"message": "Password updated successfully"}

View File

@@ -0,0 +1,45 @@
from datetime import timedelta
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, status
from decnet.web.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token,
verify_password,
)
from decnet.web.dependencies import repo
from decnet.web.db.models import LoginRequest, Token
router = APIRouter()
@router.post(
"/auth/login",
response_model=Token,
tags=["Authentication"],
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Incorrect username or password"},
422: {"description": "Validation error"}
},
)
async def login(request: LoginRequest) -> dict[str, Any]:
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
if not _user or not verify_password(request.password, _user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
_access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
# Token uses uuid instead of sub
_access_token: str = create_access_token(
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
)
return {
"access_token": _access_token,
"token_type": "bearer", # nosec B105
"must_change_password": bool(_user.get("must_change_password", False))
}

View File

@@ -0,0 +1,36 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
from decnet.web.db.models import BountyResponse
router = APIRouter()
@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"],
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
async def get_bounties(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0, le=2147483647),
bounty_type: Optional[str] = None,
search: Optional[str] = None,
current_user: str = Depends(get_current_user)
) -> dict[str, Any]:
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
def _norm(v: Optional[str]) -> Optional[str]:
if v in (None, "null", "NULL", "undefined", ""):
return None
return v
bt = _norm(bounty_type)
s = _norm(search)
_data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bt, search=s)
_total = await repo.get_total_bounties(bounty_type=bt, search=s)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _data
}

View File

@@ -0,0 +1,106 @@
import logging
import os
from fastapi import APIRouter, Depends, HTTPException
from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT, log
from decnet.engine import deploy as _deploy
from decnet.ini_loader import load_ini_from_string
from decnet.network import detect_interface, detect_subnet, get_host_ip
from decnet.web.dependencies import get_current_user, repo
from decnet.web.db.models import DeployIniRequest
router = APIRouter()
@router.post(
"/deckies/deploy",
tags=["Fleet Management"],
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
409: {"description": "Configuration conflict (e.g. invalid IP allocation or network mismatch)"},
422: {"description": "Invalid INI config or schema validation error"},
500: {"description": "Deployment failed"}
}
)
async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
from decnet.fleet import build_deckies_from_ini
try:
ini = load_ini_from_string(req.ini_content)
except ValueError as e:
log.debug("deploy: invalid INI structure: %s", e)
raise HTTPException(status_code=409, detail=str(e))
log.debug("deploy: processing configuration for %d deckies", len(ini.deckies))
state_dict = await repo.get_state("deployment")
ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
if state_dict:
config = DecnetConfig(**state_dict["config"])
subnet_cidr = ini.subnet or config.subnet
gateway = ini.gateway or config.gateway
host_ip = get_host_ip(config.interface)
# Always sync config log_file with current API ingestion target
if ingest_log_file:
config.log_file = ingest_log_file
else:
# If no state exists, we need to infer network details from the INI or the host.
try:
iface = ini.interface or detect_interface()
subnet_cidr, gateway = ini.subnet, ini.gateway
if not subnet_cidr or not gateway:
detected_subnet, detected_gateway = detect_subnet(iface)
subnet_cidr = subnet_cidr or detected_subnet
gateway = gateway or detected_gateway
host_ip = get_host_ip(iface)
except RuntimeError as e:
raise HTTPException(
status_code=409,
detail=f"Network configuration conflict: {e}. "
"Add a [general] section with interface=, net=, and gw= to the INI."
)
config = DecnetConfig(
mode="unihost",
interface=iface,
subnet=subnet_cidr,
gateway=gateway,
deckies=[],
log_file=ingest_log_file,
ipvlan=False,
mutate_interval=ini.mutate_interval or DEFAULT_MUTATE_INTERVAL
)
try:
new_decky_configs = build_deckies_from_ini(
ini, subnet_cidr, gateway, host_ip, False, cli_mutate_interval=None
)
except ValueError as e:
log.debug("deploy: build_deckies_from_ini rejected input: %s", e)
raise HTTPException(status_code=409, detail=str(e))
# Merge deckies
existing_deckies_map = {d.name: d for d in config.deckies}
for new_decky in new_decky_configs:
existing_deckies_map[new_decky.name] = new_decky
config.deckies = list(existing_deckies_map.values())
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
try:
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
_deploy(config)
# Persist new state to DB
new_state_payload = {
"config": config.model_dump(),
"compose_path": str(_ROOT / "docker-compose.yml") if not state_dict else state_dict["compose_path"]
}
await repo.set_state("deployment", new_state_payload)
except Exception as e:
logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e)
raise HTTPException(status_code=500, detail="Deployment failed. Check server logs for details.")
return {"message": "Deckies deployed successfully"}

View File

@@ -0,0 +1,13 @@
from typing import Any
from fastapi import APIRouter, Depends
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.get("/deckies", tags=["Fleet Management"],
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]:
return await repo.get_deckies()

View File

@@ -0,0 +1,25 @@
import os
from fastapi import APIRouter, Depends, HTTPException, Path
from decnet.mutator import mutate_decky
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.post(
"/deckies/{decky_name}/mutate",
tags=["Fleet Management"],
responses={401: {"description": "Could not validate credentials"}, 404: {"description": "Decky not found"}}
)
async def api_mutate_decky(
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
current_user: str = Depends(get_current_user),
) -> dict[str, str]:
if os.environ.get("DECNET_CONTRACT_TEST") == "true":
return {"message": f"Successfully mutated {decky_name} (Contract Test Mock)"}
success = await mutate_decky(decky_name, repo=repo)
if success:
return {"message": f"Successfully mutated {decky_name}"}
raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate")

View File

@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException
from decnet.config import DecnetConfig
from decnet.web.dependencies import get_current_user, repo
from decnet.web.db.models import MutateIntervalRequest
router = APIRouter()
_UNIT_TO_MINUTES = {"m": 1, "d": 1440, "M": 43200, "y": 525600, "Y": 525600}
def _parse_duration(s: str) -> int:
"""Convert a duration string (e.g. '5d') to minutes."""
value, unit = int(s[:-1]), s[-1]
return value * _UNIT_TO_MINUTES[unit]
@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"],
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
404: {"description": "No active deployment or decky not found"},
422: {"description": "Validation error"}
},
)
async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
state_dict = await repo.get_state("deployment")
if not state_dict:
raise HTTPException(status_code=404, detail="No active deployment")
config = DecnetConfig(**state_dict["config"])
compose_path = state_dict["compose_path"]
decky = next((d for d in config.deckies if d.name == decky_name), None)
if not decky:
raise HTTPException(status_code=404, detail="Decky not found")
decky.mutate_interval = _parse_duration(req.mutate_interval) if req.mutate_interval else None
await repo.set_state("deployment", {"config": config.model_dump(), "compose_path": compose_path})
return {"message": "Mutation interval updated"}

View File

@@ -0,0 +1,28 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
router = APIRouter()
@router.get("/logs/histogram", tags=["Logs"],
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
async def get_logs_histogram(
search: Optional[str] = None,
start_time: Optional[str] = Query(None),
end_time: Optional[str] = Query(None),
interval_minutes: int = Query(15, ge=1),
current_user: str = Depends(get_current_user)
) -> list[dict[str, Any]]:
def _norm(v: Optional[str]) -> Optional[str]:
if v in (None, "null", "NULL", "undefined", ""):
return None
return v
s = _norm(search)
st = _norm(start_time)
et = _norm(end_time)
return await repo.get_log_histogram(search=s, start_time=st, end_time=et, interval_minutes=interval_minutes)

View File

@@ -0,0 +1,37 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from decnet.web.dependencies import get_current_user, repo
from decnet.web.db.models import LogsResponse
router = APIRouter()
@router.get("/logs", response_model=LogsResponse, tags=["Logs"],
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}})
async def get_logs(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0, le=2147483647),
search: Optional[str] = Query(None, max_length=512),
start_time: Optional[str] = Query(None),
end_time: Optional[str] = Query(None),
current_user: str = Depends(get_current_user)
) -> dict[str, Any]:
def _norm(v: Optional[str]) -> Optional[str]:
if v in (None, "null", "NULL", "undefined", ""):
return None
return v
s = _norm(search)
st = _norm(start_time)
et = _norm(end_time)
_logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=s, start_time=st, end_time=et)
_total: int = await repo.get_total_logs(search=s, start_time=st, end_time=et)
return {
"total": _total,
"limit": limit,
"offset": offset,
"data": _logs
}

View File

@@ -0,0 +1,14 @@
from typing import Any
from fastapi import APIRouter, Depends
from decnet.web.dependencies import get_current_user, repo
from decnet.web.db.models import StatsResponse
router = APIRouter()
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]:
return await repo.get_stats_summary()

View File

@@ -0,0 +1,93 @@
import json
import asyncio
import logging
from typing import AsyncGenerator, Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import StreamingResponse
from decnet.env import DECNET_DEVELOPER
from decnet.web.dependencies import get_stream_user, repo
log = logging.getLogger(__name__)
router = APIRouter()
@router.get("/stream", tags=["Observability"],
responses={
200: {
"content": {"text/event-stream": {}},
"description": "Real-time Server-Sent Events (SSE) stream"
},
401: {"description": "Could not validate credentials"},
422: {"description": "Validation error"}
},
)
async def stream_events(
request: Request,
last_event_id: int = Query(0, alias="lastEventId"),
search: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
max_output: Optional[int] = Query(None, alias="maxOutput"),
current_user: str = Depends(get_stream_user)
) -> StreamingResponse:
async def event_generator() -> AsyncGenerator[str, None]:
last_id = last_event_id
stats_interval_sec = 10
loops_since_stats = 0
emitted_chunks = 0
try:
if last_id == 0:
last_id = await repo.get_max_log_id()
# Emit initial snapshot immediately so the client never needs to poll /stats
stats = await repo.get_stats_summary()
yield f"event: message\ndata: {json.dumps({'type': 'stats', 'data': stats})}\n\n"
histogram = await repo.get_log_histogram(
search=search, start_time=start_time,
end_time=end_time, interval_minutes=15,
)
yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n"
while True:
if DECNET_DEVELOPER and max_output is not None:
emitted_chunks += 1
if emitted_chunks > max_output:
log.debug("Developer mode: max_output reached (%d), closing stream", max_output)
break
if await request.is_disconnected():
break
new_logs = await repo.get_logs_after_id(
last_id, limit=50, search=search,
start_time=start_time, end_time=end_time,
)
if new_logs:
last_id = max(entry["id"] for entry in new_logs)
yield f"event: message\ndata: {json.dumps({'type': 'logs', 'data': new_logs})}\n\n"
loops_since_stats = stats_interval_sec
if loops_since_stats >= stats_interval_sec:
stats = await repo.get_stats_summary()
yield f"event: message\ndata: {json.dumps({'type': 'stats', 'data': stats})}\n\n"
histogram = await repo.get_log_histogram(
search=search, start_time=start_time,
end_time=end_time, interval_minutes=15,
)
yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n"
loops_since_stats = 0
loops_since_stats += 1
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
except Exception:
log.exception("SSE stream error for user %s", last_event_id)
yield f"event: error\ndata: {json.dumps({'type': 'error', 'message': 'Stream interrupted'})}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")

26
decnet_web/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.local

73
decnet_web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
decnet_web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>decnet_web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3727
decnet_web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
decnet_web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "decnet_web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.14.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
decnet_web/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

57
decnet_web/src/App.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './components/Login';
import Layout from './components/Layout';
import Dashboard from './components/Dashboard';
import DeckyFleet from './components/DeckyFleet';
import LiveLogs from './components/LiveLogs';
import Attackers from './components/Attackers';
import Config from './components/Config';
import Bounty from './components/Bounty';
function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
const savedToken = localStorage.getItem('token');
if (savedToken) {
setToken(savedToken);
}
}, []);
const handleLogin = (newToken: string) => {
setToken(newToken);
};
const handleLogout = () => {
localStorage.removeItem('token');
setToken(null);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
};
if (!token) {
return <Login onLogin={handleLogin} />;
}
return (
<Router>
<Layout onLogout={handleLogout} onSearch={handleSearch}>
<Routes>
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
<Route path="/fleet" element={<DeckyFleet />} />
<Route path="/live-logs" element={<LiveLogs />} />
<Route path="/bounty" element={<Bounty />} />
<Route path="/attackers" element={<Attackers />} />
<Route path="/config" element={<Config />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</Router>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Activity } from 'lucide-react';
import './Dashboard.css';
const Attackers: React.FC = () => {
return (
<div className="logs-section">
<div className="section-header">
<Activity size={20} />
<h2>ATTACKER PROFILES</h2>
</div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>NO ACTIVE THREATS PROFILED YET.</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Attackers view placeholder)</p>
</div>
</div>
);
};
export default Attackers;

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Archive, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import api from '../utils/api';
import './Dashboard.css';
interface BountyEntry {
id: number;
timestamp: string;
decky: string;
service: string;
attacker_ip: string;
bounty_type: string;
payload: any;
}
const Bounty: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const typeFilter = searchParams.get('type') || '';
const page = parseInt(searchParams.get('page') || '1');
const [bounties, setBounties] = useState<BountyEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const limit = 50;
const fetchBounties = async () => {
setLoading(true);
try {
const offset = (page - 1) * limit;
let url = `/bounty?limit=${limit}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (typeFilter) url += `&bounty_type=${typeFilter}`;
const res = await api.get(url);
setBounties(res.data.data);
setTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch bounties', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchBounties();
}, [query, typeFilter, page]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, type: typeFilter, page: '1' });
};
const setPage = (p: number) => {
setSearchParams({ q: query, type: typeFilter, page: p.toString() });
};
const setType = (t: string) => {
setSearchParams({ q: query, type: t, page: '1' });
};
const totalPages = Math.ceil(total / limit);
return (
<div className="dashboard">
{/* Page Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Archive size={32} className="violet-accent" />
<h1 style={{ fontSize: '1.5rem', letterSpacing: '4px' }}>BOUNTY VAULT</h1>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
<Filter size={16} className="dim" />
<select
value={typeFilter}
onChange={(e) => setType(e.target.value)}
style={{ background: 'transparent', border: 'none', color: 'inherit', fontSize: '0.8rem', outline: 'none' }}
>
<option value="">ALL TYPES</option>
<option value="credential">CREDENTIALS</option>
<option value="payload">PAYLOADS</option>
</select>
</div>
<form onSubmit={handleSearch} style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
<Search size={18} style={{ opacity: 0.5, marginRight: '8px' }} />
<input
type="text"
placeholder="Search bounty..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }}
/>
</form>
</div>
</div>
<div className="logs-section">
<div className="section-header" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span className="matrix-text" style={{ fontSize: '0.8rem' }}>{total} ARTIFACTS CAPTURED</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {page} of {totalPages || 1}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page <= 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= totalPages ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER</th>
<th>TYPE</th>
<th>DATA</th>
</tr>
</thead>
<tbody>
{bounties.length > 0 ? bounties.map((b) => (
<tr key={b.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(b.timestamp).toLocaleString()}</td>
<td className="violet-accent">{b.decky}</td>
<td>{b.service}</td>
<td className="matrix-text">{b.attacker_ip}</td>
<td>
<span style={{
fontSize: '0.7rem',
padding: '2px 8px',
borderRadius: '4px',
border: `1px solid ${b.bounty_type === 'credential' ? 'var(--text-color)' : 'var(--accent-color)'}`,
backgroundColor: b.bounty_type === 'credential' ? 'rgba(0, 255, 65, 0.1)' : 'rgba(238, 130, 238, 0.1)',
color: b.bounty_type === 'credential' ? 'var(--text-color)' : 'var(--accent-color)'
}}>
{b.bounty_type.toUpperCase()}
</span>
</td>
<td>
<div style={{ fontSize: '0.9rem' }}>
{b.bounty_type === 'credential' ? (
<div style={{ display: 'flex', gap: '12px' }}>
<span><span className="dim" style={{ marginRight: '4px' }}>user:</span>{b.payload.username}</span>
<span><span className="dim" style={{ marginRight: '4px' }}>pass:</span>{b.payload.password}</span>
</div>
) : (
<span className="dim" style={{ fontSize: '0.8rem' }}>{JSON.stringify(b.payload)}</span>
)}
</div>
</td>
</tr>
)) : (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '60px', opacity: 0.5, letterSpacing: '4px' }}>
{loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Bounty;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Settings } from 'lucide-react';
import './Dashboard.css';
const Config: React.FC = () => {
return (
<div className="logs-section">
<div className="section-header">
<Settings size={20} />
<h2>SYSTEM CONFIGURATION</h2>
</div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>CONFIGURATION READ-ONLY MODE ACTIVE.</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Config view placeholder)</p>
</div>
</div>
);
};
export default Config;

View File

@@ -0,0 +1,129 @@
.dashboard {
display: flex;
flex-direction: column;
gap: 32px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.stat-card {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
transform: translateY(-2px);
}
.stat-icon {
color: var(--accent-color);
filter: drop-shadow(var(--violet-glow));
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 0.7rem;
opacity: 0.6;
letter-spacing: 1px;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
}
.logs-section {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.section-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 12px;
}
.section-header h2 {
font-size: 0.9rem;
letter-spacing: 2px;
}
.logs-table-container {
overflow-x: auto;
}
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
text-align: left;
}
.logs-table th {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
opacity: 0.5;
font-weight: normal;
}
.logs-table td {
padding: 12px 24px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.logs-table tr:hover {
background-color: rgba(0, 255, 65, 0.03);
}
.raw-line {
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim {
opacity: 0.5;
}
.loader {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
letter-spacing: 4px;
animation: pulse 1s infinite alternate;
}
@keyframes pulse {
from { opacity: 0.5; }
to { opacity: 1; }
}
.spin {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import './Dashboard.css';
import { Shield, Users, Activity, Clock } from 'lucide-react';
interface Stats {
total_logs: number;
unique_attackers: number;
active_deckies: number;
deployed_deckies: number;
}
interface LogEntry {
id: number;
timestamp: string;
decky: string;
service: string;
event_type: string | null;
attacker_ip: string;
raw_line: string;
fields: string | null;
msg: string | null;
}
interface DashboardProps {
searchQuery: string;
}
const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
const [stats, setStats] = useState<Stats | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
} else if (payload.type === 'stats') {
setStats(payload.data);
setLoading(false);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
};
eventSource.onerror = (err) => {
console.error('SSE connection error, attempting to reconnect...', err);
};
return () => {
eventSource.close();
};
}, [searchQuery]);
if (loading && !stats) return <div className="loader">INITIALIZING SENSORS...</div>;
return (
<div className="dashboard">
<div className="stats-grid">
<StatCard
icon={<Activity size={32} />}
label="TOTAL INTERACTIONS"
value={stats?.total_logs || 0}
/>
<StatCard
icon={<Users size={32} />}
label="UNIQUE ATTACKERS"
value={stats?.unique_attackers || 0}
/>
<StatCard
icon={<Shield size={32} />}
label="ACTIVE DECKIES"
value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`}
/>
</div>
<div className="logs-section">
<div className="section-header">
<Clock size={20} />
<h2>LIVE INTERACTION LOG</h2>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER IP</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
let parsedFields: Record<string, string> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {
// Ignore parsing errors
}
}
return (
<tr key={log.id}>
<td className="dim">{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {v}
</span>
))}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<tr>
<td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>NO INTERACTION DETECTED</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<span className="stat-label">{label}</span>
<span className="stat-value">{value.toLocaleString()}</span>
</div>
</div>
);
export default Dashboard;

View File

@@ -0,0 +1,278 @@
import React, { useEffect, useState } from 'react';
import api from '../utils/api';
import './Dashboard.css'; // Re-use common dashboard styles
import { Server, Cpu, Globe, Database, Clock, RefreshCw, Upload } from 'lucide-react';
interface Decky {
name: string;
ip: string;
services: string[];
distro: string;
hostname: string;
archetype: string | null;
service_config: Record<string, Record<string, any>>;
mutate_interval: number | null;
last_mutated: number;
}
const DeckyFleet: React.FC = () => {
const [deckies, setDeckies] = useState<Decky[]>([]);
const [loading, setLoading] = useState(true);
const [mutating, setMutating] = useState<string | null>(null);
const [showDeploy, setShowDeploy] = useState(false);
const [iniContent, setIniContent] = useState('');
const [deploying, setDeploying] = useState(false);
const fetchDeckies = async () => {
try {
const _res = await api.get('/deckies');
setDeckies(_res.data);
} catch (err) {
console.error('Failed to fetch decky fleet', err);
} finally {
setLoading(false);
}
};
const handleMutate = async (name: string) => {
setMutating(name);
try {
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
await fetchDeckies();
} catch (err: any) {
console.error('Failed to mutate', err);
if (err.code === 'ECONNABORTED') {
alert('Mutation is still running in the background but the UI timed out.');
} else {
alert('Mutation failed');
}
} finally {
setMutating(null);
}
};
const handleIntervalChange = async (name: string, current: number | null) => {
const _val = prompt(`Enter new mutation interval in minutes for ${name} (leave empty to disable):`, current?.toString() || '');
if (_val === null) return;
const mutate_interval = _val.trim() === '' ? null : parseInt(_val);
try {
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval });
fetchDeckies();
} catch (err) {
console.error('Failed to update interval', err);
alert('Update failed');
}
};
const handleDeploy = async () => {
if (!iniContent.trim()) return;
setDeploying(true);
try {
await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 });
setIniContent('');
setShowDeploy(false);
fetchDeckies();
} catch (err: any) {
console.error('Deploy failed', err);
alert(`Deploy failed: ${err.response?.data?.detail || err.message}`);
} finally {
setDeploying(false);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setIniContent(content);
};
reader.readAsText(file);
};
useEffect(() => {
fetchDeckies();
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
return () => clearInterval(_interval);
}, []);
if (loading) return <div className="loader">SCANNING NETWORK FOR DECOYS...</div>;
return (
<div className="dashboard">
<div className="section-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)', marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Server size={20} />
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
</div>
<button
onClick={() => setShowDeploy(!showDeploy)}
style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
>
+ DEPLOY DECKIES
</button>
</div>
{showDeploy && (
<div style={{ marginBottom: '24px', padding: '24px', backgroundColor: 'var(--secondary-color)', border: '1px solid var(--accent-color)', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ fontSize: '1rem', color: 'var(--text-color)' }}>Deploy via INI Configuration</h3>
<div>
<input
type="file"
id="ini-upload"
accept=".ini"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<label
htmlFor="ini-upload"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.8rem',
color: 'var(--accent-color)',
border: '1px solid var(--accent-color)',
padding: '4px 12px'
}}
>
<Upload size={14} /> UPLOAD FILE
</label>
</div>
</div>
<textarea
value={iniContent}
onChange={(e) => setIniContent(e.target.value)}
placeholder="[decky-01]&#10;archetype=linux-server&#10;services=ssh,http"
style={{ width: '100%', height: '200px', backgroundColor: '#000', color: 'var(--text-color)', border: '1px solid var(--border-color)', padding: '12px', fontFamily: 'monospace' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button onClick={() => setShowDeploy(false)} style={{ border: '1px solid var(--border-color)', color: 'var(--dim-color)' }}>CANCEL</button>
<button onClick={handleDeploy} disabled={deploying} style={{ background: 'var(--accent-color)', color: '#000', border: 'none', display: 'flex', alignItems: 'center', gap: '8px' }}>
{deploying && <RefreshCw size={14} className="spin" />}
{deploying ? 'DEPLOYING...' : 'DEPLOY'}
</button>
</div>
</div>
)}
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
{deckies.length > 0 ? deckies.map(decky => (
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
<span className="matrix-text" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{decky.name}</span>
<span className="dim" style={{ fontSize: '0.8rem', backgroundColor: 'rgba(0, 255, 65, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>{decky.ip}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Cpu size={14} className="dim" />
<span className="dim">HOSTNAME:</span> {decky.hostname}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Globe size={14} className="dim" />
<span className="dim">DISTRO:</span> {decky.distro}
</div>
{decky.archetype && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Database size={14} className="dim" />
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
<Clock size={14} className="dim" />
<span className="dim">MUTATION:</span>
<span
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
<button
onClick={() => handleMutate(decky.name)}
disabled={!!mutating}
style={{
background: 'transparent', border: '1px solid var(--accent-color)',
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
opacity: mutating ? 0.5 : 1
}}
>
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
</button>
</div>
{decky.last_mutated > 0 && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}>
Last mutated: {new Date(decky.last_mutated * 1000).toLocaleString()}
</div>
)}
</div>
<div style={{ width: '100%' }}>
<div className="dim" style={{ fontSize: '0.7rem', marginBottom: '8px', letterSpacing: '1px' }}>EXPOSED SERVICES:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{decky.services.map(svc => {
const _config = decky.service_config[svc];
return (
<div key={svc} className="service-tag-container" style={{ position: 'relative' }}>
<span className="service-tag" style={{
display: 'inline-block',
padding: '4px 10px',
fontSize: '0.75rem',
backgroundColor: 'var(--bg-color)',
border: '1px solid var(--accent-color)',
color: 'var(--accent-color)',
borderRadius: '2px',
cursor: 'help'
}}>
{svc}
</span>
{_config && Object.keys(_config).length > 0 && (
<div className="service-config-tooltip" style={{
display: 'none',
position: 'absolute',
bottom: '100%',
left: '0',
backgroundColor: 'rgba(10, 10, 10, 0.95)',
border: '1px solid var(--accent-color)',
padding: '12px',
zIndex: 100,
minWidth: '200px',
boxShadow: '0 0 15px rgba(0, 255, 65, 0.2)',
marginBottom: '8px'
}}>
{Object.entries(_config).map(([k, v]) => (
<div key={k} style={{ fontSize: '0.7rem', marginBottom: '4px' }}>
<span style={{ color: 'var(--highlight-color)', fontWeight: 'bold' }}>{k}:</span>
<span style={{ marginLeft: '6px', opacity: 0.9 }}>{String(v)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)) : (
<div className="stat-card" style={{ gridColumn: '1 / -1', justifyContent: 'center', padding: '60px' }}>
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
</div>
)}
</div>
<style dangerouslySetInnerHTML={{ __html: `
.service-tag-container:hover .service-config-tooltip {
display: block !important;
}
`}} />
</div>
);
};
export default DeckyFleet;

View File

@@ -0,0 +1,179 @@
.layout-container {
display: flex;
height: 100vh;
width: 100vw;
background-color: var(--background-color);
}
/* Sidebar Styling */
.sidebar {
background-color: var(--secondary-color);
border-right: 1px solid var(--border-color);
height: 100%;
display: flex;
flex-direction: column;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
flex-shrink: 0;
}
.sidebar.open {
width: 240px;
}
.sidebar.closed {
width: 70px;
}
.sidebar-header {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
}
.logo-text {
font-weight: bold;
font-size: 1.2rem;
margin-left: 10px;
letter-spacing: 2px;
}
.toggle-btn {
background: transparent;
border: none;
color: var(--text-color);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-btn:hover {
box-shadow: none;
color: var(--accent-color);
}
.sidebar-nav {
flex-grow: 1;
padding: 20px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 24px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-color);
opacity: 0.7;
}
.nav-item:hover, .nav-item.active {
background-color: rgba(0, 255, 65, 0.1);
opacity: 1;
color: var(--text-color);
border-left: 3px solid var(--text-color);
}
.nav-label {
margin-left: 12px;
font-size: 0.9rem;
white-space: nowrap;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
}
.logout-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px;
border: 1px solid transparent;
}
.logout-btn:hover {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
}
/* Main Content Area */
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* Topbar Styling */
.topbar {
height: 64px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
background-color: var(--background-color);
}
.search-container {
display: flex;
align-items: center;
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 4px 12px;
max-width: 400px;
width: 100%;
}
.search-icon {
margin-right: 8px;
opacity: 0.5;
}
.search-container input {
background: transparent;
border: none;
width: 100%;
padding: 4px;
}
.search-container input:focus {
box-shadow: none;
}
.topbar-status {
font-size: 0.8rem;
}
.neon-blink {
animation: blink 2s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; text-shadow: var(--matrix-green-glow); }
50% { opacity: 0.5; }
}
.violet-accent {
color: var(--accent-color);
filter: drop-shadow(var(--violet-glow));
}
.matrix-text {
color: var(--text-color);
}
/* Viewport for dynamic content */
.content-viewport {
flex-grow: 1;
padding: 32px;
overflow-y: auto;
}

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive } from 'lucide-react';
import api from '../utils/api';
import './Layout.css';
interface LayoutProps {
children: React.ReactNode;
onLogout: () => void;
onSearch: (q: string) => void;
}
const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [search, setSearch] = useState('');
const [systemActive, setSystemActive] = useState(false);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(search);
};
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await api.get('/stats');
setSystemActive(res.data.deployed_deckies > 0);
} catch (err) {
console.error('Failed to fetch system status', err);
}
};
fetchStatus();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, []);
return (
<div className="layout-container">
{/* Sidebar */}
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header">
<Activity size={24} className="violet-accent" />
{sidebarOpen && <span className="logo-text">DECNET</span>}
<button className="toggle-btn" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="sidebar-nav">
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" open={sidebarOpen} />
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav>
<div className="sidebar-footer">
<button className="logout-btn" onClick={onLogout}>
<LogOut size={20} />
{sidebarOpen && <span>Logout</span>}
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="main-content">
{/* Topbar */}
<header className="topbar">
<form onSubmit={handleSearchSubmit} className="search-container">
<Search size={18} className="search-icon" />
<input
type="text"
placeholder="Search logs, deckies, IPs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</form>
<div className="topbar-status">
<span className="matrix-text" style={{ color: systemActive ? 'var(--text-color)' : 'var(--accent-color)' }}>
SYSTEM: {systemActive ? 'ACTIVE' : 'INACTIVE'}
</span>
</div>
</header>
{/* Dynamic Content */}
<div className="content-viewport">
{children}
</div>
</main>
</div>
);
};
interface NavItemProps {
to: string;
icon: React.ReactNode;
label: string;
open: boolean;
}
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open }) => (
<NavLink to={to} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} end={to === '/'}>
{icon}
{open && <span className="nav-label">{label}</span>}
</NavLink>
);
export default Layout;

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Terminal, Search, Activity,
ChevronLeft, ChevronRight, Play, Pause
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
} from 'recharts';
import api from '../utils/api';
import './Dashboard.css';
interface LogEntry {
id: number;
timestamp: string;
decky: string;
service: string;
event_type: string;
attacker_ip: string;
raw_line: string;
fields: string;
msg: string;
}
interface HistogramData {
time: string;
count: number;
}
const LiveLogs: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
// URL-synced state
const query = searchParams.get('q') || '';
const timeRange = searchParams.get('time') || '1h';
const isLive = searchParams.get('live') !== 'false';
const page = parseInt(searchParams.get('page') || '1');
// Local state
const [logs, setLogs] = useState<LogEntry[]>([]);
const [histogram, setHistogram] = useState<HistogramData[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(isLive);
const [searchInput, setSearchInput] = useState(query);
const eventSourceRef = useRef<EventSource | null>(null);
const limit = 50;
// Sync search input if URL changes (e.g. back button)
useEffect(() => {
setSearchInput(query);
}, [query]);
const fetchData = async () => {
if (streaming) return; // Don't fetch historical if streaming
setLoading(true);
try {
const offset = (page - 1) * limit;
let url = `/logs?limit=${limit}&offset=${offset}&search=${encodeURIComponent(query)}`;
// Calculate time bounds for historical fetch
const now = new Date();
let startTime: string | null = null;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
startTime = new Date(now.getTime() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19);
url += `&start_time=${startTime}`;
}
}
const res = await api.get(url);
setLogs(res.data.data);
setTotalLogs(res.data.total);
// Fetch histogram for historical view
let histUrl = `/logs/histogram?search=${encodeURIComponent(query)}`;
if (startTime) histUrl += `&start_time=${startTime}`;
const histRes = await api.get(histUrl);
setHistogram(histRes.data);
} catch (err) {
console.error('Failed to fetch historical logs', err);
} finally {
setLoading(false);
}
};
const setupSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}&search=${encodeURIComponent(query)}`;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
const startTime = new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19);
url += `&start_time=${startTime}`;
}
}
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 500));
} else if (payload.type === 'histogram') {
setHistogram(payload.data);
} else if (payload.type === 'stats') {
setTotalLogs(payload.data.total_logs);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
};
es.onerror = () => {
console.error('SSE connection lost, reconnecting...');
};
};
useEffect(() => {
if (streaming) {
setupSSE();
setLoading(false);
} else {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
fetchData();
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, [query, timeRange, streaming, page]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, time: timeRange, live: streaming.toString(), page: '1' });
};
const handleToggleLive = () => {
const newStreaming = !streaming;
setStreaming(newStreaming);
setSearchParams({ q: query, time: timeRange, live: newStreaming.toString(), page: '1' });
};
const handleTimeChange = (newTime: string) => {
setSearchParams({ q: query, time: newTime, live: streaming.toString(), page: '1' });
};
const changePage = (newPage: number) => {
setSearchParams({ q: query, time: timeRange, live: 'false', page: newPage.toString() });
};
return (
<div className="dashboard">
{/* Control Bar */}
<div className="logs-section" style={{ border: 'none', background: 'transparent', padding: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div className="search-container" style={{ flexGrow: 1, maxWidth: 'none' }}>
<Search className="search-icon" size={18} />
<input
type="text"
placeholder="Query logs (e.g. decky:decky-01 service:http attacker:192.168.1.5 status:failed)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<select
value={timeRange}
onChange={(e) => handleTimeChange(e.target.value)}
className="search-container"
style={{ width: 'auto', color: 'var(--text-color)', cursor: 'pointer' }}
>
<option value="15m">LAST 15 MIN</option>
<option value="1h">LAST 1 HOUR</option>
<option value="24h">LAST 24 HOURS</option>
<option value="all">ALL TIME</option>
</select>
<button
type="button"
onClick={handleToggleLive}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
border: `1px solid ${streaming ? 'var(--text-color)' : 'var(--accent-color)'}`,
color: streaming ? 'var(--text-color)' : 'var(--accent-color)',
minWidth: '120px', justifyContent: 'center'
}}
>
{streaming ? <><Play size={14} className="neon-blink" /> LIVE</> : <><Pause size={14} /> PAUSED</>}
</button>
</form>
</div>
{/* Histogram Chart */}
<div className="logs-section" style={{ height: '200px', padding: '20px', marginBottom: '24px', minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', color: 'var(--dim-color)' }}>
<Activity size={12} /> ATTACK VOLUME OVER TIME
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-color)' }}>
MATCHES: {totalLogs.toLocaleString()}
</div>
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={histogram}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis
dataKey="time"
hide
/>
<YAxis
stroke="#30363d"
fontSize={10}
tickFormatter={(val) => Math.floor(val).toString()}
/>
<Tooltip
contentStyle={{ backgroundColor: '#0d1117', border: '1px solid #30363d', fontSize: '0.8rem' }}
itemStyle={{ color: 'var(--text-color)' }}
labelStyle={{ color: 'var(--dim-color)', marginBottom: '4px' }}
cursor={{ fill: 'rgba(0, 255, 65, 0.05)' }}
/>
<Bar dataKey="count" fill="var(--text-color)" radius={[2, 2, 0, 0]}>
{histogram.map((entry, index) => (
<Cell key={`cell-${index}`} fillOpacity={0.6 + (entry.count / (Math.max(...histogram.map(h => h.count)) || 1)) * 0.4} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Logs Table */}
<div className="logs-section">
<div className="section-header" style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={20} />
<h2>LOG EXPLORER</h2>
</div>
{!streaming && (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {page} of {Math.ceil(totalLogs / limit)}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={page === 1}
onClick={() => changePage(page - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page === 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= Math.ceil(totalLogs / limit)}
onClick={() => changePage(page + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= Math.ceil(totalLogs / limit) ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
let parsedFields: Record<string, any> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {}
}
return (
<tr key={log.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)', fontSize: '0.9rem' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
</span>
))}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '40px', opacity: 0.5 }}>
{loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default LiveLogs;

View File

@@ -0,0 +1,90 @@
.login-container {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-color);
background-image:
linear-gradient(rgba(0, 255, 65, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 65, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.login-box {
width: 100%;
max-width: 400px;
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 40px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
gap: 32px;
}
.login-header {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.login-header h1 {
font-size: 2.5rem;
letter-spacing: 10px;
font-weight: bold;
}
.login-header p {
font-size: 0.7rem;
letter-spacing: 2px;
opacity: 0.6;
}
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 0.7rem;
opacity: 0.8;
letter-spacing: 1px;
}
.login-form input {
width: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.error-msg {
color: #ff4141;
font-size: 0.8rem;
text-align: center;
padding: 8px;
border: 1px solid #ff4141;
background-color: rgba(255, 65, 65, 0.1);
}
.login-form button {
padding: 12px;
margin-top: 8px;
font-weight: bold;
letter-spacing: 2px;
}
.login-footer {
text-align: center;
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1px;
}

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import api from '../utils/api';
import './Login.css';
import { Activity } from 'lucide-react';
interface LoginProps {
onLogin: (token: string) => void;
}
const Login: React.FC<LoginProps> = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [needsPasswordChange, setNeedsPasswordChange] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [tempToken, setTempToken] = useState('');
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await api.post('/auth/login', { username, password });
const { access_token, must_change_password } = response.data;
if (must_change_password) {
setTempToken(access_token);
setNeedsPasswordChange(true);
} else {
localStorage.setItem('token', access_token);
onLogin(access_token);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Authentication failed');
} finally {
setLoading(false);
}
};
const handleChangePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
setError('');
try {
await api.post('/auth/change-password',
{ old_password: password, new_password: newPassword },
{ headers: { Authorization: `Bearer ${tempToken}` } }
);
// Re-authenticate to get a fresh token with must_change_password=false
const response = await api.post('/auth/login', { username, password: newPassword });
const { access_token } = response.data;
localStorage.setItem('token', access_token);
onLogin(access_token);
} catch (err: any) {
setError(err.response?.data?.detail || 'Password change failed');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-box">
<div className="login-header">
<Activity size={48} className="violet-accent neon-blink" />
<h1>DECNET</h1>
<p>AUTHORIZED PERSONNEL ONLY</p>
</div>
{!needsPasswordChange ? (
<form onSubmit={handleLoginSubmit} className="login-form">
<div className="form-group">
<label>IDENTIFIER</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>ACCESS KEY</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error-msg">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'VERIFYING...' : 'ESTABLISH CONNECTION'}
</button>
</form>
) : (
<form onSubmit={handleChangePasswordSubmit} className="login-form">
<div className="form-group" style={{ textAlign: 'center', marginBottom: '10px' }}>
<p className="violet-accent">MANDATORY SECURITY UPDATE</p>
<p style={{ fontSize: '0.8rem', opacity: 0.7 }}>Please establish a new access key</p>
</div>
<div className="form-group">
<label>NEW ACCESS KEY</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="form-group">
<label>CONFIRM KEY</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
{error && <div className="error-msg">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'UPDATING...' : 'UPDATE SECURE KEY'}
</button>
</form>
)}
<div className="login-footer">
<span>SECURE PROTOCOL v1.0</span>
</div>
</div>
</div>
);
};
export default Login;

75
decnet_web/src/index.css Normal file
View File

@@ -0,0 +1,75 @@
@import url('https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
:root {
--background-color: #000000;
--text-color: #00ff41;
--accent-color: #ee82ee;
--secondary-color: #0d1117;
--border-color: #30363d;
--matrix-green-glow: 0 0 10px rgba(0, 255, 65, 0.5);
--violet-glow: 0 0 10px rgba(238, 130, 238, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Ubuntu Mono', monospace;
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.5;
overflow-x: hidden;
}
input, button, textarea, select {
font-family: inherit;
}
button {
cursor: pointer;
background: transparent;
border: 1px solid var(--text-color);
color: var(--text-color);
padding: 8px 16px;
transition: all 0.3s ease;
}
button:hover {
background: var(--text-color);
color: var(--background-color);
box-shadow: var(--matrix-green-glow);
}
input {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 12px;
}
input:focus {
outline: none;
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--background-color);
}
::-webkit-scrollbar-thumb {
background: var(--secondary-color);
border: 1px solid var(--border-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
}

10
decnet_web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,15 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
decnet_web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

29
deploy/decnet-api.service Normal file
View File

@@ -0,0 +1,29 @@
[Unit]
Description=DECNET API Service
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/path/to/DECNET
# Ensure environment is loaded from the .env file
EnvironmentFile=/path/to/DECNET/.env
# Use the virtualenv python to run the decnet api command
ExecStart=/path/to/DECNET/.venv/bin/decnet api
# Capabilities required to manage MACVLAN interfaces and network links without root
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

30
deploy/decnet-web.service Normal file
View File

@@ -0,0 +1,30 @@
[Unit]
Description=DECNET Web Dashboard Service
After=network.target decnet-api.service
[Service]
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/path/to/DECNET
# Ensure environment is loaded from the .env file
EnvironmentFile=/path/to/DECNET/.env
# Use the virtualenv python to run the decnet web command
ExecStart=/path/to/DECNET/.venv/bin/decnet web
# The Web Dashboard service does not require network administration privileges.
# Enable the following lines if you wish to bind the Dashboard to a privileged port (e.g., 80 or 443)
# while still running as a non-root user.
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

37
development/BUGS.md Normal file
View File

@@ -0,0 +1,37 @@
# BUGS
Active bugs detected during development. Do not fix until noted otherwise.
---
## BUG-001 — Split-brain model imports across router files (Gemini SQLModel migration)
**Detected:** 2026-04-09
**Status:** Open — do not fix, migration in progress
**Symptom:** `from decnet.web.api import app` fails with `ModuleNotFoundError: No module named 'decnet.web.models'`
**Root cause:** Gemini's SQLModel migration is partially complete. Models were moved to `decnet/web/db/models.py`, but three router files were not updated and still import from the old `decnet.web.models` path:
| File | Stale import |
|------|--------------|
| `decnet/web/router/auth/api_login.py:12` | `from decnet.web.models import LoginRequest, Token` |
| `decnet/web/router/auth/api_change_pass.py:7` | `from decnet.web.models import ChangePasswordRequest` |
| `decnet/web/router/stats/api_get_stats.py:6` | `from decnet.web.models import StatsResponse` |
**Fix:** Update those three files to import from `decnet.web.db.models` (consistent with the other router files already migrated).
**Impact:** All `tests/api/` tests fail to collect. Web server cannot start.
---
## BUG-002 — `decnet/web/db/sqlite/repository.py` depends on `sqlalchemy` directly
**Detected:** 2026-04-09
**Status:** Resolved (dependency installed via `pip install -e ".[dev]"`)
**Symptom:** `ModuleNotFoundError: No module named 'sqlalchemy'` before `sqlmodel` was installed.
**Root cause:** `sqlmodel>=0.0.16` was added to `pyproject.toml` but `pip install -e .` had not been re-run in the dev environment.
**Fix:** Run `pip install -e ".[dev]"`. Already applied.

333
development/BUG_FIXES.md Normal file
View File

@@ -0,0 +1,333 @@
# Bug Fixes — Non-Feature Realism Issues
> These are fingerprint leaks and broken protocol handlers that don't need new
> interaction design — just targeted fixes. All severity High or above from REALISM_AUDIT.md.
---
## 1. HTTP — Werkzeug header leak (High)
### Problem
Every response has two `Server:` headers:
```
Server: Werkzeug/3.1.3 Python/3.11.2
Server: Apache/2.4.54 (Debian)
```
nmap correctly IDs Apache from the second header, but any attacker that does
`curl -I` or runs Burp sees the Werkzeug leak immediately. Port 6443 (K8s) also
leaks Werkzeug in every response.
### Fix — WSGI middleware to strip/replace the header
In `templates/http/server.py` (Flask app), add a `@app.after_request` hook:
```python
@app.after_request
def _fix_server_header(response):
response.headers["Server"] = os.environ.get("HTTP_SERVER_HEADER", "Apache/2.4.54 (Debian)")
return response
```
Flask sets `Server: Werkzeug/...` by default. The `after_request` hook runs after
Werkzeug's own header injection, so it overwrites it.
Same fix applies to the K8s server if it's also Flask-based.
### Fix — Apache 403 page body
Current response body: `<h1>403 Forbidden</h1>`
Replace with the actual Apache 2.4 default 403 page:
```html
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.54 (Debian) Server at {hostname} Port 80</address>
</body></html>
```
Env var `HTTP_SERVER_HEADER` and `NODE_NAME` fill the address line.
### Env vars
| Var | Default |
|-----|---------|
| `HTTP_SERVER_HEADER` | `Apache/2.4.54 (Debian)` |
---
## 2. FTP — Twisted banner (High)
### Problem
```
220 Twisted 25.5.0 FTP Server
```
This is Twisted's built-in FTP server banner. Immediately identifies the framework.
### Fix
Override the banner. The Twisted FTP server class has a `factory.welcomeMessage` or
the protocol's `sendLine()` for the greeting. Simplest fix: subclass the protocol
and override `lineReceived` to intercept the `220` line before it goes out, OR
use a `_FTPFactory` subclass that sets `welcomeMessage`:
```python
from twisted.protocols.ftp import FTPFactory, FTPAnonymousShell
from twisted.internet import reactor
import os
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
BANNER = os.environ.get("FTP_BANNER", f"220 (vsFTPd 3.0.3)")
factory = FTPFactory(portal)
factory.welcomeMessage = BANNER # overrides the Twisted default
```
If `FTPFactory.welcomeMessage` is not directly settable, patch it at class level:
```python
FTPFactory.welcomeMessage = BANNER
```
### Anonymous login + fake directory
The current server rejects everything after login. Fix:
- Use `FTPAnonymousShell` pointed at a `MemoryFilesystem` with fake entries:
```
/
├── backup.tar.gz (0 bytes, but listable)
├── db_dump.sql (0 bytes)
├── config.ini (0 bytes)
└── credentials.txt (0 bytes)
```
- `RETR` any file → return 13 lines of plausible fake content, then close.
- Log every `RETR` with filename and client IP.
### Env vars
| Var | Default |
|-----|---------|
| `FTP_BANNER` | `220 (vsFTPd 3.0.3)` |
---
## 3. MSSQL — Silent on TDS pre-login (High)
### Problem
No response to standard TDS pre-login packets. Connection is dropped silently.
nmap barely recognizes port 1433 (`ms-sql-s?`).
### Diagnosis
The nmap fingerprint shows `\x04\x01\x00\x2b...` which is a valid TDS 7.x pre-login
response fragment. So the server is sending _something_ — but it may be truncated or
malformed enough that nmap can't complete its probe.
Check `templates/mssql/server.py`: look for the raw bytes being sent in response to
`\x12\x01` (TDS pre-login type). Common bugs:
- Wrong packet length field (bytes 2-3 of TDS header)
- Missing `\xff` terminator on the pre-login option list
- Status byte 0x01 instead of 0x00 in the TDS header (signaling last packet)
### Correct TDS 7.x pre-login response structure
```
Byte 0: 0x04 (packet type: tabular result)
Byte 1: 0x01 (status: last packet)
Bytes 2-3: 0x00 0x2b (total length including header = 43)
Bytes 4-5: 0x00 0x00 (SPID)
Byte 6: 0x01 (packet ID)
Byte 7: 0x00 (window)
--- TDS pre-login payload ---
[VERSION] option: type=0x00, offset=0x001a, length=0x0006
[ENCRYPTION] option: type=0x01, offset=0x0020, length=0x0001
[INSTOPT] option: type=0x02, offset=0x0021, length=0x0001
[THREADID] option: type=0x03, offset=0x0022, length=0x0004
[MARS] option: type=0x04, offset=0x0026, length=0x0001
Terminator: 0xff
VERSION: 0x0e 0x00 0x07 0xd0 0x00 0x00 (14.0.2000 = SQL Server 2017)
ENCRYPTION: 0x02 (ENCRYPT_NOT_SUP)
INSTOPT: 0x00
THREADID: 0x00 0x00 0x00 0x00
MARS: 0x00
```
Verify the current implementation's bytes match this exactly. Fix the length field if off.
---
## 4. MongoDB — Silent on OP_MSG (High)
### Problem
No response to `OP_MSG isMaster` command. nmap shows `mongod?` (partial recognition).
### Diagnosis
MongoDB wire protocol since 3.6 uses `OP_MSG` (opcode 2013). Older clients use
`OP_QUERY` (opcode 2004) against `admin.$cmd`. Check which one `templates/mongodb/server.py`
handles, and whether the response's `responseTo` field matches the request's `requestID`.
Common bugs:
- Handling `OP_QUERY` but not `OP_MSG`
- Wrong `responseTo` in the response header (must echo the request's requestID)
- Missing `flagBits` field in OP_MSG response (must be 0x00000000)
### Correct OP_MSG `hello` response
```python
import struct, bson
def _op_msg_hello_response(request_id: int) -> bytes:
doc = {
"ismaster": True,
"maxBsonObjectSize": 16777216,
"maxMessageSizeBytes": 48000000,
"maxWriteBatchSize": 100000,
"localTime": {"$date": int(time.time() * 1000)},
"logicalSessionTimeoutMinutes": 30,
"connectionId": 1,
"minWireVersion": 0,
"maxWireVersion": 17,
"readOnly": False,
"ok": 1.0,
}
payload = b"\x00" + bson.encode(doc) # section type 0 = body
flag_bits = struct.pack("<I", 0)
msg_body = flag_bits + payload
# MsgHeader: totalLength(4) + requestID(4) + responseTo(4) + opCode(4)
header = struct.pack("<iiii",
16 + len(msg_body), # total length
1, # requestID (server-generated)
request_id, # responseTo: echo the client's requestID
2013, # OP_MSG
)
return header + msg_body
```
---
## 5. Redis — Empty keyspace (Medium)
### Problem
`KEYS *` returns `*0\r\n` after a successful AUTH. A real exposed Redis always has data.
Attacker does `AUTH anypassword` → `+OK` → `KEYS *` → empty → leaves.
### Fix — fake key-value store
Add a module-level dict with bait data. Handle `KEYS`, `GET`, `SCAN`, `TYPE`, `TTL`:
```python
_FAKE_STORE = {
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}',
b"config:db_password": b"Pr0dDB!2024#Secure",
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
b"rate_limit:192.168.1.1": b"42",
}
```
Commands to handle:
- `KEYS *` → all keys as RESP array
- `KEYS pattern` → filtered (basic glob: `*` matches all, `prefix:*` matches prefix)
- `GET key` → value or `$-1` (nil)
- `SCAN 0` → `*2\r\n$1\r\n0\r\n` + keys array (cursor always 0, return all)
- `TYPE key` → `+string\r\n`
- `TTL key` → `:-1\r\n` (no expiry)
---
## 6. SIP — Hardcoded nonce (Low)
### Problem
`nonce="decnet0000"` is hardcoded. A Shodan signature could detect this string.
### Fix
```python
import secrets
nonce = secrets.token_hex(16) # e.g. "a3f8c1b2e7d94051..."
```
Generate once per connection in `connection_made`. The WWW-Authenticate header
becomes: `Digest realm="{NODE_NAME}", nonce="{nonce}", algorithm=MD5`
---
## 7. VNC — Hardcoded DES challenge (Low)
### Problem
The 16-byte DES challenge sent during VNC auth negotiation is static.
### Fix
```python
import os
self._vnc_challenge = os.urandom(16)
```
Generate in `connection_made`. Send `self._vnc_challenge` in the Security handshake.
---
## 8. PostgreSQL — Hardcoded salt (Low)
### Problem
`AuthenticationMD5Password` response contains `\xde\xad\xbe\xef` as the 4-byte salt.
### Fix
```python
import os
self._pg_salt = os.urandom(4)
```
Use `self._pg_salt` in the `R\x00\x00\x00\x0c\x00\x00\x00\x05` response bytes.
---
## Files to change
| File | Change |
|------|--------|
| `templates/http/server.py` | `after_request` header fix, proper 403 body |
| `templates/ftp/server.py` | Banner override, anonymous login, fake dir |
| `templates/mssql/server.py` | Fix TDS pre-login response bytes |
| `templates/mongodb/server.py` | Add OP_MSG handler, fix responseTo |
| `templates/redis/server.py` | Add fake key-value store, KEYS/GET/SCAN |
| `templates/sip/server.py` | Random nonce per connection |
| `templates/vnc/server.py` | Random DES challenge per connection |
| `templates/postgres/server.py` | Random MD5 salt per connection |
| `tests/test_http_headers.py` | New: assert single Server header, correct 403 body |
| `tests/test_redis.py` | Extend: KEYS *, GET, SCAN return bait data |
---
## Priority order
1. HTTP header leak — immediately visible to any attacker
2. FTP banner — immediate framework disclosure
3. MSSQL silent — service appears dead
4. MongoDB silent — service appears dead
5. Redis empty keyspace — breaks the bait value proposition
6. SIP/VNC/PostgreSQL hardcoded values — low risk, quick wins

107
development/COVERAGE.md Normal file
View File

@@ -0,0 +1,107 @@
# DECNET Test Coverage Report
> **Last Updated:** 2026-04-12
> **Total Coverage:** 93% ✅
> **Total Tests:** 1074 Passed ✅
## 📊 Full Coverage Table
```text
Name Stmts Miss Cover Missing
------------------------------------------------------------------------------
decnet/__init__.py 0 0 100%
decnet/archetypes.py 21 0 100%
decnet/cli.py 265 43 84% 62-63, 136, 138, 146-149, 163-165, 179-180, 198-199, 223-223, 251-252, 255-260, 282-283, 385-386, 390-393, 398, 400-401, 409-410, 418-419, 458-461
decnet/collector/__init__.py 2 0 100%
decnet/collector/worker.py 110 3 97% 196-198
decnet/composer.py 36 3 92% 110-112
decnet/config.py 38 0 100%
decnet/correlation/__init__.py 4 0 100%
decnet/correlation/engine.py 62 0 100%
decnet/correlation/graph.py 37 0 100%
decnet/correlation/parser.py 47 2 96% 98-99
decnet/custom_service.py 17 0 100%
decnet/distros.py 26 1 96% 110
decnet/engine/__init__.py 2 0 100%
decnet/engine/deployer.py 147 8 95% 42, 45, 177-182
decnet/env.py 38 7 82% 17-18, 20, 29, 37-42
decnet/fleet.py 83 1 99% 136
decnet/ini_loader.py 111 5 95% 158-161, 205
decnet/logging/__init__.py 0 0 100%
decnet/logging/file_handler.py 30 0 100%
decnet/logging/forwarder.py 13 0 100%
decnet/logging/syslog_formatter.py 34 0 100%
decnet/mutator/__init__.py 2 0 100%
decnet/mutator/engine.py 80 10 88% 43, 50-51, 116-122
decnet/network.py 106 0 100%
decnet/os_fingerprint.py 8 0 100%
decnet/services/__init__.py 0 0 100%
decnet/services/base.py 7 1 86% 42
decnet/services/conpot.py 13 0 100%
decnet/services/docker_api.py 14 0 100%
decnet/services/elasticsearch.py 14 0 100%
decnet/services/ftp.py 14 0 100%
decnet/services/http.py 31 3 90% 46-48
decnet/services/imap.py 14 0 100%
decnet/services/k8s.py 14 0 100%
decnet/services/ldap.py 14 0 100%
decnet/services/llmnr.py 14 0 100%
decnet/services/mongodb.py 14 0 100%
decnet/services/mqtt.py 14 0 100%
decnet/services/mssql.py 14 0 100%
decnet/services/mysql.py 17 0 100%
decnet/services/pop3.py 14 0 100%
decnet/services/postgres.py 14 0 100%
decnet/services/rdp.py 14 0 100%
decnet/services/redis.py 19 0 100%
decnet/services/registry.py 31 3 90% 38-39, 45
decnet/services/sip.py 14 0 100%
decnet/services/smb.py 14 0 100%
decnet/services/smtp.py 19 0 100%
decnet/services/smtp_relay.py 19 0 100%
decnet/services/snmp.py 14 0 100%
decnet/services/ssh.py 15 0 100%
decnet/services/telnet.py 15 1 93% 36
decnet/services/tftp.py 14 0 100%
decnet/services/vnc.py 14 0 100%
decnet/web/api.py 39 2 95% 32, 44
decnet/web/auth.py 23 0 100%
decnet/web/db/models.py 41 0 100%
decnet/web/db/repository.py 42 0 100%
decnet/web/db/sqlite/database.py 21 4 81% 12, 29-33
decnet/web/db/sqlite/repository.py 168 20 88% 53-54, 58-74, 81, 87-88, 112-113, 304, 306-307, 339-340
decnet/web/dependencies.py 39 0 100%
decnet/web/ingester.py 55 2 96% 66-67
decnet/web/router/__init__.py 24 0 100%
decnet/web/router/auth/api_change_pass.py 14 0 100%
decnet/web/router/auth/api_login.py 15 0 100%
decnet/web/router/bounty/api_get_bounties.py 10 0 100%
decnet/web/router/fleet/api_deploy_deckies.py 50 38 24% 18-79
decnet/web/router/fleet/api_get_deckies.py 7 0 100%
decnet/web/router/fleet/api_mutate_decky.py 10 0 100%
decnet/web/router/fleet/api_mutate_interval.py 17 0 100%
decnet/web/router/logs/api_get_histogram.py 7 1 86% 19
decnet/web/router/logs/api_get_logs.py 11 0 100%
decnet/web/router/stats/api_get_stats.py 8 0 100%
decnet/web/router/stream/api_stream_events.py 44 21 52% 36-68, 70
------------------------------------------------------------------------------
TOTAL 2402 179 93%
```
## 📋 Future Coverage Plan (Missing Tests)
### 🔴 High Priority: `api_deploy_deckies.py` (24%)
- **Problem:** Requires live Docker/MACVLAN orchestration.
- **Plan:**
- Implement a mock engine specifically for the API route test that validates the `config` object without calling Docker.
- Integration testing using **Docker-in-Docker (DinD)** once CI infrastructure is ready.
### 🟡 Medium Priority: `api_stream_events.py` (52%)
- **Problem:** Infinite event loop causes test hangs.
- **Plan:**
- Test frame headers/auth (Done).
- Refactor generator to yield a fixed test set or use a loop-breaker for testing.
### 🟢 Low Priority: Misc. Service Logic
- **Modules:** `services/http.py` (90%), `services/telnet.py` (93%), `distros.py` (96%).
- **Plan:** Add edge-case unit tests for custom service configurations and invalid distro slugs.

181
development/DEBT.md Normal file
View File

@@ -0,0 +1,181 @@
# DECNET — Technical Debt Register
> Last updated: 2026-04-09 — All addressable debt cleared.
> Severity: 🔴 Critical · 🟠 High · 🟡 Medium · 🟢 Low
---
## 🔴 Critical
### ~~DEBT-001 — Hardcoded JWT fallback secret~~ ✅ RESOLVED
~~**File:** `decnet/env.py:15`~~
Fixed in commit `b6b046c`. `DECNET_JWT_SECRET` is now required; startup raises `ValueError` if unset or set to a known-bad value.
### ~~DEBT-002 — Default admin credentials in code~~ ✅ CLOSED (by design)
`DECNET_ADMIN_PASSWORD` defaults to `"admin"` intentionally — the web dashboard enforces a password change on first login (`must_change_password=1`). Startup enforcement removed as it broke tooling without adding meaningful security.
### ~~DEBT-003 — Hardcoded LDAP password placeholder~~ ✅ CLOSED (false positive)
`templates/ldap/server.py:73``"<sasl_or_unknown>"` is a log label for SASL auth attempts, not an operational credential. The LDAP template is a honeypot; it has no bind password of its own.
### ~~DEBT-004 — Wildcard CORS with no origin restriction~~ ✅ RESOLVED
~~**File:** `decnet/web/api.py:48-54`~~
Fixed in commit `b6b046c`. `allow_origins` now uses `DECNET_CORS_ORIGINS` (env var, defaults to `http://localhost:8080`). `allow_methods` and `allow_headers` tightened to explicit allowlists.
---
## 🟠 High
### ~~DEBT-005 — Auth module has zero test coverage~~ ✅ RESOLVED
~~**File:** `decnet/web/auth.py`~~
Comprehensive test suite added in `tests/api/` covering login, password change, token validation, and JWT edge cases.
### ~~DEBT-006 — Database layer has zero test coverage~~ ✅ RESOLVED
~~**File:** `decnet/web/sqlite_repository.py`~~
`tests/api/test_repository.py` added — covers log insertion, bounty CRUD, histogram queries, stats summary, and fuzz testing of all query paths. In-memory SQLite with `StaticPool` ensures full isolation.
### ~~DEBT-007 — Web API routes mostly untested~~ ✅ RESOLVED
~~**Files:** `decnet/web/router/` (all sub-modules)~~
Full coverage added across `tests/api/` — fleet, logs, bounty, stream, auth all have dedicated test modules with both functional and fuzz test cases.
### ~~DEBT-008 — Auth token accepted via query string~~ ✅ RESOLVED
~~**File:** `decnet/web/dependencies.py:33-34`~~
Query-string token fallback removed. `get_current_user` now accepts only `Authorization: Bearer <token>` header. Tokens no longer appear in access logs or browser history.
### ~~DEBT-009 — Inconsistent and unstructured logging across templates~~ ✅ CLOSED (false positive)
All service templates already import from `decnet_logging` and use `syslog_line()` for structured output. The `print(line, flush=True)` present in some templates is the intentional Docker stdout channel for container log forwarding — not unstructured debug output.
### ~~DEBT-010 — `decnet_logging.py` duplicated across all 19 service templates~~ ✅ RESOLVED
~~**Files:** `templates/*/decnet_logging.py`~~
All 22 per-directory copies deleted. Canonical source lives at `templates/decnet_logging.py`. `deployer.py` now calls `_sync_logging_helper()` before `docker compose up` — it copies the canonical file into each active template build context automatically.
---
## 🟡 Medium
### DEBT-011 — No database migration system
**File:** `decnet/web/db/sqlite/repository.py`
Schema is created during startup via `SQLModel.metadata.create_all`. There is no Alembic or equivalent migration layer. Schema changes across deployments require manual intervention or silently break existing databases.
**Status:** Architectural. Deferred — requires Alembic integration and migration history bootstrapping.
### ~~DEBT-012 — No environment variable validation schema~~ ✅ RESOLVED
~~**File:** `decnet/env.py`~~
`DECNET_API_PORT` and `DECNET_WEB_PORT` now validated via `_port()` — enforces integer type and 165535 range, raises `ValueError` with a clear message on bad input.
### ~~DEBT-013 — Unvalidated input on `decky_name` route parameter~~ ✅ RESOLVED
~~**File:** `decnet/web/router/fleet/api_mutate_decky.py:10`~~
`decky_name` now declared as `Path(..., pattern=r"^[a-z0-9\-]{1,64}$")` — FastAPI rejects non-matching values with 422 before any downstream processing.
### ~~DEBT-014 — Streaming endpoint has no error handling~~ ✅ RESOLVED
~~**File:** `decnet/web/router/stream/api_stream_events.py`~~
`event_generator()` now wrapped in `try/except`. `asyncio.CancelledError` is handled silently (clean disconnect). All other exceptions log server-side via `log.exception()` and yield an `event: error` SSE frame to the client.
### ~~DEBT-015 — Broad exception detail leaked to API clients~~ ✅ RESOLVED
~~**File:** `decnet/web/router/fleet/api_deploy_deckies.py:78`~~
Raw exception message no longer returned to client. Full exception now logged server-side via `log.exception()`. Client receives generic `"Deployment failed. Check server logs for details."`.
### ~~DEBT-016 — Unvalidated log query parameters~~ ✅ RESOLVED
~~**File:** `decnet/web/router/logs/api_get_logs.py:12-19`~~
`search` capped at `max_length=512`. `start_time` and `end_time` validated against `^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$` regex pattern. FastAPI rejects invalid input with 422.
### ~~DEBT-017 — Silent DB lock retry during startup~~ ✅ RESOLVED
~~**File:** `decnet/web/api.py:20-26`~~
Each retry attempt now emits `log.warning("DB init attempt %d/5 failed: %s", attempt, exc)`. After all retries exhausted, `log.error()` is emitted so degraded startup is always visible in logs.
### ~~DEBT-018 — No Docker HEALTHCHECK in any template~~ ✅ RESOLVED
~~**Files:** All 20 `templates/*/Dockerfile`~~
All 24 Dockerfiles updated with:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1
```
### ~~DEBT-019 — Most template containers run as root~~ ✅ RESOLVED
~~**Files:** All `templates/*/Dockerfile` except Cowrie~~
All 24 Dockerfiles now create a `decnet` system user, use `setcap cap_net_bind_service+eip` on the Python binary (allows binding ports < 1024 without root), and drop to `USER decnet` before `ENTRYPOINT`.
### ~~DEBT-020 — Swagger/OpenAPI disabled in production~~ ✅ RESOLVED
~~**File:** `decnet/web/api.py:43-45`~~
All route decorators now declare `responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}}`. OpenAPI schema is complete for all endpoints.
### ~~DEBT-021 — `sqlite_repository.py` is a god module~~ ✅ RESOLVED
~~**File:** `decnet/web/sqlite_repository.py` (~400 lines)~~
Fully refactored to `decnet/web/db/` modular layout: `models.py` (SQLModel schema), `repository.py` (abstract base), `sqlite/repository.py` (SQLite implementation), `sqlite/database.py` (engine/session factory). Commit `de84cc6`.
### DEBT-026 — IMAP/POP3 bait emails not configurable via service config
**Files:** `templates/imap/server.py`, `templates/pop3/server.py`, `decnet/services/imap.py`, `decnet/services/pop3.py`
Bait emails are hardcoded. A stub env var `IMAP_EMAIL_SEED` is read but currently ignored. Full implementation requires:
1. `IMAP_EMAIL_SEED` points to a JSON file with a list of `{from_, to, subject, date, body}` dicts.
2. `templates/imap/server.py` loads and merges/replaces `_BAIT_EMAILS` from that file at startup.
3. `decnet/services/imap.py` `compose_fragment()` reads `service_cfg["email_seed"]` and injects `IMAP_EMAIL_SEED` + a bind-mount for the seed file into the compose fragment.
4. Same pattern for POP3 (`POP3_EMAIL_SEED`).
**Status:** Stub in place — full wiring deferred to next session.
---
### DEBT-027 — Dynamic Bait Store
**Files:** `templates/redis/server.py`, `templates/ftp/server.py`
The bait store and honeypot files are hardcoded. A dynamic injection framework should be created to populate this payload across different honeypots.
**Status:** Deferred — out of current scope.
### DEBT-028 — Test coverage for `api_deploy_deckies.py`
**File:** `decnet/web/router/fleet/api_deploy_deckies.py` (24% coverage)
The deploy endpoint exercises Docker Compose orchestration via `decnet.engine.deploy`, which creates MACVLAN/IPvlan networks and runs `docker compose up`. Meaningful tests require mocking the entire Docker SDK + subprocess layer, coupling tightly to implementation details.
**Status:** Deferred — test after Docker-in-Docker CI is available.
---
## 🟢 Low
### ~~DEBT-022 — Debug `print()` in correlation engine~~ ✅ CLOSED (false positive)
`decnet/correlation/engine.py:20` — The `print()` call is inside the module docstring as a usage example, not in executable code. No production code path affected.
### DEBT-023 — Unpinned base Docker images
**Files:** All `templates/*/Dockerfile`
`debian:bookworm-slim` and similar tags are used without digest pinning. Image contents can silently change on `docker pull`, breaking reproducibility and supply-chain integrity.
**Status:** Deferred — requires `docker pull` access to resolve current digests for each base image.
### ~~DEBT-024 — Stale service version hardcoded in Redis template~~ ✅ RESOLVED
~~**File:** `templates/redis/server.py:15`~~
`REDIS_VERSION` updated from `"7.0.12"` to `"7.2.7"` (current stable).
### ~~DEBT-025 — No lock file for Python dependencies~~ ✅ RESOLVED
~~**Files:** Project root~~
`requirements.lock` generated via `pip freeze`. Reproducible installs now available via `pip install -r requirements.lock`.
---
## Summary
| ID | Severity | Area | Status |
|----|----------|------|--------|
| ~~DEBT-001~~ | ✅ | Security / Auth | resolved `b6b046c` |
| ~~DEBT-002~~ | ✅ | Security / Auth | closed (by design) |
| ~~DEBT-003~~ | ✅ | Security / Infra | closed (false positive) |
| ~~DEBT-004~~ | ✅ | Security / API | resolved `b6b046c` |
| ~~DEBT-005~~ | ✅ | Testing | resolved |
| ~~DEBT-006~~ | ✅ | Testing | resolved |
| ~~DEBT-007~~ | ✅ | Testing | resolved |
| ~~DEBT-008~~ | ✅ | Security / Auth | resolved |
| ~~DEBT-009~~ | ✅ | Observability | closed (false positive) |
| ~~DEBT-010~~ | ✅ | Code Duplication | resolved |
| DEBT-011 | 🟡 Medium | DB / Migrations | deferred (Alembic scope) |
| ~~DEBT-012~~ | ✅ | Config | resolved |
| ~~DEBT-013~~ | ✅ | Security / Input | resolved |
| ~~DEBT-014~~ | ✅ | Reliability | resolved |
| ~~DEBT-015~~ | ✅ | Security / API | resolved |
| ~~DEBT-016~~ | ✅ | Security / API | resolved |
| ~~DEBT-017~~ | ✅ | Reliability | resolved |
| ~~DEBT-018~~ | ✅ | Infra | resolved |
| ~~DEBT-019~~ | ✅ | Security / Infra | resolved |
| ~~DEBT-020~~ | ✅ | Docs | resolved |
| ~~DEBT-021~~ | ✅ | Architecture | resolved `de84cc6` |
| ~~DEBT-022~~ | ✅ | Code Quality | closed (false positive) |
| DEBT-023 | 🟢 Low | Infra | deferred (needs docker pull) |
| ~~DEBT-024~~ | ✅ | Infra | resolved |
| ~~DEBT-025~~ | ✅ | Build | resolved |
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) |
| DEBT-027 | 🟡 Medium | Features | deferred (out of scope) |
| DEBT-028 | 🟡 Medium | Testing | deferred (needs DinD CI) |
**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests)
**Estimated remaining effort:** ~12 hours

View File

@@ -0,0 +1,93 @@
# DECNET Development Roadmap
## 🛠️ Service Realism & Interaction (First Release Path)
*Goal: Ensure every service is interactive enough to feel real during manual exploration.*
### Remote Access & Shells
- [ ] **SSH (Cowrie)** — Custom filesystem, realistic user database, and command execution.
- [ ] **Telnet (Cowrie)** — Realistic banner and command emulation.
- [ ] **RDP** — Realistic NLA authentication and screen capture (where possible).
- [ ] **VNC** — Realistic RFB protocol handshake and authentication.
- [x] **Real SSH** — High-interaction sshd with shell logging.
### Databases
- [ ] **MySQL** — Support for common SQL queries and realistic schema.
- [ ] **Postgres** — Realistic version strings and basic query support.
- [ ] **MSSQL** — Realistic TDS protocol handshake.
- [ ] **MongoDB** — Support for common Mongo wire protocol commands.
- [x] **Redis** — Support for basic GET/SET/INFO commands.
- [ ] **Elasticsearch** — Realistic REST API responses for `/_cluster/health` etc.
### Web & APIs
- [x] **HTTP** — Flexible templates (WordPress, phpMyAdmin, etc.) with logging.
- [ ] **Docker API** — Realistic responses for `docker version` and `docker ps`.
- [ ] **Kubernetes (K8s)** — Mocked kubectl responses and basic API exploration.
- [x] **LLMNR** — Realistic local name resolution responses via responder-style emulation.
### File Transfer & Storage
- [ ] **SMB** — Realistic share discovery and basic file browsing.
- [x] **FTP** — Support for common FTP commands and directory listing.
- [ ] **TFTP** — Basic block-based file transfer emulation.
### Directory & Mail
- [ ] **LDAP** — Basic directory search and authentication responses.
- [x] **SMTP** — Mail server banners and basic EHLO/MAIL FROM support.
- [x] **IMAP** — Realistic mail folder structure and auth.
- [x] **POP3** — Basic mail retrieval protocol emulation.
### Industrial & IoT (ICS)
- [x] **MQTT** — Basic topic subscription and publishing support.
- [x] **SNMP** — Realistic MIB responses for common OIDs.
- [ ] **SIP** — Basic VoIP protocol handshake and registration.
- [x] **Conpot** — SCADA/ICS protocol emulation (Modbus, etc.).
---
## Core / Hardening
- [ ] **Attacker fingerprinting** — Capture TLS JA3/JA4 hashes, TCP window sizes, User-Agent strings, and SSH client banners.
- [ ] **Canary tokens** — Embed fake AWS keys and honeydocs into decky filesystems.
- [ ] **Tarpit mode** — Slow down attackers by drip-feeding bytes or delaying responses.
- [x] **Dynamic decky mutation** — Rotate exposed services or OS fingerprints over time.
- [x] **Credential harvesting DB** — Centralized database for all username/password attempts.
- [ ] **Session recording** — Full capture for SSH/Telnet sessions.
- [ ] **Payload capture** — Store and hash files uploaded by attackers.
## Detection & Intelligence
- [ ] **Real-time alerting** — Webhook/Slack/Telegram notifications for first-hits.
- [ ] **Threat intel enrichment** — Auto-lookup IPs against AbuseIPDB, Shodan, and GreyNoise.
- [ ] **Attack campaign clustering** — Group sessions by signatures and timing patterns.
- [ ] **GeoIP mapping** — Visualize attacker origin and ASN data on a map.
- [ ] **TTPs tagging** — Map observed behaviors to MITRE ATT&CK techniques.
## Dashboard & Visibility
- [x] **Web dashboard** — Real-time React SPA + FastAPI backend for logs and fleet status.
- [x] **Decky Inventory** — Dedicated "Decoy Fleet" page showing all deployed assets.
- [ ] **Pre-built Kibana/Grafana dashboards** — Ship JSON exports for ELK/Grafana.
- [ ] **CLI live feed**`decnet watch` command for a unified, colored terminal stream.
- [x] **Traversal graph export** — Export attacker movement as JSON (via CLI).
## Deployment & Infrastructure
- [ ] **SWARM / multihost mode** — Ansible-based orchestration for multi-node deployments.
- [ ] **Terraform/Pulumi provider** — Cloud-hosted decky deployment.
- [ ] **Kubernetes deployment mode** — Run deckies as K8s pods.
- [x] **Lifecycle Management** — Automatic API process termination on `teardown`.
- [x] **Health monitoring** — Active vs. Deployed decky tracking in the dashboard.
## Services & Realism
- [ ] **HTTPS/TLS support** — Honeypots with SSL certificates.
- [ ] **Fake Active Directory** — Convincing AD/LDAP emulation.
- [ ] **Realistic web apps** — Fake WordPress, Grafana, and phpMyAdmin templates.
- [ ] **OT/ICS profiles** — Expanded Modbus, DNP3, and BACnet support.
## Developer Experience
- [x] **API Fuzzing** — Property-based testing for all web endpoints.
- [x] **CI/CD pipeline** — Automated testing and linting via Gitea Actions.
- [x] **Strict Typing** — Project-wide enforcement of PEP 484 type hints.
- [ ] **Plugin SDK docs** — Documentation for adding custom services.
- [ ] **Config generator wizard**`decnet wizard` for interactive setup.

190
development/EVENTS.md Normal file
View File

@@ -0,0 +1,190 @@
# DECNET Honeypot Events
This document details the events generated by each DECNET honeypot service, as found in their respective `server.py` files.
## Service: `docker_api`
| Event Type | Included Fields |
| --- | --- |
| `request` | `method`, `path`, `remote_addr`, `body` |
| `startup` | *None* |
## Service: `elasticsearch`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `post_request` | `src`, `method`, `path`, `body_preview`, `user_agent` |
| `put_request` | `src`, `method`, `path`, `body_preview` |
| `delete_request` | `src`, `method`, `path` |
| `head_request` | `src`, `method`, `path` |
| `root_probe` | `src`, `method`, `path` |
| `cat_api` | `src`, `method`, `path` |
| `cluster_recon` | `src`, `method`, `path` |
| `nodes_recon` | `src`, `method`, `path` |
| `security_probe` | `src`, `method`, `path` |
| `request` | `src`, `method`, `path` |
## Service: `ftp`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connection` | `src_ip`, `src_port` |
| `user` | `username` |
| `auth_attempt` | `username`, `password` |
| `download_attempt` | `path` |
| `disconnect` | `src_ip`, `src_port` |
## Service: `http`
| Event Type | Included Fields |
| --- | --- |
| `request` | `method`, `path`, `remote_addr`, `headers`, `body` |
| `startup` | *None* |
## Service: `imap`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `auth` | `src`, `username`, `password` |
| `command` | `src`, `cmd` |
## Service: `k8s`
| Event Type | Included Fields |
| --- | --- |
| `request` | `method`, `path`, `remote_addr`, `auth`, `body` |
| `startup` | *None* |
## Service: `ldap`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `bind` | `src`, `dn`, `password` |
| `disconnect` | `src` |
## Service: `llmnr`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `query` | `proto`, `src`, `src_port`, `name`, `qtype` |
| `raw_packet` | `proto`, `src`, `data`, `error` |
## Service: `mongodb`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `message` | `src`, `opcode`, `length` |
| `disconnect` | `src` |
## Service: `mqtt`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `auth` | `src` |
| `packet` | `src`, `pkt_type` |
## Service: `mssql`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `auth` | `src`, `username` |
| `unknown_packet` | `src`, `pkt_type` |
## Service: `mysql`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `auth` | `src`, `username` |
## Service: `pop3`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `user` | `src`, `username` |
| `auth` | `src`, `username`, `password` |
| `command` | `src`, `cmd` |
## Service: `postgres`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `startup` | `src`, `username`, `database` |
| `auth` | `src`, `pw_hash` |
| `disconnect` | `src` |
## Service: `rdp`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connection` | `src_ip`, `src_port` |
| `data` | `src_ip`, `src_port`, `bytes`, `hex` |
| `disconnect` | `src_ip`, `src_port` |
## Service: `redis`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `command` | `src`, `cmd`, `args` |
| `disconnect` | `src` |
| `auth` | `src`, `password` |
## Service: `sip`
| Event Type | Included Fields |
| --- | --- |
| `request` | `src`, `src_port`, `method`, `from_`, `to`, `username`, `auth` |
| `startup` | *None* |
## Service: `smb`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `shutdown` | *None* |
## Service: `smtp`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `ehlo` | `src`, `domain` |
| `auth_attempt` | `src`, `command` |
| `mail_from` | `src`, `value` |
| `rcpt_to` | `src`, `value` |
| `vrfy` | `src`, `value` |
| `unknown_command` | `src`, `command` |
## Service: `snmp`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `get_request` | `src`, `src_port`, `version`, `community`, `oids` |
| `parse_error` | `src`, `error`, `data` |
## Service: `tftp`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `request` | `src`, `src_port`, `op`, `filename`, `mode` |
| `unknown_opcode` | `src`, `opcode`, `data` |
## Service: `vnc`
| Event Type | Included Fields |
| --- | --- |
| `startup` | *None* |
| `connect` | `src`, `src_port` |
| `disconnect` | `src` |
| `version` | `src`, `client_version` |
| `security_choice` | `src`, `type` |
| `auth_response` | `src`, `response` |

63
development/FUTURE.md Normal file
View File

@@ -0,0 +1,63 @@
# DECNET — Future Concepts & Architecture
This document tracks long-term, visionary architectural concepts and ideas that are outside the scope of the 1.0 roadmap, but represent the ultimate end-state of the DECNET framework.
## The Honeymaze: Spider Network Topology
### Concept Overview
As attackers breach the perimeter, instead of just lateral movement on a flat `/24` or massive VXLAN, DECNET can dynamically generate an infinite "daisy-chain" of isolated Docker networks. This forces the attacker to establish deep, nested C2 proxy chains (SOCKS, chisel, SSH tunnels) to pivot from machine to machine.
For example:
- `decky-01` sits on the main LAN via `eth0` (MACVLAN). It also has `eth1`, which belongs to `docker-bridge-1`.
- `decky-02` sits exclusively on `docker-bridge-1` as its `eth0`. It also has `eth1`, belonging to `docker-bridge-2`.
- `decky-03` sits exclusively on `docker-bridge-2`.
### Strategic Value
1. **High-Fidelity TTP Telemetry**: By forcing the attacker into a corner where they *must* deploy pivot infrastructure, we capture extremely high-value indicators of compromise regarding their proxy tooling and network tradecraft.
2. **Infinite Time Sinks**: An attacker can spend weeks navigating simulated air-gaps and deep corporate enclaves feeling a false sense of progression.
### Execution & Realism Restrictions
To prevent the topology from feeling artificial or obviously simulated:
1. **Asymmetric Nesting**: A strict 1:1 nested daisy chain is a dead giveaway. Real corporate networks branch organically.
- Some machines should be terminal endpoints (no nested subnets).
- Some machines acts as jump hosts bridging two large local arrays.
- The depth and horizontal fan-out per subnet must be randomized to emulate realistic DMZ $\rightarrow$ Internal $\rightarrow$ OT enclave architectures.
2. **Variable Sizing**: Subnets must contain a random number of containers. An internal enclave might have 50 flat machines, and only *one* of them acts as the bridge to the next isolated segment.
### The Logging Paradox Solved
Deeply nested, air-gapped machines present a logging challenge: if `decky-50` has no route to the internet or the logging network, how can it forward telemetry stealthily?
**Solution**: DECNET completely bypasses the container networking stack by relying purely on Docker's native `stdout` and daemon-level logging drivers. Because the host daemon handles the extraction, the attacker can completely destroy the container's virtual interfaces or be 50 layers deep in an air-gap without ever noticing a magic route, and the telemetry will still perfectly reach the SIEM out-of-band.
### Simulated Topographical Latency
If an attacker proxies 5 subnets deep into what is supposed to be a secure, physically segmented enclave, and `ping` returns a flat `0.05ms` response time, they will instantly realize it's a local simulation on a single host.
To maintain the illusion of depth, DECNET can utilize the **Linux Traffic Control (`tc`)** subsystem and its **Network Emulator (`netem`)** module on the virtual bridge interfaces (`veth` pairs).
By procedurally generating `tc` rules as the network scales, we can inject mathematical latency penalties per hop:
```bash
# Example: Add 45ms latency, +/- 10ms jitter on a normal curve, with 0.1% packet loss
tc qdisc add dev eth1 root netem delay 45ms 10ms distribution normal loss 0.1%
```
As an attacker pivots deeper into the "Spider Network," this injected latency compounds automatically. A proxy chain going 4 levels deep would realistically suffer from 150ms+ of latency and erratic jitter, perfectly mimicking the experience of routing over slow, multi-site corporate VPNs.
---
## Distributed Scale: Swarm Overlay Architecture
To scale DECNET across multiple physical racks or sites, DECNET can leverage **Docker Swarm Overlay Networks** to create a unified L2/L3 backbone without surrendering control to Swarm's "Orchestration" scheduler.
### The `--attachable` Paradigm
By default, Docker's `overlay` driver requires Swarm mode but tightly couples it to `docker service` (which abstracts and randomizes container placement to balance loads). In honeypot deployments, absolute control over physical placement is critical (e.g., placing the `scada-archetype` explicitly on bare-metal node C in the DMZ).
To solve this, DECNET will initialize the swarm control plane simply to construct the backend VXLAN, but completely ignore the service scheduler in favor of `--attachable` networks:
1. **Initialize the Control Plane** (manager node + remote worker joins):
```bash
docker swarm init
```
2. **Create the Attachable Backbone**:
```bash
docker network create -d overlay --attachable decnet-backbone
```
3. **Deploy Standalone**: Keep relying entirely on local `decnet deploy` scripts on the individual physical nodes. Because the network is `attachable`, standalone container instances can seamlessly attach to it and communicate with containers running on completely different hardware across the globe as if they were on a local layer 2 switch!

Some files were not shown because too many files have changed in this diff Show More