Skip to content

Latest commit

 

History

History
317 lines (243 loc) · 12.4 KB

MIGRATION.md

File metadata and controls

317 lines (243 loc) · 12.4 KB

Migration

Two migrations, in either order:

  • (a) from the shell-backed scanners (zmap, masscan) to the native net_scan tool, and
  • (b) from legacy config.toml [[auth.secrets]] entries to MongoDB.

Both are additive. Nothing in this document deletes your TOML file, and the on-disk TOML stays authoritative for node-local scan settings (crates/store/src/migrate.rs:10).


(a) From shell-backed zmap / masscan to native net_scan

Why

  • net_scan runs the engine in-process. No subprocess, no argv, no PATH lookup, no process-group cleanup, and it can stream notifications/progress while the scan runs (src/tools/net_scan.rs:1-9).
  • It removes two workarounds the shell tools need: the server must count addresses itself for masscan (tools.masscan.max_ips, config.toml:202-203), and it must hand-write rate flags into argv (tools.masscan.enforce_rate_in_args).
  • The quota chain, target policy and IP accounting are byte-for-byte the same code path, so nothing is lost (src/tools/net_scan.rs:161-174).

Step 1 — check the node settings

net_scan needs an interface and, for off-LAN targets, a gateway MAC. It reads [tools.net_scan] first and falls back to [node] (src/tools/net_scan.rs:200-219):

[node]
iface = "eth0"
# router_mac = "d8:b1:22:68:e1:32"
# adapter_ip = "203.0.113.10"

[tools.net_scan]
enabled = true
source_port_range = "40000-60000"
max_rate_pps = 50000
max_ip_count = 65536
deny_private_targets = true
deny_special_targets = true
wait_secs = 4
tx_workers = 2
max_findings = 10000

Without an interface the tool refuses with no scan interface configured: set [node].iface or [tools.net_scan].interface (src/tools/net_scan.rs:202-207).

The container needs CAP_NET_RAW, the same capability zmap and masscan need (config.toml:157).

Step 2 — translate the call

zmap / masscan invocation net_scan arguments
zmap -p 80 10.0.0.0/8 {"targets": "10.0.0.0/8", "ports": "80", "probe": "tcp_syn"}
zmap -p 80 --probe-module udp 1.2.3.0/24 {"targets": "1.2.3.0/24", "ports": "U:53", "probe": "udp_dns", "dnsName": "example.com"}
zmap -r 10000 "ratePps": 10000
zmap -C 3 "numProbes": 3
masscan 10.0.0.0/8 -p80,443 --rate 1000 {"targets": "10.0.0.0/8", "ports": "80,443", "ratePps": 1000}
masscan --exclude 10.1.0.0/16 "exclude": ["10.1.0.0/16"]
masscan --banners "banners": true (+ optional "bannerHello")
masscan --adapter-ip / --router-mac Point [tools.net_scan].source_ip / .router_mac at them.
zmap --shards n/m (distributed) "shard": "n/m"

Working example:

{
  "targets": "10.0.0.0/24",
  "ports": "22,80,443",
  "probe": "tcp_syn",
  "numProbes": 3,
  "ratePps": 5000,
  "shard": "0/4",
  "seed": 123456789
}

Response shape (src/tools/net_scan.rs:364-400): a text summary plus structured data with interface, targets, probes_sent, frames_received, rate_pps_cap, bytes_per_sec_cap, shard, and findings. Each finding carries ip, port, proto, state, ttl and an optional banner.

Step 3 — probe-name mapping

net_scan accepts exactly these names; anything else is refused before traffic is sent (src/tools/net_scan.rs:99-112):

Probe Use
tcp_syn (default), tcp_ack, tcp_fin TCP scans; tcp_ack maps firewall state, tcp_fin is stealthier.
icmp_echo Host discovery. Ports are ignored.
udp_dns DNS query, name from dnsName.
udp_mdns, udp_snmp, udp_ntp, udp_ssdp, udp_memcached The matching UDP probe templates.

net_scan is IPv4 only: an IPv6 target returns net_scan speaks IPv4 only; split the target or use the nmap tool for IPv6 (src/tools/net_scan.rs:180-183). Keep nmap enabled if you still need IPv6.

Step 4 — port syntax

Comma pieces may carry a protocol prefix, and a bare port takes the probe's default protocol (src/tools/net_scan.rs:99-152):

80                       one TCP port
22,80,443                three TCP ports
1-1024                   a TCP range
U:53                     one UDP port
T:80,U:53                explicit per-portion protocol

Step 5 — verify

  1. Confirm the tool is listed: tools/list must contain net_scan (src/main.rs:530-546 proves this in tests).
  2. Run a small scan and check rate_pps_cap in the structured result equals the minimum you expect from node, secret, cluster and request.
  3. Watch for unknown probe, blocked address space and no scan interface configured — these are the three failure modes that mean configuration, not scanning.

Rollback

Nothing is irreversible: leave [tools.zmap] and [tools.masscan] enabled and call them instead. Both keep working exactly as before.


(b) Legacy [[auth.secrets]] to MongoDB

What it does

zscan migrate reads a TOML file, and for every enabled [[auth.secrets]] entry creates an ApiSecret document and a QuotaProfile document (crates/store/src/migrate.rs:83-112). Idempotent: a name already present under owner legacy is skipped (crates/store/src/migrate.rs:88-95).

It does not modify or delete the TOML file, and it does not rewrite config.toml for you. It also does not touch [tools.*], [node] or [server] — node-local settings stay in TOML on purpose (crates/store/src/migrate.rs:10).

Step 1 — run it

zscan migrate --config config.toml \
  --mongo-uri mongodb://127.0.0.1:27017 \
  --redis-uri redis://127.0.0.1:6379

--mongo-uri and --redis-uri are required; they can come from ZSCAN_MONGO_URI and ZSCAN_REDIS_URI. --mongo-db defaults to zscan.

Expected output:

imported secret 'default' (token + quota profile)
1 secret(s) imported

Re-running prints:

nothing to import (already migrated)

Secrets with enabled = false are skipped silently (crates/store/src/migrate.rs:88).

Step 2 — check the field mapping

The conversion is impl From<&LegacyQuota> for quota::QuotaProfile (crates/store/src/migrate.rs:52-79):

Legacy TOML key Target Exact behaviour
requests_per_minute WindowSpec { len_secs: 60, max_requests: Some(rpm) } Emitted only when > 0.
daily_calls WindowSpec { len_secs: 86_400, max_requests: Some(daily_calls) } Shares the 86400 s window with daily_output_bytes.
daily_output_bytes max_bytes_in on that 86400 s window Note: bytes_in, not bytes_out (migrate.rs:63).
max_concurrent_jobs max_concurrency Clamped with .max(1), so 0 becomes 1.
burst — Dropped. QuotaProfile has no burst field.
daily_ip_budget — Dropped. QuotaProfile has no IP-budget dimension.
per-tool overrides ([auth.secrets.quota.per_tool.*]) — Not modelled by the legacy parser at all.
allow_tools / deny_tools ApiSecret.allow_tools / .deny_tools Copied verbatim.
token ApiSecret.token_hash + .lookup Re-hashed with argon2id; the plaintext token keeps working.
— queue_when_busy, priority, cooldown, max_pps, byte rates, cpu_quota_pct Hard-coded defaults: true, 0, Duration::ZERO, None, None, None.

The daily_output_bytes -> max_bytes_in direction surprises people. It is what the code does (migrate.rs:63) and what the module header documents (migrate.rs:7-8). Be aware that the MCP server bills cluster usage as Usage { bytes_out: bytes, bytes_in: 0 } (src/server.rs:237), so a migrated byte window measured on bytes_in will not be tripped by tool output. If the byte budget matters to you, set it explicitly after migrating:

zscan quota set default --rpd 5000 \
  --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379

and add a byte window through the admin panel, which writes max_bytes_out for the "MB out/day" field and max_bytes_in for "MB in/day" (crates/admin/src/handlers.rs:186-194).

Step 3 — understand what Mongo now holds

Database zscan (or --mongo-db) with three collections:

Collection Document Key fields
users User username, password_hash (argon2id PHC), role, enabled, quota_subject, created_at. Unique index on username.
secrets ApiSecret owner, name, token_hash, lookup (first 8 chars of the token, for O(1) shortlisting), allow_tools, deny_tools, enabled, created_at, expires_at. Unique index on (owner, name).
quota_profiles QuotaProfileDoc _id = subject, the flattened QuotaProfile, updated_at.

Migrated secrets are owned by the literal owner legacy (crates/store/src/migrate.rs:90-104).

Step 4 — wire the server to the store

Add the [store] section and restart. Cluster quotas load at startup and stay fresh through the Redis invalidation bus:

[store]
mongo_uri = "mongodb://127.0.0.1:27017"
mongo_db = "zscan"
redis_uri = "redis://127.0.0.1:6379"
cookie_key = "<128 hex chars>"
session_ttl_secs = 86400
admin_bind = "127.0.0.1:9100"

Startup logs N cluster quota profiles loaded and the admin panel then listens on admin_bind (src/main.rs:113-122, src/main.rs:246-250).

Every publish to zq:invalidate names one subject; each node re-reads that subject from Mongo and either installs or removes the profile (crates/store/src/quota_cache.rs:33-53).

Step 5 — bootstrap an admin

curl -i -X POST http://127.0.0.1:9100/admin/bootstrap \
  -d 'username=admin&password=<at-least-8-chars>'

This creates the first user with role admin. The second call returns 403 bootstrap closed: users already exist (crates/admin/src/handlers.rs:38-44). After that, sign in at /admin/login and manage users, secrets and quota profiles from the panel.

Step 6 — keep TOML or drop it

Two supported end states:

  • Standalone (TOML only). Leave store.mongo_uri and store.redis_uri unset. The server uses only [[auth.secrets]] and serves no admin panel (src/main.rs:222-225).
  • Shared state (Mongo + Redis). Mongo holds users, secrets and cluster quota profiles; TOML keeps [server], [limits], [tools.*] and [node].

A partially migrated cluster is a supported state: when a subject has no Mongo profile, the server falls back to the TOML secret quotas (crates/store/src/quota_cache.rs:5-8).

Important: the MCP data path authenticates only against [[auth.secrets]] in TOML (src/auth.rs:99-171). Mongo ApiSecret documents authenticate through Store::authenticate_token (crates/store/src/secrets.rs:76-89), which the MCP request path does not call. So after migrating, keep the corresponding [[auth.secrets]] entries in config.toml for as long as MCP clients must authenticate. Removing them makes those tokens stop working on the MCP endpoint even though the Mongo documents still verify.

Step 7 — verify

zscan quota list --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379
zscan user list  --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379

quota list prints one line per profile: <subject> conc=<n> cooldown=<s>s prio=<p> pps=<n> windows=[60s:60req, ...] (crates/zscan-cli/src/main.rs:385-395).

The repository's own live check does the same thing (tests/migrate_live.rs:13-88): run zscan migrate against a TOML with two secrets, then assert both plaintext tokens still authenticate and the allow_tools list survived.

Rolling back

Migration is additive, so rollback means going back to TOML-only operation: remove the [store] URIs and restart. Do not delete [[auth.secrets]] if you may need to roll back — the tokens live there.

Command reference

Command Required flags Notes
zscan migrate --mongo-uri, --redis-uri --config defaults to config.toml, --mongo-db to zscan.
zscan user add <USERNAME> --mongo-uri, --redis-uri --password or --password-stdin (min 10 chars), --role admin|operator|viewer.
zscan user list --mongo-uri, --redis-uri Prints username, role, enabled/disabled.
zscan quota set <SUBJECT> --mongo-uri, --redis-uri --concurrency, --cooldown-secs, --priority, --rpm, --rph, --rpd, --rpw, --max-pps, --cpu-pct.
zscan quota list --mongo-uri, --redis-uri Prints concurrency, cooldown, priority, pps and windows.

--mongo-uri and --redis-uri read ZSCAN_MONGO_URI and ZSCAN_REDIS_URI respectively, so CI can export them once.