Two migrations, in either order:
- (a) from the shell-backed scanners (
zmap,masscan) to the nativenet_scantool, 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).
net_scanruns the engine in-process. No subprocess, no argv, noPATHlookup, no process-group cleanup, and it can streamnotifications/progresswhile 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).
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 = 10000Without 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).
| 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.
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.
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
- Confirm the tool is listed:
tools/listmust containnet_scan(src/main.rs:530-546proves this in tests). - Run a small scan and check
rate_pps_capin the structured result equals the minimum you expect from node, secret, cluster and request. - Watch for
unknown probe,blocked address spaceandno scan interface configured— these are the three failure modes that mean configuration, not scanning.
Nothing is irreversible: leave [tools.zmap] and [tools.masscan] enabled and
call them instead. Both keep working exactly as before.
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).
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).
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:6379and 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).
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).
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).
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.
Two supported end states:
- Standalone (TOML only). Leave
store.mongo_uriandstore.redis_uriunset. 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.
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:6379quota 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.
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 | 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.