Scenario authoring reference
Authoritative reference for netdoc-sim scenario YAML: the schema, service
and fault semantics, and how to add or change one. See
docs/simulation.md for setup and the guide index.
Scenario authoring
Built-ins live in internal/simulation/scenarios/ and are embedded by
internal/simulation/library.go. Start from the closest existing scenario;
the YAML itself is the authoritative example for specialized features such as
routed and dual-stack networks, proxies, TLS, timed faults, and campaigns.
A scenario has four operational parts:
topology: logical segments, nodes, interfaces, routes, resolvers, aliases, and bounded test services;faults: impairments applied before or during the diagnostic;tests: one or more real netdoc invocations in the client node;expect: the verdict and probe results that should follow.
This fragment shows the common shape without duplicating a complete scenario:
name: broken-dns-with-working-ip-connectivity
topology:
nodes:
- {name: client, role: client, address: 10.77.0.10, resolver: 10.77.0.1}
- {name: resolver, address: 10.77.0.1, services: [{type: dns}]}
faults:
- {type: drop, node: resolver, direction: inbound, protocol: udp, port: 53}
tests:
- {node: client, target: example.test:80}
expect:
verdict: dns
summary: Cannot resolve example.test: DNS failure.
checks:
- {id: dns, status: FAIL, fix: "name resolution failing: check /etc/resolv.conf / DNS"}
Use
broken-dns.yaml as the
complete, validated single-segment example.
Resource budget
A scenario is a file the simulator will act on, so the amount of work a valid
one may ask for is bounded. The ceilings below sit an order of magnitude above
the largest scenario in this repository, and a scenario that exceeds any of
them is rejected by netdoc-sim validate, run and campaign alike, before a
namespace, interface, route, alias, listener or service process is created.
| Resource | Scope | Maximum |
|---|---|---|
| scenario input | file | 1 MiB |
topology.nodes |
scenario | 64 |
topology.segments |
scenario | 32 |
topology.routes |
scenario | 256 |
interfaces |
per node | 32 |
aliases |
per node | 32 |
services |
per node | 16 |
tests |
scenario | 32 |
faults |
scenario | 64 |
The input limit is enforced on the file before the YAML parser is given any of it, so an oversized scenario costs one bounded read rather than a parse. Every other limit is measured against the file as written, before shorthand normalization, so the counts an error reports are the ones in the file.
Narrower limits apply within a single object and remain authoritative: 64
records across a DNS service’s zone and records together, 16 certificate
dns_names, 256 outcomes across a dns_fault, 16 events per scheduled fault
and 64 scheduled events across the scenario, and 1000 campaign runs.
Each cardinality ceiling is also published as a maxItems in the JSON
Schema, and a parity test
compares the two so they cannot drift. The input size limit has no schema
equivalent, because JSON Schema cannot describe the size of the document it
validates; there the Go validator is the only contract.
Editor and tooling schema
A machine-readable JSON
Schema for scenario YAML is
published at schema/simulation-scenario-v1.schema.json, in Draft 2020-12.
Point an editor at it for field names, property completion, and enum
completion while authoring. It describes the v1 scenario input the parser
already accepts rather than proposing a replacement for it, so no existing
scenario needs changing to satisfy it.
What it catches is what a schema can see without a topology: misspelled and
unknown keys, which every object rejects because the parser decodes with
KnownFields(true); wrong types; the fields a scenario cannot omit; and the
closed vocabularies, meaning service and fault types, node roles, proxy
schemes, certificate modes, DNS and link outcomes, and the expected probe ids,
statuses, verdicts, causes and address-family states.
netdoc-sim validate remains authoritative for semantic and cross-reference
validation. The schema describes the structural v1 input the parser accepts
before defaults and normalization, and it is designed not to reject a
structurally valid file merely to make the format cleaner: known zero, empty,
and default compatibility cases the parser accepts (port: 0, status: 0,
portal: false, an empty string or list) validate here too. The
cross-reference and topology-aware rules stay in Go: that exactly one node is a
client, that referenced names exist and are unique, that an address sits inside
its segment, that a gateway is on-link, that durations and percentages parse,
that scheduled offsets increase, and that a service or fault carries only the
options its type supports. Schema-valid does not imply semantically valid; a
file that passes the schema can still be rejected by netdoc-sim validate.
Network Doctor validates every built-in scenario against the file and compares
each frozen vocabulary and every yaml: field against the Go source, so the
published schema cannot fall behind the parser.
Deterministic NXDOMAIN scenario
The built-in dns-nxdomain scenario represents an office printer whose
hostname, printer.office.test, has no DNS record while the interface, direct
internet path, QUIC, public DNS and encrypted DNS remain healthy. It is the
deterministic regression fixture for an isolated missing-name diagnosis:
printer.office.test has no A/AAAA records according to either system or public DNS. (The general internet is reachable.)
Fix: check the hostname or publish the missing DNS record
Build and run the scenario from a clone with:
CGO_ENABLED=0 go build -o netdoc .
CGO_ENABLED=0 go build -o netdoc-sim ./cmd/netdoc-sim
./netdoc-sim run dns-nxdomain -repeat 3
The run provisions the network, executes the real
netdoc printer.office.test diagnostic in its client node, and cleans up. Add
-keep to retain it for inspection; the report prints its simulation id, and
./netdoc-sim cleanup <id> releases it.
The original topology.subnet, node address, and node gateway fields are
single-segment compatibility shorthand. Routed scenarios use named segments,
node interfaces, and validated routes; see
healthy-routed-network.yaml.
two-path-healthy.yaml
is the focused hunt control for two IPv4 defaults: ordinary traffic selects the
lower-metric preferred router, while a controlled literal target independently
proves the higher-metric alternate path.
two-router-healthy.yaml
is the control for the single-default route faults. Its client LAN carries two
routers with different jobs: one holds the default out to the internet, the
other a specific route to the target’s subnet. That is what makes “the
default points at the wrong on-link router” and “the specific route is missing”
expressible at all, and distinguishable from each other. Its second client test
is a controlled literal behind the internet router, reached over its own
specific route: that endpoint keeps answering when the default is repointed,
which is how a wrong default route is told apart from the network beyond the
gateway simply being down.
Dual-stack scenarios use ipv4 and ipv6 on the same segment and interface;
see
dual-stack-healthy.yaml.
Addresses are parsed and rendered canonically with net/netip. Routes accept a
prefix or default, never free-form ip syntax. Scenario authors cannot
provide kernel interface names, commands, executable paths, arbitrary proxy
URLs or environment variables, certificate keys or paths, qdisc handles, or
raw firewall expressions. The schema accepts logical intent and trusted code
derives the operating-system arguments.
Services and faults
Do not maintain a second inventory here. The current service and fault types,
their fields, and validation limits are defined in
internal/simulation/scenario.go and internal/simulation/timeline.go; shipped
examples are discoverable with:
rg -n 'type:' internal/simulation/scenarios
rg -l 'campaign:' internal/simulation/scenarios
Some semantics are easy to get wrong even after reading a scenario:
- A
dropwithdirection: outboundacts like a local firewall and can return an immediate refusal.direction: inboundsilently discards arrival at the destination, so the sender waits for its timeout. Use inbound drops for a black-holed remote resolver or service. - A
pmtu_blackholenarrows one router interface and drops the ICMP fragmentation-needed replies that router would send about it. Both halves are required and neither is useful alone: narrowing a hop that still reports the smaller MTU is discovered and worked around, and narrowing an endpoint makes the local kernel refuse the send instead of losing the packet silently. It is rejected on a node that is not a router for that reason. Both endpoints must keep the default MTU, so the narrow hop has to be transit between two routers;pmtu-blackhole.yamlis the worked example. - Public-looking aliases, including documentation-prefix IPv6 addresses, exist only in the private namespace. They do not create host or public routes.
socks5resolves on the client before CONNECT;socks5hsends the hostname to the proxy’s resolver. The paired SOCKS scenarios prove the location with DNS and CONNECT evidence rather than inferring it from success.- A test’s
proxyblock decides which variable netdoc is handed the proxy through, and the two fixtures are not interchangeable: asocks5/socks5hscheme arrives asALL_PROXYand needs asocks5service, whilehttparrives asHTTPS_PROXYand needs anhttp_connectservice. Thehttp_connectfixture tunnels one authority per connection and answers anything else with a status code, so a scenario cannot mistake a refused request for a working one;proxy-only-network.yamlis the worked example, and it blocks direct egress rather than only pointing netdoc at the proxy.proxy-only-network-broken-dns.yamlis the same network with the client’s resolver taken away too. Its port 53 drops are scoped to the client and to the configured resolver address on purpose: a CONNECT proxy resolves the tunnel authority in its own namespace, so a drop that also caught the proxy’s lookups would take the proxy down along with the client and stop testing anything. - TLS, QUIC, and encrypted-DNS fixtures generate private keys and certificates
in memory. Only public CA certificates are written inside the mode-
0700run workspace; trusted code points the netdoc process at the selected TLS CA and at the isolated fixed-endpoint trust directory the QUIC and encrypted-DNS fixtures share, without modifying a host trust store. - The
encrypted_dnsfixture answers netdoc’s encrypted-DNS row over both transports from one static zone (RFC 8484 DoH on its port and RFC 7858 DoT on 853) and accepts the plain TCP connect the direct-egress row makes, which is why the simulated internet serves it on 443 instead of atcpsink. A node that claims1.1.1.1without it makes every scenario report a blocked encrypted resolver; a library test enforces that pairing. - Plain DNS and encrypted DNS are separate capabilities, and the scenarios that
say so have to break one without touching the other.
encrypted-dns-blocked.yamldrops TCP/443 and TCP/853 to1.1.1.1only, so port 53 keeps resolving and direct egress keeps its8.8.8.8fallback;plain-dns-blocked.yamldrops port 53 on both transports and leaves the encrypted ports alone. Each one asserts the working rows beside the broken one, since an encrypted-DNS result collected on a dead network is evidence about the network. - An
httpservice withportal: trueintercepts netdoc’s connectivity checks the way a captive portal does:/generate_204and/connecttest.txtboth answer302to a fixed sign-in URL instead of what they document, and every other path it serves is untouched. A plainhttpservice answers both of those paths cleanly, so which node a connectivity name resolves to is what decides whether that provider is intercepted.captive-portal.yamlis the worked example, and it leaves DNS, TCP/443, QUIC and the target healthy on purpose. That is what makes it a test of precedence: the portal verdict has to outrank the rows that all report success, and the direct-egress row has to stay FAIL rather than be downgraded by them, since behind a portal it is the portal answering. Both connectivity names resolve to the portal node there, because netdoc needs two independently operated endpoints intercepted on one pass before it will name a portal. selective-http-interception.yamlis the negative case on the same topology: the two connectivity names resolve to different nodes, one intercepting and one plain, so exactly one provider is redirected. One provider treated differently is a fact about that provider, so the run has to warn on the direct-egress row and never reach the captive-portal verdict. It is also the drift canary for the second endpoint: if netdoc’s expected clean payload changes, the untouched provider stops reading as clean here.- An
httpservice may setdate_offsetto a signed Go duration such as72hor-30m. ItsDateheader is the server’s current wall clock plus that offset. Omitting the field leaves the standard HTTP server date unchanged. - A
tcpservice withbannerwrites that fixed, newline-terminated greeting and then drains the connection until the client closes it. Banners are limited to 1024 bytes, and active connections close with the service.
Timed faults
The scheduled_netem, scheduled_dns, and scheduled_link examples in the
scenario directory change the network while netdoc is running. T0 is the
instant just before the first netdoc process starts, and one timeline spans all
tests in that scenario.
The complete requested timeline is resolved and validated before a namespace exists. Timing is best-effort: normal scheduling may delay application, so the report records requested and actual offsets and marks events that were applied, failed, or skipped. The requested timeline and its fingerprint are deterministic; wall-clock application is not a hard real-time guarantee.
The runner cancels and joins the scheduler before evidence collection and
cleanup. Keep that lifecycle when extending timed behavior so no event can
reach a topology being dismantled. internal/simulation/timeline_test.go owns
the detailed ordering, cancellation, and shutdown contract.
Expectations and reports
Expectations match netdoc’s stable machine-readable contract: probe id,
PASS/WARN/FAIL/SKIP/N/A status, verdict, and optional structured cause
or address-family state. An optional expectation-level summary pins the exact
final user-facing diagnosis, and an optional check-level fix pins that row’s
exact user-facing remedy. Omit either field when the scenario does not claim its
wording. A test may override the scenario-level expectation when several
network phases are exercised in one file.
Naming ipv4 or ipv6 on an expected check asserts that netdoc published that
family’s verdict, so a run that omitted the family fails the expectation rather
than passing it by default. Leave the field out for families the scenario makes
no claim about: netdoc omits a family it never dialed, and the report keeps it
omitted rather than reporting an empty verdict.
Unknown YAML keys, probe ids, statuses, causes, node references, addresses, and unsupported combinations fail validation before setup:
./netdoc-sim validate path/to/scenario.yaml
./netdoc-sim run path/to/scenario.yaml -dry-run
./netdoc-sim run path/to/scenario.yaml -json
Text and JSON reports carry comparison results, suggestions, cleanup state,
and evidence observed from services and the kernel. Use -json for automation;
the report types in internal/simulation/report.go, campaign/hunt report code,
and their tests are authoritative for fields and stable suggestion codes.
Empty evidence collections remain [], and logical evidence never exposes
generated kernel device names.
Adding or changing a scenario
- Copy the closest YAML under
internal/simulation/scenarios/and change only the topology, fault, test, and expectation needed for the behavior. - Validate the file by path. If it is built in, also rebuild and validate its filename stem through the embedded library.
- Run the scenario with
-dry-run, then normally on a supported Linux host. Use-jsonto inspect structured evidence rather than matching report prose. - Add or adjust the smallest unit test for new parsing, scheduling,
comparison, or evidence logic. Add a focused
netns_integrationtest when the behavior must be proved through real namespaces. - If diagnostic endpoints changed, update every affected alias/zone and run
the
healthycanary; see Probe endpoint drift.
Keep ordinary tests deterministic, rootless, and offline. Real-socket tests
retain the integration tag and loopback-only scope; real namespace tests
retain the netns_integration tag.
This page has one editable copy: edit it at the source.