Three compose projects. One shared network.
Agents speak streamable HTTP to MCPJungle. Testers speak DNS, LDAP, TACACS+, RADIUS, SMTP, NFS, NTP, HTTPS dest-443 (LabSSO), and the HTTP intercept proxy at the published host ports. The appliances meet on mcplab-shared (LAB_DOCKER_SUBNET, default 10.99.42.0/24 — not Docker's default /16 per network).
Host ports and preflight
Protocol data planes SUTs already speak use the IANA dest on the host: DNS 53, NTP 123, LDAP 389 / LDAPS 636, SMTP 25, TACACS+ 49 / 300, RADIUS 1812/1813, NFS 2049, HTTPS 443 for LabSSO (AGENTS.md rule 15). Container listen ports are unprivileged and irrelevant to consumers. Management/control planes stay high. LabSSO is dest-443. LabMITM is a forward proxy (SUT sets HTTP_PROXY), not dest 443 — do not move LABMITM_PROXY_PORT to 443. Today's default-profile dests that are not native (DNS 10053, LDAP 3389/3636, SMTP 1025, NFS 20490) are residual, not a second policy. TacLab is already native. Operator escape is allowed when preflight cannot free the IANA port; it is an escape, not the default. Remaps are follow-on PRs per service.
Host-occupancy preflight checks every published host port. EACCES / EPERM is not occupied (TacLab 49; dockerd can still publish). Occupancy is /proc/net TCP LISTEN / UDP bound. Fail closed if a non-lab process holds the port.
When a lab feature depends on a Docker daemon or host setting, preflight fails closed with the configuration change in the error. Do not boot a lab that pretends the feature works. LabNTP is in compose; there is no Go userland-proxy probe (appliance ADR 0014: host-published UDP is SNAT'd; compose-network + views.preview stay reliable). The daemon.json copy is operator documentation only. Occupancy of opt-in host UDP 123 uses the timesyncd copy. Docker Desktop / VM NAT cannot preserve UDP source — Linux dockerd + userland-proxy false, or macvlan/ipvlan.
Error messages must name the fix. DNS 53 held by systemd-resolved: stop/disable resolved or extra IP for LAB_PUBLIC_HOST or escape LABDNS_DNS_PORT. NTP 123 held by systemd-timesyncd: stop/disable timesyncd (lab host clock may drift; LabNTP never settimeofday) or extra IP or escape LABNTP_NTP_PORT=10123. HTTPS 443 held by nginx/caddy/apache/another compose stack: stop/disable the occupant or extra IP or escape LABSSO_HTTPS_PORT (SUTs that hardcode dest 443 cannot follow).
- LabDNS scratch image
- labinfo service directory
- LabMail receive-only (compose: maildev)
- LabMITM HTTP intercept :18888 / :18088
- LabNTP NTP :10123 / management :18123
- LabSSO HTTPS :443 / management :18443
- ratarmount-rs writable NFSv3
- control :8443
- labldapd native engine
- HTTPS + bearer, lab CA
- taclabd :49 / :300
- RADIUS :1812 / :1813
- control :18049
MCP clients ──streamable HTTP──► MCPJungle :8080/mcp
│
┌────────────────────┼────────────────────┐
│ compose mcplab │ │
│ labdns labinfo LabMail labmitm labntp labsso labgraph ratarmount │
└──────────┬─────────┴─────────┬──────────┘
│ │
compose labldap compose labtacacs
control :8443 taclabd :49/:300
labldapd :1812/:1813 :8080
Permanence vs. wipe
Bootstrap YAML and scenario files are desired state. Anything an MCP tool mutates must come back with make reset or a *_state_reset tool. Gateway registration is tmpfs. The only durable host paths a profile may define are NFS_ARCHIVE_DIR and NFS_DATA_DIR. After a YAML tweak, make reload APP=<app> recreates that one service; make up is first bring-up, a vendor pin bump, or a profile switch.
Security model
Internal hops always use static bearer tokens on the isolated docker network. The single profile knob is LAB_DEV_MODE.
- Hardened: enterprise gateway, redacted labinfo, random-if-missing secrets (empty leftover token files are reminted). labinfo serve fail-closes on a missing or empty token file. Leaving dev mode (marker
secrets/.lab-dev-mode) unlinks and remints orchestrator tokens, runs LabLDAPsetupsecrets --forceandlabgen -force, reloads running containers, and removes the marker last. LabLDAP and LabSSO CAs are not rotated (extra SANs are mode-independent; leaves may still be re-signed ifLAB_PUBLIC_HOSTis missing). - Dev: open gateway, revealed credentials staged world-readable under
secrets/labinfo-creds/(LDAP bind, RADIUS and TACACS+ shared secrets, TacLab lab-user passwords, LabLDAP CA PEM, LabSSO CA PEM, IdP Alice password, optional TacLab client certs), and secret files reconciled fromdev-credentials.yaml(including TacLab lab-user passwords and AAA shared secrets: first mint / pin-bump vialabgen -secrets-from; catalog-only enter-dev still pins afterlabgen; fail-closed if missing; no merge withdefault; default profile shipslab-dev-*values, inert unless this knob is on; LabMail, LabMITM, LabNTP, and LabSSO tokens must be at least 32 bytes). Catalog reconcile never inspectsMCPJUNGLE_MODE.mcplab secretsreloads running containers whose files changed (or when the marker is missing /reloadsis notdone, or when LabLDAP or LabSSO leaves were re-signed forLAB_PUBLIC_HOST) and re-registers the gateway when registrar tokens change;make upskips those apps. Thereloads=pendingmarker is written before catalog apply and setupsecrets/labgen, so a crash after those files land still retries.mcplab creds/make credsprints the same sheet from files on disk (fails closed outside dev; never prints TLS private keys). Dev-modemake smokeasserts those catalog values on the wire (Alice bind, RADIUStaclabAdmin,connections_listequal to disk). Default-profile smoke stays on random secrets and redaction. - Phase 1 puts OAuth 2.1 / OIDC on a proxy in front of the gateway. The tools do not change.
Vendoring and pins
- MCPJungle
0.4.6—MCPJUNGLE_IMAGE_TAG(compose default:-0.4.6). This lab’s default is 0.4.6, not upstream Composelatest/latest-stdio. Do not setMCPJUNGLE_BIND_HOST(unset = all interfaces). Operator dashboard isGET /in development mode only (enterprise 404s). Observed GHCR digest 2026-08-29:sha256:59940d2e3a586ab9a063cf24fa37460bc993686fa056729fd1ada25436123dd9(informational; the tag is the lock). - LabDNS
v1.4.1— operator console onGET /. MCPJungle usesspec.management.mcp.allowLegacyClients: truein the profile YAML (no local patch). Over-length names answer on managementresolve/explainonly; do not add them tolab.test.. - LabLDAP
v0.7.0— native is the default engine; no local patch. Compose iscompose.yaml+compose.ephemeral.yamlplus overlay. Overlay mapsLABLDAP_MANAGEMENT_ALLOWED_HOSTSfromLAB_PUBLIC_HOST(mapping merge, notenvironment: !override). Leaf certs includeLAB_PUBLIC_HOSTas a DNS or IP SAN in both modes; the lab CA private key is not committed. ChangingLAB_PUBLIC_HOSTneedsmcplab secretsplusmake reload APP=labldap. - TacLab
v1.6.0— MCP pin relaxed with the upstreamallow_legacy_clientsknob. InLAB_DEV_MODE=true, first mint and pin-bump passlabgen -secrets-fromso Argon2id is labgen-minted; catalog-only enter-dev still post-processes afterlabgen(ApplyDevSecrets; no PKI wipe). Leave-dev islabgen -forcewithout the flag. The flag does not replaceEnableLegacyClientsDir. Dev mode does not patch the vendor. - LabMail
v1.0.0-rc.4— MCP pin relaxed withallowLegacyClients: trueinlabmail/bootstrap.yaml. DefaultoriginAllowlist: ["*"]for remote inbox JS. Compose service name staysmaildev. - LabMITM
v1.7.2— MCPhttp://labmitm:8088/mcp. Compose must pass--management-listen=:8088. Unauthenticated data plane; intercept:443only (CONNECT to LabLDAP/TacLab TLS is tunnel-not-decrypt). Overlay writeshttpAuth.enabled: falseand 1.2 nested flags present-and-off. Native/v1catalog 31 (includesfeatures.get);GET /v1/features/ MCPmitm_features_listis the 11-row hop/accept catalog. Exact Origins (no"*"). Token 0o644. No local patch.make reload APP=labmitmdoes not re-register. - LabNTP
v1.0.0-rc.3(commit83d568a; GHCR index digest informational when published; the vendor tag is the lock). MCPhttp://labntp:8088/mcp. Compose must pass--management-listen=:8088andcap_add: [NET_BIND_SERVICE]. Host UDP 10123 (ADR 0014). SPAallowedOriginsdeny-all (no"*"). Token 0o644. No Gouserland-proxyprobe.make reload APP=labntpdoes not re-register. No labgraph NTP fan-out. - LabSSO
v1.0.0-rc.4. MCPhttp://labsso:8080/mcp. Compose must pass--management-listen=:8080. NoNET_BIND_SERVICE. Host HTTPS dest-443; management 18443. Dedicated CA (dir 0o755). Signing key mint-if-missing (empty leftover reminted), out of catalog. Issuer YAML must match derived issuer; catalog URLs omit:443. SAML on; generic clothes. NoallowedOrigins. Loopback management is unauthenticated admin. Token 0o644. No local patch.make reload APP=labssodoes not re-register. No labgraph SSO fan-out. - labgraph (first-party) — LabScenario orchestrator on
LABGRAPH_PORT18091. REST + MCP + SPA. Native fan-out DNS → MITM → mail → LDAP → TacLab. Named packs atlabgraph://fixtures/{id}/fixture.apply. Empty default scenario is a no-op. Jenkins is not in the integrator.make reload APP=labgraphdrops the in-memory journal. - ratarmount-rs
v0.1.34— NFSv3 export; NFSv4.1 is packaged but not enabled. Persist still copies the compressed prefix; remount uses the patched sidecar and does not rescan prefix frames.
Full write-up, mermaid topology, and the OAuth plan: docs/architecture.md. Configure a profile: Configure.