XTR
XTR is a REST-facing proxy for X-Road services. Point it at a folder of service definitions and it publishes each one as an HTTP endpoint. Callers speak plain HTTP to XTR; XTR speaks mTLS to the X-Road Security Server on their behalf.
Two service kinds:
- SOAP — auto-generated from WSDL files. Ships with 194 live endpoints for real Estonian X-Road services (Ariregister + Maa-amet + Keskkonnaamet + RMK + Kliimaministeerium). SOAP responses are translated to JSON.
- REST — hand-written DSL, passthrough. Body + headers + query forwarded verbatim over mTLS. Implements the X-Road Message Protocol for REST v1.0.4.
Rust reimplementation of buerokratt/XTR.
Version: 0.3.0-rc · License: Apache-2.0
· Repo: turnerrainer/XTR
· Images: docker.io/turnerrainer/xtr:rc, ghcr.io/turnerrainer/xtr:rc
One-command demo
docker run -d --name xtr -p 8080:8080 turnerrainer/xtr:rc
curl http://localhost:8080/health # {"status":"ok"}
curl -s http://localhost:8080/api | jq '.paths | keys | length' # 194
Real call against the real Estonian Business Register (fake creds → real SOAP fault, which proves the wire round-trip works):
curl -sX POST http://localhost:8080/ariregister/lihtandmed_v3 \
-H 'content-type: application/json' \
-d '{"ariregister_kasutajanimi":"x","ariregister_parool":"x","ariregistri_kood":"70006317","ariregister_sessioon":"","ariregister_valjundi_formaat":"","evnimi":"","evarv":"","keel":""}'
Response:
{"error":"upstream_soap_fault","message":"upstream returned SOAP Fault (SOAP-ENV:Server)","code":"SOAP-ENV:Server","string":"Incorrect user name or password."}
Read in order
- Getting started — install, run, add a SOAP service, add a REST service
- Configuration —
xtr.yamlreference (every field, every default) - WSDL folder-drop — auto-generate SOAP DSLs from WSDLs
- REST passthrough — REST-lane DSL reference (wire protocol, headers, security posture)
- Security Server setup — mTLS keystore + trust CA (required for both lanes' X-Road routing)
- Doctor & migration — validate
xtr.yamlbefore deploy - Failure modes — every HTTP status XTR emits
Getting started
Install → run → add a SOAP service → add a REST service. Everything you need to reach a working deployment on one page.
Prerequisites
Docker + Docker Compose v2. Optional: Rust 1.88+ for source builds and running the test suite.
Run
Three ways to start XTR. Any one is enough.
A. Pre-built image (fastest)
docker run -d --name xtr -p 8080:8080 turnerrainer/xtr:rc
Also available at ghcr.io/turnerrainer/xtr:rc. Both are multi-arch
(amd64 + arm64), cosign-signed.
B. Docker Compose from source
git clone -b dev https://github.com/turnerrainer/XTR.git xtr
cd xtr
docker compose up -d --build
First build takes 2–3 minutes; incrementals are seconds.
C. Cargo (for development)
git clone -b dev https://github.com/turnerrainer/XTR.git xtr
cd xtr
cargo run --release
Verify
curl http://localhost:8080/health # {"status":"ok"}
curl -s http://localhost:8080/api | jq '.paths | keys | length' # 194
/api returns the auto-generated OpenAPI 3.1 spec — the complete
endpoint list, request/response schemas, and error codes.
First real call (no Security Server needed)
Ariregister (Estonian Business Register) is the shipped SOAP demo — no Security Server required, just a vendor username/password. With fake creds you get a real SOAP fault, which proves the wire works:
curl -sX POST http://localhost:8080/ariregister/lihtandmed_v3 \
-H 'content-type: application/json' \
-d '{"ariregister_kasutajanimi":"x","ariregister_parool":"x","ariregistri_kood":"70006317","ariregister_sessioon":"","ariregister_valjundi_formaat":"","evnimi":"","evarv":"","keel":""}'
Every other X-Road service — SOAP or REST — needs a Security Server. See Security Server setup.
Add your own SOAP service
SOAP DSLs are one YAML file per operation. Two ways to produce them: auto-generated from a WSDL, or hand-written for services without a WSDL / when you need custom Handlebars logic.
From a WSDL (the standard way)
- Drop the WSDL under
wsdl/<group>/<subsystem>/:wsdl/my-vendor/my-service/api.wsdl - Create a sidecar
api.meta.yamlnext to it with the X-Road identity:member_class: GOV member_code: "70000123" subsystem_code: my-service - Restart XTR. Every
wsdl:operationin the WSDL becomes aPOST /my-vendor/my-service-<operation>endpoint.
Full details: WSDL folder-drop.
Hand-written SOAP DSL (fallback)
-
Create
DSL/<group>/<operation>.yml:# DSL/example/lookup.yml → POST /example/lookup params: - reg_code service: https://example.com/soap # omit → route via Security Server method: POST envelope: > <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Body> <lookup> <reg_code>{{reg_code}}</reg_code> </lookup> </soapenv:Body> </soapenv:Envelope> -
Restart XTR. Available at
POST /example/lookup.
Field-by-field:
| Field | Purpose |
|---|---|
kind: soap | Optional. Default when omitted — every 0.2.x DSL parses unchanged. |
params: | Allow-list of JSON keys the caller may supply. Anything else is silently dropped before Handlebars sees it — this is your template-injection defense. |
service: | Set to a URL for direct HTTPS. Omit to route through the Security Server. |
method: | Almost always POST. Non-POST returns 405. |
envelope: | SOAP envelope as a Handlebars template. |
Handlebars auto-context available in every envelope:
| Placeholder | Renders |
|---|---|
{{generate.uuid}} | Fresh UUID per request (X-Road message id) |
{{generate.instance}} | xroad_instance: from config |
{{{generate.client}}} | Your <xroad:client> element — triple-brace, always |
{{generate.protocol_version}} | xroad_protocol_version: from config |
Triple-brace {{{...}}} disables HTML-escaping — needed anywhere
the value is raw XML. Double-brace {{...}} is right for user-
provided text (numeric IDs, names) and provides XML-injection
defense.
Add your own REST service
REST DSLs declare kind: rest and route through the same X-Road
Security Server the SOAP lane uses — over the same mTLS identity.
XTR builds the /r1/… URL, sets the mandatory X-Road-Client
header, and forwards the caller's body + headers + query verbatim.
-
Create
DSL/<group>/<operation>.yml:# DSL/rr/isikud.yml → GET /rr/isikud kind: rest method: GET # DSL contract — GET only; non-GET → 405 target: member_class: GOV member_code: "70008440" subsystem_code: rr service_code: dde # X-Road REST §4.1: versioning lives INSIDE path. path: /v1/isikud # Optional. Absent → forward every query key unmodified (spec default). # Empty [] → drop all. Non-empty → allow-list. allowed_query_params: - personalCode -
Restart XTR. Call it:
curl -s "http://localhost:8080/rr/isikud?personalCode=38001011234" \ -H 'X-Road-UserId: EE38001011234'
XTR forwards to:
GET https://<security-server>/r1/ee-test/GOV/70008440/rr/dde/v1/isikud?personalCode=38001011234
X-Road-Client: ee-test/GOV/70008440/<your-subsystem>
X-Road-Id: <fresh-uuid>
X-Road-UserId: EE38001011234
Deep dive on the wire behaviour, header semantics, and DSL options: REST passthrough.
End-to-end example: both lanes together
A complete xtr.yaml + DSL tree serving one SOAP service (via
direct HTTPS to a public vendor) and one REST service (via
Security Server):
Directory layout:
.
├── xtr.yaml
├── ssl/
│ ├── xtr-client.p12 # your PKCS12 identity
│ └── xroad-ca.pem # your Security Server's CA bundle
└── DSL/
├── ariregister/
│ └── lihtandmed_v3.yml # SOAP — direct HTTPS, no SS
└── rr/
└── isikud.yml # REST — through SS
xtr.yaml:
dsl_path: ./DSL
port: 8080
xroad_instance: ee-test
xroad_protocol_version: "4.0"
client_data: # your X-Road identity
member_class: GOV # (needed for the REST lane's
member_code: "70000000" # X-Road-Client + SOAP lane's
subsystem_code: my-subsystem # <xroad:client> envelope)
security_server: # required by REST DSLs +
# any SOAP DSL without `service:`
url: https://out.test.x-tee.ee:5500/
keystore_path: ./ssl/xtr-client.p12
keystore_password_env: XTR_KEYSTORE_PASSWORD
# Almost always needed — real X-Road SS certs are behind an
# operator-managed private CA that isn't in the system trust store.
trust_ca_path: ./ssl/xroad-ca.pem
DSL/ariregister/lihtandmed_v3.yml — plain-HTTPS SOAP, no SS:
kind: soap # optional; default
service: https://ariregxmlv6.rik.ee/ # direct HTTPS
method: POST
params:
- ariregister_kasutajanimi
- ariregister_parool
- ariregistri_kood
envelope: >
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:prod="http://arireg.x-road.eu/producer/">
<soapenv:Body>
<prod:lihtandmed_v3>
<prod:keha>
<prod:ariregister_kasutajanimi>{{ariregister_kasutajanimi}}</prod:ariregister_kasutajanimi>
<prod:ariregister_parool>{{ariregister_parool}}</prod:ariregister_parool>
<prod:ariregistri_kood>{{ariregistri_kood}}</prod:ariregistri_kood>
</prod:keha>
</prod:lihtandmed_v3>
</soapenv:Body>
</soapenv:Envelope>
DSL/rr/isikud.yml — REST passthrough via Security Server:
kind: rest
method: GET
target:
member_class: GOV
member_code: "70008440"
subsystem_code: rr
service_code: dde
path: /v1/isikud
allowed_query_params:
- personalCode
Boot and validate:
export XTR_KEYSTORE_PASSWORD='<the-p12-password>'
# 1. Doctor first — catches missing SS, wrong URL scheme, empty
# target fields, identifier charset issues, etc.
docker run --rm \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
-v "$(pwd)/DSL:/app/DSL:ro" \
-v "$(pwd)/ssl:/app/ssl:ro" \
-e XTR_KEYSTORE_PASSWORD \
turnerrainer/xtr:rc doctor --strict
# 2. Then boot the server.
docker run -d --name xtr -p 8080:8080 \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
-v "$(pwd)/DSL:/app/DSL:ro" \
-v "$(pwd)/ssl:/app/ssl:ro" \
-e XTR_KEYSTORE_PASSWORD \
turnerrainer/xtr:rc
Call both endpoints:
# SOAP — direct HTTPS to Ariregister:
curl -sX POST http://localhost:8080/ariregister/lihtandmed_v3 \
-H 'content-type: application/json' \
-d '{"ariregister_kasutajanimi":"x","ariregister_parool":"x","ariregistri_kood":"70006317"}'
# REST — through your Security Server to Population Register:
curl -s "http://localhost:8080/rr/isikud?personalCode=38001011234" \
-H 'X-Road-UserId: EE38001011234'
That's a complete two-lane deployment.
Collision rules (SOAP DSLs)
If a hand-written DSL and a WSDL-generated DSL would land at the
same path, the hand-written one wins. WSDL-generated files carry a
marker header (# GENERATED BY XTR from WSDL — do not edit; delete this line to convert into a hand-written override).
Deleting that line converts the file into a hand-written override
that will never be overwritten by regeneration.
REST DSLs are hand-written only — no WSDL / OpenAPI generator today.
Response shape
SOAP — XML <Body> and <Header> translated to JSON:
{
"body": { …translated SOAP <Body>… },
"headers": { …translated SOAP <Header>… }
}
XML → JSON translation preserves namespace prefixes as literal
keys (prod:reg_code), repeats as arrays, attributes as @name
keys, and coerces bare integer / boolean text nodes to typed
values (42 → Value::Number, "true" → Value::Bool).
REST — upstream response bytes returned unchanged, with all
non-hop-by-hop upstream headers (including
X-Road-Service, X-Road-Request-Hash, X-Road-Error) passed to
the caller. No translation, no reshape.
Response errors
Every XTR-generated error is structured JSON with a stable error
code. Full table: Failure modes.
Upstream errors on the REST lane pass through untranslated — the caller sees the provider service's own 4xx/5xx status + body.
Stop
docker rm -f xtr # Path A / end-to-end example
docker compose down # Path B
# Ctrl-C for Path C
Configuration
Where the file lives
Search order:
--config <path>CLI flagXTR_CONFIG=<path>env var./xtr.yamlor./xtr.ymlin the working directory- Built-in defaults (no file required)
Boot log says which won:
INFO xtr_on_rust: loaded config from ./xtr.yaml
Full annotated xtr.yaml
dsl_path: ./DSL # tree of *.yml DSL files (SOAP + REST)
port: 8080
xroad_instance: ee-test # → {{generate.instance}} (SOAP envelope)
# → X-Road-Client instance (REST header)
xroad_protocol_version: "4.0" # → {{generate.protocol_version}} (SOAP only)
# must be "4.0" or "4.1"
client_data: # X-Road identity XTR presents
member_class: GOV # GOV / COM / NGO / NEE
member_code: "70000000" # your organisation's registry code
subsystem_code: my-subsystem # what you registered with RIA
# → {{{generate.client}}} in SOAP envelopes
# → X-Road-Client: {instance}/{class}/{code}/{subsystem} on REST
wsdl_watch_dir: ./wsdl # auto-generate SOAP DSLs from WSDLs
# (unset = feature off, hand-written only)
# REST-lane is NOT affected — REST DSLs are
# always hand-written.
wsdl: # WSDL ingestion trust boundary (SOAP lane only)
allow_http_upstream: false # opt-in for plaintext http upstreams
upstream_host_allowlist: [] # optional hostname pinning
# upstream_host_allowlist:
# - ariregxmlv6.rik.ee
# - jvis.envir.ee
expose_soap_fault_detail: false # echo raw upstream fault to REST callers
# (SOAP lane only — REST lane doesn't
# translate faults; upstream passes through)
security_server: # X-Road Security Server routing (mTLS)
# Required by REST DSLs and any SOAP DSL without `service:`.
# Unused if you only run direct-HTTPS SOAP DSLs like Ariregister.
url: https://out.test.x-tee.ee:5500/ # YOUR Security Server, port 5500 (message)
keystore_path: /app/ssl/xtr-client.p12 # PKCS12 client identity
keystore_password_env: XTR_KEYSTORE_PASSWORD
# Almost always needed for real X-Road: the SS's TLS cert is
# issued by an operator-managed private CA that isn't in the
# system trust store. Point at the CA bundle PEM.
trust_ca_path: /app/ssl/xroad-ca.pem # optional; PEM CA bundle
limits: # resource ceilings
max_request_bytes: 1048576 # 1 MiB inbound → 413 on overflow
max_response_bytes: 16777216 # 16 MiB upstream → 502 on overflow
request_timeout_secs: 30 # per outbound → 504 on overflow
Fields
Grouped by which lane needs them. "Both" means the field is consulted by SOAP and REST DSLs alike.
Runtime
| Field | Default | Lane | Purpose |
|---|---|---|---|
dsl_path | ./DSL | Both | Directory walked for *.yml / *.yaml DSL files. |
port | 8080 | Both | HTTP listen port. |
limits.max_request_bytes | 1048576 (1 MiB) | Both | Inbound REST body cap. Overflow → 413. |
limits.max_response_bytes | 16777216 (16 MiB) | Both | Upstream response cap. Overflow → 502, connection torn down. |
limits.request_timeout_secs | 30 | Both | Per outbound request. Timeout → 504. |
X-Road identity
| Field | Default | Lane | Purpose |
|---|---|---|---|
xroad_instance | ee-test | Both | SOAP: {{generate.instance}}. REST: instance segment of URL + X-Road-Client. |
xroad_protocol_version | "4.0" | SOAP | {{generate.protocol_version}}. Boot-validated: must be "4.0" or "4.1" (audit-v1 M1). Not sent on REST — the REST protocol has its own version (r1, hard-coded). |
client_data.member_class | "" | Both | SOAP: <xroad:client>. REST: X-Road-Client. Empty skips sidecar identity check. |
client_data.member_code | "" | Both | Same as above. |
client_data.subsystem_code | "" | Both | Correctly spelled (fixes JVM bug #1). Same as above. |
WSDL folder-drop (SOAP only)
| Field | Default | Lane | Purpose |
|---|---|---|---|
wsdl_watch_dir | absent | SOAP | Feature off when unset. See WSDL folder-drop. |
wsdl.allow_http_upstream | false | SOAP | When false, <soap:address> URLs must be https://. Set true only for local test setups. Audit-v1 C1. |
wsdl.upstream_host_allowlist | [] | SOAP | Optional. When non-empty, every WSDL upstream host must appear on the list. Closes the DNS-rebinding lane. Audit-v1 C1. |
SOAP fault exposure
| Field | Default | Lane | Purpose |
|---|---|---|---|
expose_soap_fault_detail | false | SOAP | When false, upstream SOAP Fault.detail is stripped from REST responses and faultstring is capped at 200 chars; server logs still carry the full detail at warn! level. Set true only inside trusted environments. Audit-v1 H3. Does not apply to REST DSLs — REST faults come from the provider service, not from XTR. |
Security Server (mTLS)
| Field | Default | Lane | Purpose |
|---|---|---|---|
security_server | absent | Both | Required by REST DSLs. Required by SOAP DSLs that omit service:. Absent → those DSLs error at request time. |
security_server.url | required if section set | Both | URL of YOUR Security Server (not the central authority's). Must be https:// (see doctor rule fatal-rest-ss-not-https). Standard port is 5500 for the message channel; 4000 is the admin UI (do not use). |
security_server.keystore_path | required if section set | Both | PKCS12 identity file for mTLS. Same file serves both lanes. |
security_server.keystore_password_env | XTR_KEYSTORE_PASSWORD | Both | Env var name to read the PKCS12 password from. Never a default value — fixes JVM bug #16. |
security_server.trust_ca_path | absent | Both | Optional PEM CA bundle used to verify the Security Server's TLS cert. Real X-Road SS certs are typically behind an operator-managed private CA that isn't in the system trust store; without this the handshake fails with unknown issuer. |
Environment variables
| Variable | Purpose |
|---|---|
XTR_CONFIG | Alternative path to xtr.yaml (bypasses cwd search). |
XTR_KEYSTORE_PASSWORD | Password for the PKCS12 identity. Required whenever security_server: is set. |
RUST_LOG | tracing_subscriber filter (info, debug, xtr_on_rust=trace, …). |
Validating your config before deploying
Run xtr-on-rust doctor (shipped in the same image) — it walks
the loaded config, emits FATAL / BREAK / WEAK / INFO findings, and
exits non-zero when something will fail at boot or when a stronger
security posture is available. See
Doctor & migration for the full rule catalogue —
including the REST-lane codes that flag missing SS, non-HTTPS SS
URL, empty target fields, and identifier-charset issues.
Startup validation
Every SOAP DSL's Handlebars envelope is compiled at boot. A malformed template blows up on startup with the offending file path — not on the first live request. REST DSLs have no template and are validated against required-fields presence + spec identifier charset at load time.
No hot reload
Config, DSLs, and WSDLs are read once at boot. Restart to apply changes.
Doctor & migration
XTR ships an in-image config validator: xtr-on-rust doctor.
Run it against your xtr.yaml before every deploy — it flags
what will break at boot, what changed vs the previous minor
version, and where a stronger security posture is available.
The full migration guide lives in
MIGRATION.md
at the repo root (mirrored in this book under
reference/migration).
Recipe
docker run --rm \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
-v "$(pwd)/DSL:/app/DSL:ro" \
turnerrainer/xtr:rc doctor --strict
Mount the DSL tree too — several REST-lane rules only fire when the doctor can see the loaded DSL files (e.g. it can only warn about a missing Security Server if REST DSLs are present).
Findings model
| Severity | Meaning | Exit code |
|---|---|---|
| FATAL | Server will not boot with this config. | 1 |
| BREAK | Behaviour changed vs last minor and your config is on the losing side. Set the named recovery flag if you need bit-for-bit equivalence. | 1 |
| WEAK | Currently works, but a stronger posture is available. | 0 normally; 1 under --strict |
| INFO | Positive observations. | 0 |
The code field on every finding is stable — pin your CI rules
to those, not to headlines.
Rule catalogue
Every code the doctor can emit, grouped by area.
Startup validation (always checked)
| Severity | Code | Fires when |
|---|---|---|
| FATAL | fatal-config-xroad-protocol-invalid | xroad_protocol_version isn't "4.0" or "4.1" (audit-v1 M1). |
| INFO | info-config-xroad-protocol-ok | Protocol version accepted. |
| INFO | info-config-source | Which file was loaded (--config / XTR_CONFIG / ./xtr.yaml). |
| INFO | info-config-defaults | No config file found; using built-in defaults. |
| INFO | info-limits-summary | Snapshot of resource ceilings that will apply. |
X-Road identity (SOAP + REST)
| Severity | Code | Fires when |
|---|---|---|
| FATAL | fatal-client-data-placeholder-member_code | client_data.member_code still holds <placeholder> text. |
| FATAL | fatal-client-data-placeholder-subsystem_code | Same for subsystem_code. |
| WEAK | weak-client-data-empty | All three client_data.* fields empty — envelope/header will have no identity. |
Security Server + mTLS (used by REST + Security-Server-routed SOAP)
| Severity | Code | Fires when |
|---|---|---|
| FATAL | fatal-keystore-env-missing | security_server: is set but its keystore_password_env variable is unset. |
| FATAL | fatal-keystore-env-empty | Env var is set but empty. |
| FATAL | fatal-keystore-file-missing | keystore_path doesn't exist on disk. |
| INFO | info-keystore-env-present | Env var resolved. |
WSDL folder-drop (SOAP lane)
| Severity | Code | Fires when |
|---|---|---|
| WEAK | weak-wsdl-allow-http | wsdl.allow_http_upstream: true (audit-v1 C1). |
| WEAK | weak-wsdl-allowlist-empty | wsdl_watch_dir set but wsdl.upstream_host_allowlist is []. |
| INFO | info-wsdl-allowlist-pinned | Non-empty allowlist. |
SOAP fault exposure
| Severity | Code | Fires when |
|---|---|---|
| WEAK | weak-error-expose-soap-fault-detail | expose_soap_fault_detail: true (audit-v1 H3). |
REST lane (issue #5)
| Severity | Code | Fires when |
|---|---|---|
| FATAL | fatal-rest-no-security-server | REST DSL(s) loaded but security_server: is unset. Every REST request would 500. |
| FATAL | fatal-rest-ss-not-https | security_server.url doesn't start with https:// (X-Road REST §4.7). |
| FATAL | fatal-rest-target-fields-missing | A REST DSL has empty target.member_class / member_code / subsystem_code / service_code. |
| WEAK | weak-rest-identifier-charset | A REST DSL's target identifiers contain characters outside spec §4.8 (A-Za-z0-9'()+,-.=?). |
| INFO | info-rest-lane-ready | REST DSL(s) present and SS configured. |
| INFO | info-rest-trust-ca-system | Using system trust store for SS TLS — flag reminder to set trust_ca_path if the SS uses a private CA. |
Resource limits
| Severity | Code | Fires when |
|---|---|---|
| WEAK | weak-limits-request-too-generous | max_request_bytes > 16 MiB. |
| WEAK | weak-limits-response-too-generous | max_response_bytes > 128 MiB. |
| WEAK | weak-limits-timeout-too-long | request_timeout_secs > 300. |
Path checks
| Severity | Code | Fires when |
|---|---|---|
| WEAK | weak-paths-dsl-missing | dsl_path doesn't exist. |
| WEAK | weak-paths-wsdl-watch-missing | wsdl_watch_dir set but path doesn't exist. |
Machine-readable output
xtr-on-rust doctor --format json | jq '.[] | select(.severity == "FATAL")'
Stable schema per finding:
{
"severity": "FATAL",
"code": "fatal-rest-no-security-server",
"field": "security_server",
"headline": "3 REST DSL(s) loaded but security_server is unset",
"rationale": "...",
"recovery": "..."
}
Recommended CI gate
# .github/workflows/xtr-config-gate.yml
name: XTR config gate
on:
pull_request:
paths: [xtr.yaml, wsdl/**, DSL/**]
jobs:
doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: |
docker run --rm \
-v "$PWD/xtr.yaml:/app/xtr.yaml:ro" \
-v "$PWD/wsdl:/app/wsdl:ro" \
-v "$PWD/DSL:/app/DSL:ro" \
turnerrainer/xtr:rc doctor --strict --format json \
| tee doctor.json
- run: |
fatal=$(jq '[.[] | select(.severity=="FATAL")] | length' doctor.json)
[ "$fatal" -eq 0 ] || { echo "::error::$fatal FATAL finding(s)"; exit 1; }
See the migration reference for the exact breaking changes across minor versions with their recovery flags.
WSDL folder-drop
Point XTR at a folder of WSDL files. Every wsdl:operation becomes
a live POST /group/operation endpoint on next boot.
SOAP lane only. WSDL-driven generation produces SOAP DSLs
(envelope + Handlebars). REST DSLs (kind: rest) are hand-written
— see REST passthrough.
Enable
# xtr.yaml
wsdl_watch_dir: ./wsdl
Absent = feature off (only hand-written DSLs load).
Layout
wsdl/
├── <owner>/ # URL prefix — often the org / vendor slug
│ └── <subsystem>/ # organisational grouping (optional)
│ ├── N.wsdl
│ └── N.meta.yaml # sidecar: X-Road identity for the envelope
└── <single-service>/ # flat: no subsystem subdir
├── api.wsdl
└── api.meta.yaml
Path mapping:
| WSDL location | Generated DSL | URL |
|---|---|---|
wsdl/foo/api.wsdl | DSL/foo/<op>.yml | POST /foo/<op> |
wsdl/foo/bar/N.wsdl | DSL/foo/bar-<op>.yml | POST /foo/bar-<op> |
Nesting is for human browsability. URL segments are always
/<owner>/<optionally-prefixed-op> — 2 segments, no exceptions.
Sidecar meta.yaml
Placed next to each WSDL (api.wsdl + api.meta.yaml).
member_class: GOV
member_code: "70000123"
subsystem_code: my-service
# Optional: explicit service code (defaults to operation name).
service_code: someMethod
# Optional: public-HTTPS override for dual-mode vendors like
# Ariregister. When set, DSLs skip X-Road envelope wrapping and
# hit this URL directly. Vendor auth (username/password in SOAP
# body) still required.
service_url: https://vendor.example.com/soap
When service_url: is set → plain SOAP envelope, direct HTTPS.
Otherwise → X-Road-wrapped envelope, routed via security_server:.
Marker header + collision rules
Every generated DSL starts with:
# GENERATED BY XTR from WSDL — do not edit; delete this line to convert into a hand-written override
Collision rules on the next boot:
| Existing file | Action |
|---|---|
| Doesn't exist | Write |
| Has marker | Overwrite |
| No marker (hand-written) | SKIP with WARN — your edits stand |
Delete the marker line from a generated file to convert it into a hand-written override that survives regeneration.
Deterministic output
Same WSDL always produces byte-equal DSL YAML. git diff between
two boots is empty. Any drift = real WSDL change (or a generator
bug — file it).
Harvest more Estonian services
RIA publishes a public catalog at https://x-tee.ee/catalogue-data/EE/index.json
listing every X-Road subsystem's WSDLs. The shipped
scripts/harvest-xtee-wsdls.sh fetches on demand:
./scripts/harvest-xtee-wsdls.sh --member 70008440 # RR (Rahvastikuregister)
./scripts/harvest-xtee-wsdls.sh --subsystem liiklusregister
./scripts/harvest-xtee-wsdls.sh # everything (~421 subsystems)
Auto-writes .meta.yaml sidecars from catalog metadata. Groups
under wsdl/<owner>/ per the script's built-in OWNERS map (extend
the map for new memberCodes).
Scale limits
The current DSL loader validates every Handlebars template at boot. Practical caps:
| Subsystems | Endpoints | Boot |
|---|---|---|
| Ariregister only | 33 | ~1 s |
| Climate orbit (shipped default) | 194 | ~1 s |
| ~20 subsystems | ~500–800 | ~10 s |
| Full RIA catalog (~421) | ~3000+ | not viable today (task 014) |
Use --subsystem / --member filters to keep the working set
manageable.
Unsupported constructs
WSDL parser bails (with WARN, skips the operation, XTR still boots)
on: xsd:choice, WSDL 2.0, RPC/encoded style, MIME attachments,
xsd:import beyond framework schemas. Override with a hand-written
DSL for those cases (see Getting started).
Why no admin HTTP endpoint
There's no POST /admin/wsdl-from-url. Ingestion is folder-only
by design — admin and consumer surfaces stay hard-separated at
the infrastructure layer, not muddled together via in-process auth.
For URL-driven ingestion, curl -o wsdl/foo/api.wsdl <url> from
your config-management job.
REST passthrough
XTR fronts X-Road REST services the same way it fronts SOAP ones:
a hand-written DSL file per service, mounted at
<method> /<group>/<service>. Callers speak plain HTTP to XTR;
XTR speaks mTLS to the Security Server on their behalf.
Wire behaviour implements the X-Road Message Protocol for REST v1.0.4. Each claim below carries the spec section it maps to.
When to reach for the REST lane
- Your provider speaks X-Road REST (Population Register
/r1/, most services published post-2019). - You want a single component (XTR) to hold the mTLS identity for your whole stack instead of every caller managing its own.
- You need transparent passthrough — no JSON reshape, no translation, provider's response bytes untouched.
Use the SOAP lane instead when the provider publishes a WSDL — see WSDL folder-drop for auto-generation.
DSL shape
# DSL/rr/isikud.yml → /rr/isikud on the axum surface
kind: rest
method: GET # DSL contract — non-GET → 405
target:
member_class: GOV
member_code: "70008440"
subsystem_code: rr
service_code: dde
# Versioning lives INSIDE path (spec §4.1). There is no
# separate service_version field.
path: /v1/isikud
# Optional query filter:
# omitted → forward every query key unmodified (spec §4.5 default)
# [] → drop every query key
# [k1, k2] → allow-list
allowed_query_params:
- personalCode
forward_body: true # default; set false to send empty body upstream
Parent directory becomes the URL group (rr); filename stem
becomes the service (isikud). Same convention as the SOAP lane.
Field reference
| Field | Required | Purpose |
|---|---|---|
kind: rest | ✓ | Selects the REST lane. Omitted → SOAP. |
method: | ✓ | HTTP method the DSL contracts. Inbound mismatch → 405. |
target.member_class | ✓ | Provider identity — from RIA registration. |
target.member_code | ✓ | Provider identity. |
target.subsystem_code | ✓ | Provider identity. |
target.service_code | ✓ | The service code registered under the subsystem. |
target.path | Appended after {service_code}. Leading slash optional. Include any versioning (/v1/…). | |
allowed_query_params | Absent → forward all (spec §4.5 default). [] → drop all. Non-empty → allow-list. | |
forward_body | Default true. Set false for methods that must not carry a body. |
What XTR does on the wire
Given the DSL above and this inbound request:
GET http://xtr/rr/isikud?personalCode=38001011234&extra=preserved
Accept: application/json
X-Road-UserId: EE38001011234
XTR builds and sends:
GET https://<security-server>/r1/ee-test/GOV/70008440/rr/dde/v1/isikud?personalCode=38001011234
Accept: application/json
X-Road-Client: ee-test/GOV/70008440/<your-subsystem>
X-Road-Id: <fresh-uuid>
X-Road-UserId: EE38001011234
Per-header semantics:
| Header | Direction | Behaviour |
|---|---|---|
X-Road-Client | outbound | Mandatory (§4.3). XTR always sets this from config. Inbound values are stripped — callers cannot spoof identity. |
X-Road-Id | outbound | If caller sets one, forwarded verbatim. Else XTR generates a UUID (§4.3). |
X-Road-UserId | outbound | Forwarded verbatim from caller. XTR never synthesises it. |
Accept | outbound | Forwarded unmodified (§4.3). |
Content-Type | outbound | Forwarded unmodified (§4.3). |
Cache-Control, Pragma | outbound | Forwarded unmodified (§4.3). |
User-defined (X-Custom-* etc.) | outbound | Forwarded unmodified (§4.3). |
Host | outbound | Stripped — reqwest sets it from the SS URL. |
Hop-by-hop (Connection, TE, Upgrade, Transfer-Encoding, Keep-Alive, Proxy-*, Trailer) | outbound | Stripped. |
X-Road-Service, X-Road-Request-Hash, X-Road-Request-Id, X-Road-Error, X-Road-Id | inbound (response) | Forwarded to caller (§4.3 response headers). |
URL construction (spec §4.1)
<SS URL>/r1/{instance}/{member_class}/{member_code}/{subsystem_code}/{service_code}{path}
Each identifier segment is percent-encoded per §4.2 — a
service_code literally containing / becomes %2F. XTR uses
RFC 3986 "unreserved" (A-Za-z0-9-._~) as the safe set; every
other character is encoded.
Passthrough response
The upstream response passes through as-is: same status, same
Content-Type, same body bytes, plus all X-Road response headers
the provider Security Server sets (spec §4.3). Hop-by-hop response
headers are stripped.
Upstream 4xx / 5xx responses pass through untouched — including
the X-Road-Error header, which lets the caller distinguish
provider-side errors from Security-Server-side errors per spec
§4.6.
Failures internal to XTR (413 request too large, 502 upstream I/O
error, 504 timeout, 405 method mismatch) still surface as the
standard XtrError JSON envelope. See
Failure modes.
HTTP redirects
Per spec §4.4, X-Road does not follow redirects. XTR pins its
reqwest client to redirect::Policy::none() — 3xx responses reach
the caller verbatim so the caller decides whether to follow.
Required configuration
Every REST DSL routes through the Security Server; there is no
plain-REST bypass. When any REST DSL is loaded, xtr.yaml MUST
carry:
security_server:
url: https://<your-ss>:5500/ # spec §4.7: HTTPS only
keystore_path: /app/ssl/xtr-client.p12
keystore_password_env: XTR_KEYSTORE_PASSWORD
# Almost always needed. Real X-Road SS certs live behind an
# operator-managed private CA that isn't in the system trust
# store. Set to the PEM CA bundle if you get "unknown issuer"
# handshake errors.
trust_ca_path: /app/ssl/xroad-ca.pem
See Security Server setup for how to obtain the PKCS12 + CA bundle.
xtr-on-rust doctor --strict catches the common issues at deploy
time — see Doctor & migration for the REST-lane
rule catalogue.
Trust boundary and shared mTLS
The whole point of the REST lane is that XTR — not each caller — holds the mTLS identity to the Security Server:
Ruuter ─plain HTTP──► XTR ──mTLS──► X-Road SS ──► RR REST service
Muu app ─plain HTTP──► XTR ──mTLS──► X-Road SS ──► LR SOAP service
XTR is the only component in the stack that ever talks mTLS to the Security Server, for either SOAP or REST. Callers behind XTR need plain-HTTP reachability to XTR only — no per-caller PKCS12 keystore, no per-caller SS route. Certificate rotation is a single-component change.
Identifier character restrictions
X-Road REST §4.8 restricts identifier values to
A-Za-z0-9'()+,-.=?. XTR's loader accepts non-conforming values
(so operators can experiment) but the doctor flags them as
weak-rest-identifier-charset. Real Security Servers may reject
them.
Non-goals
- Prefix-mount / wildcard passthrough — one DSL file still maps to one URL. Watch for a follow-up if you need to expose a whole REST service under a single prefix.
- Response translation — no JSON reshape, no XML→JSON adapter. The response is opaque.
- Auto-generation from OpenAPI — REST DSLs are hand-written. SOAP DSLs get WSDL-driven generation; there's no equivalent for REST today.
X-Road Security Server setup
Skip this chapter if you only need the shipped Ariregister demo —
that hits ariregxmlv6.rik.ee directly, no Security Server needed.
Read on when you need any of:
- Any of the 160+ shipped SOAP endpoints (Maa-amet, Keskkonnaamet, RMK, Kliimaministeerium).
- Any REST DSL (
kind: rest) — the REST lane always routes through the Security Server; there is no plain-REST bypass. - Any real X-Road service in general.
The same PKCS12 identity, same SS URL, and same
security_server: config block serve both lanes. Setting up the
Security Server once unlocks both.
What a Security Server is
A Debian-based appliance published by NIIS. It sits on the border between your organisation and the X-Road network:
- Terminates mTLS on both sides (your client cert → SS → peer cert)
- Wraps outbound requests in the standard X-Road envelope
- Validates response
requestHash— proves the response is genuinely a reply to your request - Logs everything for audit
- Registers your organisation's identity with the central authority
XTR itself does none of these — it just points at your Security Server over mTLS.
Decision tree
| Your goal | You need |
|---|---|
| Call Ariregister (shipped demo) | Nothing — works out of the box |
| Any other X-Road test service | An ee-test Security Server (~half a day, free) |
| Real Estonian production service | Full RIA onboarding, contracts, paid CA cert (weeks) |
Rest of this chapter is the ee-test middle row.
Prerequisites
- Public-IP Linux VM (Ubuntu 22.04 or Debian 12, ~2 vCPU / 4 GB RAM)
- DNS name pointing at the VM
- Firewall: inbound 4000 (admin UI), 5500–5501 (message exchange),
5577 (OCSP proxy), 22 (SSH); outbound 80/443 to central authority
- your target services
- A subsystem name (anything for test, matches your registration for prod)
- A free RIA test account
Install & register (map, not commands)
Full commands live in the NIIS X-Road manuals — package names and admin-UI wording drift between releases, so consult the current version. The stable shape:
- Add NIIS APT repo (country-specific:
ubuntu-22.04-current-eeforee-test). apt install xroad-securityserver-ee— interactive wizard sets admin UI address + initial password.- Log in to the admin UI at
https://<vm-fqdn>:4000/. - Import RIA's
ee-testconfiguration anchor via the admin UI. - Generate a signing keypair + CSR, paste CSR into RIA's self-service portal, upload the returned cert.
- Register your subsystem (
ee-testauto-approves in minutes). - Verify: admin UI's Client tab shows your subsystem as
Registered(green).
Export the PKCS12 for XTR
XTR needs an information-system client key (not the SS's own signing key):
- Admin UI → Keys and Certificates → generate a software-token
key with usage
sign+auth. - Export as PKCS12 with a strong password.
- Move
.p12onto the host running XTR (or bake into your image).
Never commit .p12 files or passwords. Password comes to XTR
via XTR_KEYSTORE_PASSWORD env var.
Export the Security Server's TLS CA (usually required)
Real Security Servers terminate TLS with a cert issued by an
operator-managed private CA, not by a public root that ships in
the system trust store. Without pointing XTR at that CA bundle,
the mTLS handshake fails with unknown issuer.
- Admin UI → System Parameters → TLS Certificate (or copy from
/etc/xroad/ssl/). - Export as PEM.
- Move onto the XTR host at a path you'll reference below.
Skip only if your Security Server's TLS cert is issued by a public CA already in the system trust store (uncommon in real deployments).
Wire XTR
# xtr.yaml
xroad_instance: ee-test
client_data:
member_class: GOV
member_code: "70000000" # your org registry code
subsystem_code: <your-subsystem> # what you registered
security_server:
url: "https://<your-ss-fqdn>:5500/" # YOUR SS, not the central authority's
keystore_path: /app/ssl/xtr-client.p12
keystore_password_env: XTR_KEYSTORE_PASSWORD
# Point at the CA bundle exported above. Optional but almost
# always needed for real deployments.
trust_ca_path: /app/ssl/xroad-ca.pem
Both SOAP (envelope-wrapped) and REST (kind: rest DSLs) route
through this same security_server: block. Once configured, both
lanes work.
Run:
XTR_KEYSTORE_PASSWORD='<the-p12-password>' \
docker run -d --name xtr -p 8080:8080 \
-v $PWD/xtr.yaml:/app/xtr.yaml:ro \
-v $PWD/ssl:/app/ssl:ro \
-e XTR_KEYSTORE_PASSWORD \
turnerrainer/xtr:rc
Live-verify with the shipped X-Road meta-service (needs a real target subsystem to query):
curl -sX POST http://localhost:8080/xroad/listMethods \
-H 'content-type: application/json' \
-d '{"member_class":"GOV","member_code":"70000000","subsystem_code":"target-subsystem"}'
Failure modes specific to mTLS
| Symptom | Cause |
|---|---|
keystore_load_failed: parsing PKCS12 at startup | Wrong password, wrong file, or the .p12 was generated with a modern (AES) cipher OpenSSL rejects. Regenerate with -legacy or RC2/3DES on export. |
Internal("upstream request: error sending request …") or handshake errors mentioning unknown issuer | The SS's TLS cert isn't in the system trust store. Set security_server.trust_ca_path to your CA bundle PEM. |
SOAP: every call 502 upstream_http_error HTTP 401/403. REST: every call passes through as upstream 401/403. | Your subsystem isn't authorized for that service. Ask the target's owner to add you to their allow-list. |
Every call 504 upstream_timeout | Firewall — outbound 5500 to your SS's peers is blocked. |
SSL routines::wrong version number in XTR logs | security_server.url port is wrong — should be 5500 (message port), not 4000 (admin UI). |
Subsystem stuck in GLOBALERROR in admin UI | Registration hasn't propagated. ee-test: wait 15 min. Prod: contact RIA. |
Production heads-up
ee-test self-service does not scale to production. Prod requires:
- Signed contract with RIA
- Operationally-hardened SS (backups, monitoring, cert rotation)
- Paid CA cert (not the free test cert)
- Formal legal registration of the org + subsystem
Ballpark: several weeks calendar time, single-digit thousands EUR
setup. Don't plan production integration on ee-test timelines.
Failure modes
Every HTTP status XTR emits for its own errors, with the stable
error code and cause. Upstream 4xx/5xx on the REST lane behave
differently — see "REST passthrough" below.
Response shape (XTR-generated errors)
{ "error": "<stable_code>", "message": "<human message>", ...extras }
Extras depend on variant:
| Variant | Extras |
|---|---|
upstream_soap_fault | code, string, detail (SOAP lane only; detail present only when expose_soap_fault_detail: true) |
request_too_large / upstream_body_too_large | limit (byte cap exceeded) |
Status table
| Status | error | Lane | Cause |
|---|---|---|---|
404 | template_not_found | Both | No DSL matched /<group>/<service>. |
405 | method_not_allowed | Both | Inbound HTTP method doesn't match the DSL's declared method:. SOAP DSLs are POST-only; REST DSLs contract whichever method they declare. |
413 | request_too_large | Both | Body exceeded limits.max_request_bytes. |
500 | template_expansion_failed | SOAP | Handlebars render error at request time. Startup validation catches most; anything reaching here is exotic (e.g. runtime helper failure). |
500 | keystore_load_failed | Both | .p12 couldn't be read/parsed. Both lanes share the mTLS identity, so either triggers this. |
500 | internal_error | Both | Unexpected. REST DSLs also 500 with this when security_server: is missing — the doctor's fatal-rest-no-security-server catches this at deploy time. Check the log line. |
502 | upstream_http_error | SOAP | Upstream returned non-2xx AND the body wasn't a parseable SOAP Fault. REST lane does not translate — see "REST passthrough" below. |
502 | upstream_soap_fault | SOAP | Upstream returned <Fault> (on HTTP 200 OR wrapped in HTTP 5xx). Both SOAP 1.1 and 1.2 shapes handled. |
502 | upstream_xml_parse_error | SOAP | Response wasn't valid XML. Includes XXE-guard rejections (custom entities) and nesting-depth cap. |
502 | upstream_body_too_large | Both | Response exceeded limits.max_response_bytes. Connection torn down. |
504 | upstream_timeout | Both | Upstream didn't respond within limits.request_timeout_secs. |
REST passthrough — upstream 4xx / 5xx
REST DSLs are transparent proxies. When the upstream (or the Security Server) returns a non-2xx status, XTR passes it through unchanged:
- Same status code (401, 403, 404, 500, whatever).
- Same body bytes (usually the provider's JSON error shape).
- All X-Road response headers preserved — including
X-Road-Error, which distinguishes provider-service errors from Security-Server errors per X-Road REST §4.6.
This means callers see the provider's own error format on the
wire, not an XtrError envelope. Only XTR-generated failures
(the table above) use the {"error": …, "message": …} shape.
To tell "was this XTR or the upstream?":
- XTR-generated: response body is JSON matching the shape at the top of this page.
- Upstream passthrough: response body is whatever the
provider service returned; the
X-Road-Errorheader will name the X-Road component that flagged the failure (if any).
What XTR does NOT return
400— malformed SOAP request bodies are treated as "no params". Not an error.401/403— XTR has no built-in auth. Put auth in front (reverse proxy, or a Ruuter DSL layer). REST-lane upstream 401s pass through; they're the provider's, not XTR's.429— no built-in rate limiting.
See also
- Configuration — tune the limits.
- Security Server setup — for
keystore_load_failedand mTLS-specific failures. - REST passthrough — for the
X-Road-Errorheader semantics and per-header response behaviour.
Changelog
All notable changes to XTR-on-Rust will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.3.0-rc - 2026-09-10
Third minor release. Ships the X-Road REST passthrough lane (issue #5) — XTR now fronts both X-Road SOAP and X-Road REST services from the same DSL directory, over the same mTLS identity. Spec-compliant per X-Road Message Protocol for REST v1.0.4.
Fully backwards-compatible with existing 0.2.x SOAP DSLs. See
the migration reference in this book for the (small) surface of
externally-visible changes and their recovery flags.
Added
- REST passthrough lane (issue
#5). Spec-compliant
implementation of the
X-Road Message Protocol for REST v1.0.4.
DSL files declare
kind: restwith atarget:block; XTR constructs the/r1/{instance}/{class}/{code}/{subsystem}/{service_code}[{path}]URL (§4.1) with percent-encoded identifier segments (§4.2), setsX-Road-Client(§4.3) andX-Road-Id, and forwards all inbound headers (Accept, Content-Type, Cache-Control, X-Road-UserId, user-defined) unmodified (§4.3). Query params pass unmodified by default (§4.5), with an optional DSL-level allow-list. Response returns as-is with all upstream X-Road response headers (X-Road-Service,X-Road-Request-Hash,X-Road-Error, etc.) passed to the caller. Redirects pinned toPolicy::none()per §4.4. See the "REST passthrough" chapter of the mdBook for the operator-facing setup guide. security_server.trust_ca_pathconfig field. Real X-Road Security Server TLS certs are typically issued by an operator-managed private CA that isn't in the system trust store; this field points at a PEM bundle so the mTLS handshake can verify the server cert. Applies to both SOAP and REST lanes.XtrError::MethodNotAllowed(405) — emitted when the DSL-declared method doesn't match the inbound HTTP method. Applies to both SOAP (POST-only) and REST DSLs. Previously axum routed non-POST to a bare 405; the newanyroute needs an explicit variant so the wire shape ({error, message}) stays consistent.- Doctor rules for the REST lane:
fatal-rest-no-security-server,fatal-rest-ss-not-https,fatal-rest-target-fields-missing,weak-rest-identifier-charset(per spec §4.8),info-rest-lane-ready,info-rest-trust-ca-system. - Full-mTLS integration test (
tests/it_rest_mtls.rs) —rcgen+openssl(PKCS12) +tokio-rustls(client-cert verifying server) drive the productionRestLaneExecutor::newcode path end-to-end with a real handshake. Complements the plain-HTTP router tests intests/it_rest_passthrough.rs.
Changed
XRoadTemplateis now{ method, kind: TemplateKind }whereTemplateKindisSoaporRest. Existing DSL files without akind:field deserialise asSoap— full backward compatibility.POST /:group/:serviceroute widened toany /:group/:service. DSLmethod:is now enforced at the handler; mismatches return405 method_not_allowedfor both SOAP and REST kinds.Executorgaineddispatch_rest(); existingdispatch()renamed todispatch_soap(). External callers were only via the router.- The mTLS client builder is now a shared
build_mtls_client()helper used by bothSecurityServerExecutor(SOAP) andRestLaneExecutor(REST). Applies redirect policy, TLS floor, decompression posture, and CA bundle from one place.
0.2.0-rc.1 - 2026-09-06
Hotfix — Dockerfile ENTRYPOINT / CMD interaction broke the
subcommand shape documented in MIGRATION.md and
book/src/doctor.md. The recipe
docker run --rm turnerrainer/xtr:0.2.0-rc doctor was
supposed to invoke the doctor subcommand but instead tini
tried to exec a non-existent doctor binary — the tini
error surfaced was
FATAL tini (7) exec doctor failed: No such file or directory.
Discovered when local-testing the just-published
0.2.0-rc image against the operator flow.
Fixed
Dockerfile:ENTRYPOINT ["/usr/bin/tini", "--", "/app/xtr-on-rust"]CMD []. Extra args todocker runnow APPEND as argv to the binary instead of REPLACING CMD. Baredocker run <image>still boots the server (no argv → server path in main.rs).
tests/dockerfile_entrypoint.rs(new): contract test parses the Dockerfile and refuses any shape where ENTRYPOINT doesn't pin the binary. Guards against this class of regression before publish.
Everything else about 0.2.0-rc still applies — see below.
0.2.0-rc - 2026-09-06
Third release candidate. Closes the h2ck.me pre-publication audit (v1) — two Critical, four High, five Medium findings on the WSDL trust boundary. Because several fixes changed externally-visible behaviour (SOAP fault response shape most notably), this ships as a minor bump, not a patch.
Snyk-driven base-image bump debian:bookworm-slim →
debian:13.6-slim (PR #1) is included; runtime-verified with
end-to-end TLS calls to real Ariregister.
Migration from 0.1.0-rc.2
Read MIGRATION.md and run
xtr-on-rust doctor (new subcommand — see below) against
your xtr.yaml. The doctor prints a per-finding table
(FATAL / BREAK / WEAK / INFO) with exact recovery flags.
Breaking changes vs 0.1.0-rc.2
Four externally-visible changes. All have recovery flags for strict behaviour equivalence; the defaults were changed because the safer posture is a better fit for a public release.
-
SOAP fault response shape (H3). The JSON body for a
502 upstream_soap_faultno longer includes thedetailfield;string(faultstring) is capped at 200 characters;messageis shortened from"upstream returned SOAP Fault (X): Y"to"upstream returned SOAP Fault (X)".- Server logs still carry the full fault detail at
warn!level via structuredtracingfields. - Recover exact-equivalence by setting
expose_soap_fault_detail: trueinxtr.yaml.
- Server logs still carry the full fault detail at
-
xroad_protocol_versionenum validation at boot (M1). Values other than"4.0"or"4.1"now hard-fail startup with an error naming the bad value and the accepted set.- Default remains
"4.0"; vast majority of configs unaffected. Empty string, typos, or a value set by an operator experimenting with a newer protocol will now refuse to boot. - Recover by setting
xroad_protocol_versionto one of the accepted values.
- Default remains
-
X-Road sidecar identity validation (H2). If a
<wsdl>.meta.yamlsidecar declaresmember_class,member_code, orsubsystem_code, they must equal the correspondingclient_datafield inxtr.yaml. Mismatch → whole WSDL is skipped with a WARN. Emptyclient_datafields (default state) skip the check per-field so pre-onboarding operators still get all endpoints.- The shipped
xtr.yamluses placeholdermember_code: "<your-registry-code>". Any real sidecar with a real member code will now be rejected against the placeholder — setclient_databefore deploying with sidecars. - Recover by either aligning sidecar values with
config OR removing the identity fields from the sidecar
(sidecar can still override
service_code/service_url).
- The shipped
-
URL guard on WSDL upstreams (C1). Every URL discovered in a WSDL
<soap:address location=…>or metadata sidecarservice_url:override is validated at ingest. Private, loopback, link-local, CGNAT, ULA, IPv4-mapped-IPv6, and non-http(s) schemes are rejected; the offending URL is dropped from the DSL (WSDL operations then fall back to Security Server routing). Also,http://upstreams are rejected by default.- Recover by setting
wsdl.allow_http_upstream: truefor plaintext HTTP upstreams, or by adding legitimate internal hostnames towsdl.upstream_host_allowlist. Private-IP upstreams cannot be recovered — that is by design.
- Recover by setting
Behaviour changes worth calling out
Not breaking in the SemVer sense (unlikely to affect real deployments) but observable if you're at an edge:
- HTTP client no longer decompresses response bodies
(M2). Both executors now build the reqwest client with
.no_gzip(),.no_brotli(),.no_deflate(). reqwest's default WAS to decompress transparently, which would let a 16 MiB wire-body cap silently protect a much larger in-memory payload. If any upstream sendsContent-Encoding: gzipunconditionally, XTR now surfaces the compressed bytes to the XML parser and it will error. No recovery flag; if you hit this, open an issue and we'll add one. - XML depth cap 512 → 128. Real X-Road envelopes are single-digit-deep; 128 leaves ~10x headroom while keeping the debug-build test-thread stack safe.
- Schema-include filenames restricted to
[A-Za-z0-9._-]+. Symlinks under the WSDL dir are also rejected. Any WSDL corpus that ships XSDs with non-ASCII filenames or symlink-organized includes now silently drops those includes.
Added
xtr-on-rust doctorsubcommand — new. Validates the operator'sxtr.yamlagainst the audit-v1 ruleset and the known breaking-change recovery matrix. Emits FATAL / BREAK / WEAK / INFO findings; exit code 1 on any FATAL (or on WEAK under--strict). SeeMIGRATION.mdfor the recipe.MIGRATION.md— machine-readable + human-readable guide for both operators and LLMs walking through the 0.1 → 0.2 upgrade.
Security — h2ck.me audit-v1 fixes (2026-09-05)
Pre-publication audit findings from h2ck.me/projects/XTR/v1.
Two Critical, four High, five Medium — all closed on this branch.
- C1 (SSRF via WSDL upstream URL) — new
src/wsdl/url_guard.rsvalidates every URL discovered in a<soap:address location=…/>or metadata-sidecarservice_url:override. Rejectshttpby default, private / loopback / link-local / CGNAT / ULA / v4-mapped-v6 ranges, and non-http(s) schemes. New config:wsdl.allow_http_upstream,wsdl.upstream_host_allowlist. - C2 (XML bomb safety net) — added
MAX_XML_EVENTS = 100_000per-document event budget insrc/translate/xml_to_json.rs. Complements the existing depth cap (lowered from 512 → 128 for debug-stack safety) and the pre-existing custom-entity rejection. Regression test bombs → error, no expansion. - H1 (path traversal in WSDL schema-include loader) —
resolve_local_schemanow applies filename charset restriction, symlink rejection viasymlink_metadata, and canonicalisation withstarts_with(wsdl_dir)containment check. - H2 (X-Road client impersonation via sidecar) — sidecar
member_class/member_code/subsystem_codeare now validated againstclient_dataat load time. Mismatch → refuse to load the sidecar (WSDL is skipped with a WARN). - H3 (SOAP fault detail leak) —
IntoResponseforUpstreamSoapFaultnow stripsdetailand capsfaultstringat 200 chars by default. Full detail always logged atwarn!level for operators. Configexpose_soap_fault_detail: boolre-enables the raw response for internal debugging. - H4 (TLS defaults not tested) — both executors now pin
min_tls_version(TLS_1_2)explicitly on the reqwest builder. Addedtests/tls_defaults_enforced.rsintegration test (post-h2ck.me-v1 nit): spins a self-signed TLS server viarcgen+tokio-native-tlson a random port, asserts a default-trust-store reqwest client refuses the handshake AND a bypass-flagged client succeeds against the same server (counter-test guards against false-positive greens from a broken server setup). - M1 (xroad_protocol_version typos) —
AppConfig::validate()called at boot; rejects any value not in{"4.0", "4.1"}with an error naming both the bad value and the accepted set. - M2 (gzip invariant) — both executors set
.no_gzip(),.no_brotli(),.no_deflate()so the 16 MiB wire-body cap inread_boundedstays meaningful regardless of upstream content-encoding. - M3 (attribute-safe Handlebars helper) — registered
{{xml_attr foo}}helper that emits" ' < > &for interpolation into XML attribute values. Default{{foo}}is still safe in element text.
New crate dependency: url = "2" (already a transitive of
reqwest — promoted to a direct dep for url_guard.rs).
0.1.0-rc.2 - 2026-07-29
Second release candidate. Adds runtime WSDL folder-drop
(task 013), ships real Ariregister WSDL + 34 companion XSDs
under wsdl/ar/ as the canonical source of truth (yields 33
auto-generated /ar/* endpoints on every boot), and renames
all "SS" abbreviations to "Security Server" throughout code,
config, DSLs, docs, and task files.
Added — Task 013 WSDL folder-drop
wsdl_watch_dirconfig field. At boot, XTR scans<dir>/<group>/*.wsdl, parses each, and generatesDSL/<group>/<operation>.ymlperwsdl:operation.- SOAP-1.1 document/literal parser in
src/wsdl/— supports inline anonymous complexTypes, named top-level complexTypes (with lazy resolution + cycle guard),xsd:includevia a local-filesystem loader (offline discipline preserved),xsd:importskipped as framework,xsd:annotationskipped as documentation. Bail-out-on-unsupported forxsd:choice, WSDL 2.0, RPC/encoded, MIME attachments. - Per-op lenient: unresolvable input elements log WARN and skip that operation; sibling operations still generate.
- Deterministic YAML output — same WSDL always produces byte-equal bytes.
- Generated DSLs carry a marker header. Hand-written DSLs (no marker) always win on collision with a WARN log.
- Optional
<wsdl>.meta.yamlsidecar opts into X-Road envelope wrapping (member_class/member_code/subsystem_code → auto-generated<xroad:*>header block). - Generator recognises the X-Road
TURVASERVERplaceholder in<soap:address location=…/>and omitsservice:so the executor routes viasecurity_server:instead.
Added — WSDL as source of truth
wsdl/ar/— real Ariregister WSDL + 34 companion XSDs (~180 KB) vendored into the repo.xtr.yaml(new) — default config that ships with the repo.docker compose up/cargo runnow boots with 33 Ariregister endpoints live via WSDL ingestion..gitignore—/DSL/ar/*.ymlignored (regenerated per boot from the WSDL). Hand-written DSLs (likeDSL/xroad/*) stay tracked.- Removed the 4 previously hand-written Ariregister sample
DSLs (
lihtandmed_v3,detailandmed_v2,ettevottegaSeotudIsikud_v1,tegelikudKasusaajad_v2) — now auto-generated with WSDL-native param names (Estonianariregistri_koodinstead of Englishreg_code).
Changed — SS → Security Server
Renamed every "SS" abbreviation to "Security Server" across
code, configs, DSLs, book chapters, task files, comments,
and CHANGELOG entries. Rationale: the "SS" abbreviation
carries a well-known historical reputation that reads
unprofessional in a European government-infrastructure
context. See feedback_never_abbreviate_security_server.md.
SsExecutor→SecurityServerExecutorsrc/executor/ss.rs→src/executor/security_server.rsExecutor.ssfield →Executor.security_server- All prose in
book/,docs/,tasks/, comments — same. - SOAP protocol literals unchanged (
SOAP-ENV:Server,env:Serveretc are external error codes and must stay as-is).
Docs
book/src/ops/wsdl-ingestion.md— folder layout, marker semantics, override rules, X-Road sidecar convention, "no admin HTTP endpoint" rationale.book/src/dsl/adding-a-service.md— reframed as override fallback path; WSDL-drop is primary now.
Verified: 82/0/0 tests, fmt clean, clippy -D warnings clean, mdbook + linkcheck build clean, live smoke boots with 35 endpoints (33 auto-generated Ariregister + 2 hand-written X-Road samples).
0.1.0-rc.1 - 2026-07-28
First publishable release candidate. Working REST → SOAP → X-Road proxy in Rust, live-verified against public Ariregister endpoints. Everything below in this section is what ships in this tag.
Added — Post-MVP hardening sweep (2026-07-28)
Landed tasks 003, 005, 010, 011, 012, and a follow-up security sweep in a single day. Test count 29 → 51 (0 fail, 0 ignored).
Task 010 — SOAP Fault detection. HTTP 200 + <soap:Fault>
now maps to a structured 502 upstream_soap_fault with
code / string / detail top-level fields, instead of silently
being translated as a successful response. Handles SOAP 1.1 and
1.2 including namespace-prefixed variants and xml:lang-tagged
Reason elements.
Task 011 — Request/response size caps + timeout config. New
limits: config section (max_request_bytes 1 MiB,
max_response_bytes 16 MiB, request_timeout_secs 30). Inbound
overflow → 413 request_too_large; upstream overflow → 502
upstream_body_too_large with the connection torn down
immediately. Outbound responses read chunk-by-chunk via a new
read_bounded helper — bounded memory per request.
Task 012 — JSON type coercion. Bare integer leaves become
Value::Number; true/false become Value::Bool. Deliberate
non-goals with enforcing tests: no float coercion (precision loss
on "3.10"), no leading-zero coercion ("007" stays string —
those are opaque IDs), no case-insensitive booleans, i64
overflow keeps raw string, attributed-leaf #text stays string.
Task 005 — Explicit X-Road protocol version in config. New
xroad_protocol_version: "4.0" config field exposed as
{{generate.protocol_version}} in the Handlebars auto-context.
The two shipped X-Road DSL samples (listMethods,
allowedMethods) migrated to the auto-context variable —
protocol-version changes now require a single config line update
instead of touching every DSL.
Task 003 — Content-Type + charset on outbound calls. Closed
as landed with task 002 Phase D — both executors already set
text/xml; charset=utf-8; existing integration test already
captured + asserted it. Marker added to done/.
Security sweep
quick-xml 0.36 → 0.41. cargo audit flagged two
high-severity DoS advisories (RUSTSEC-2026-0194 quadratic on
duplicate attribute names, RUSTSEC-2026-0195 unbounded
namespace-declaration allocation) — both fixed in 0.41. Both
directly relevant since XTR parses untrusted upstream XML on
every request; size caps alone don't help against the quadratic
runtime.
XXE guard. quick-xml 0.41 introduced Event::GeneralRef
for entity references outside the XML-predefined set. Character
references (&#nnn;, &#xhh;) resolve to Unicode codepoints
via a new decode_char_ref helper. Custom entities
( , ©) are rejected with an explicit
XmlParseError mentioning XXE risk — accepting them would
require a DOCTYPE, which is the XXE attack surface.
Nesting-depth cap (MAX_NESTING_DEPTH = 512) on
parse_children. Prior state: unbounded recursion — a document
with hundreds of thousands of <a><a><a>… levels blew the
stack. Real envelopes rarely exceed 10 levels; cap gives ~50x
headroom.
Regression coverage added: Handlebars single-pass re-render safety, malformed-body handling (7 shapes), percent-encoded-slash path traversal, XML nesting cap, hex character ref, custom entity XXE guard.
Final audit posture: cargo audit 0 advisories, cargo deny check green on advisories/bans/licenses/sources.
Added — Task 002 MVP (v0.1.0-rc.2 candidate)
Working REST → SOAP → X-Road proxy per DESIGN.md §8. Implements the module tree, HTTP surface, DSL loader, Handlebars expansion, executor (plain + mTLS), XML → JSON translation, auto-generated OpenAPI, and integration tests. 12 of the 17 JVM XTR bugs from DESIGN.md §7 fixed:
#1 subsystem_code (correctly spelled)
#2 no @Value on statics — instance-field config
#3 Handlebars: single-pass render with merged context
#5 xroad:client element built correctly (no literal %s)
#6 system trust store (no trust-all X509TrustManager)
#7 response exposes both {body, headers}
#8 route pattern is /:group/:service (not wildcard)
#9 structured error responses ({error, message} + proper status)
#10 /health endpoint
#13 port 8080 everywhere (no 9010/9020/8080 confusion)
#14 OpenAPI param type "string" (not "String")
#15 no Towarsd typo
#16 keystore password from env var, never a default
Modules added (src/*)
- config/ — AppConfig with load_or_default (--config / XTR_CONFIG / ./xtr.yaml search path)
- dsl/ — XRoadTemplate, ServiceMap, loader::load_all, handlebars::expand (unified single-pass render)
- executor/ — PlainExecutor (system trust), SecurityServerExecutor (mTLS via PKCS12 identity), Executor::dispatch
- translate/ — xml_to_json::translate_soap emits {body, headers} with namespaces preserved, attributes as @-keys, repeats as arrays
- router/ — axum routes + AppState wiring
- openapi.rs — build_spec walks ServiceMap, emits stable OpenAPI 3.1 output
- error.rs — XtrError with IntoResponse
- main.rs — tokio + config load + assemble + serve
Tests (29 pass, 0 fail, 0 ignored)
- 5 loader (walk, missing path, extensions, non-YAML skip, parse error)
- 6 handlebars (allow-list filter, drop non-allowlist, auto context, generate.client shape, generate.uuid validity, single-pass regression guard)
- 8 xml_to_json (body/headers extraction with namespace prefixes, UTF-8 Estonian chars, XML entity refs, repeat → array, attributes → @-keys, empty → null, malformed error, namespaced element names)
- 5 openapi (empty map, one service, "string" type regression, requestBody.required toggle, response schema shape)
- 5 integration (health, /api lists loaded services, end-to-end with mock upstream capturing outbound Content-Type + body, unknown-service 404, params filter)
DSL samples
- DSL/samples/ar/{lihtandmed_v3, detailandmed_v2, ettevottegaSeotudIsikud_v1, tegelikudKasusaajad_v2}.yml
- DSL/samples/xroad/{listMethods, allowedMethods}.yml
Imported verbatim from buerokratt/XTR. Live smoke test loads all six into GET /api as OpenAPI operations.
Docs
- book/src/dsl/format.md — new. DSL format, params allow-list, service field semantics, Handlebars auto-context, response shape, end-to-end example.
- book/src/getting-started/run-locally.md — refreshed with real /health + /api output; shipped-sample invocation recipe.
- book/src/getting-started/automated-tests.md — baseline updated to 29/0/0.
- book/src/SUMMARY.md — new DSL section.
Added — task epic system + follow-ups (earlier this cycle)
docs/DESIGN.md— the domain design derived from a direct read of the original buerokratt/XTR. Documents the JVM XTR's public surface, DSL format, config, request lifecycle, and 17 known bugs. Defines the XTR-on-Rust MVP scope (v0.1.0-rc.2), correctness fixes applied, non-goals, crate layout, roadmap to v1.0. Now includes §2.7 X-Road protocol context — the domain gotchas beyond mechanical translation, each cross-linked to a follow-up task.tasks/backlog/002-implement-mvp-v0.1.0-rc.2.md— next task on the roadmap: implement DESIGN.md §8 (the MVP slice).- Task epic system in
tasks/backlog/epic-*/. Three epics filed after the task 001 review, each with its own README:epic-xroad-protocol-compliance/— 3 open tasks (003, 004, 005: Content-Type, response requestHash verification, explicit protocol version in config).epic-operator-onboarding/— 1 open task (006: X-Road cert acquisition + keystore setup docs).epic-testing-infrastructure/— 2 open tasks (007, 008: mock X-Road Security Server for CI, UTF-8 / Estonian charset round-trip test).
- Empty
mainbranch — orphan commit with a README redirecting todev. Reserved for the futurev1.0.0.
Changed
HANDOFF.md— roadmap section rewritten. Task 001 marked done; task 002 up next. New "Open backlog" table listing top-level tasks + epics.README.mdStatus section now surfaces the domain design.book/src/introduction.mdfirst paragraph points atdocs/DESIGN.md.STANDARDS.md§13 extended — task tracking now allows optional epic subdirectories (tasks/backlog/epic-<slug>/mirrored todone/on completion). New "Linking rule" clause: every commit must reference at least one task file.
Task tracking
- Task 001 (deep-dive) moved from
backlog/todone/with a Landed note.
0.1.0 - 2026-07-28
Initial scaffold. Standards-compliant repo skeleton — no shipped
domain functionality yet. Every rule from Ruuter-on-Rust's
STANDARDS.md applied from day one.
Added
- Rust binary crate (
xtr-on-rust) — placeholdermain.rsthat prints a scaffold notice and exits. MSRV pinned to 1.88. - CI workflows (
.github/workflows/):tests.yml— matrix onubuntu-latest+ubuntu-24.04-arm,cargo fmt --check+cargo clippy --all-targets -- -D warnings+cargo test --release --no-fail-fast.security.yml—cargo audit --deny warnings+cargo deny check allon push/PR/daily cron.publish.yml— multi-arch (linux/amd64+linux/arm64) Docker Hub + GHCR publish on release tag orworkflow_dispatch. Cosign keyless signing, SPDX SBOM, in-toto provenance, Trivy vulnerability scan gates signing, smoke test both platforms. Supports SemVer pre-release tags with maturity-scoped moving tag (:rc,:beta,:alpha).docs.yml— mdBook build + GitHub Pages deploy on push tomain.
- Supply-chain configs:
deny.toml— Apache-2.0-compatible license allow-list, banned wildcards, crates.io-only sources..cargo/audit.toml— empty exceptions stub (mirror any entries here intodeny.toml's[advisories].ignore).
- Hardened container:
Dockerfile— multi-stagerust:1.88-slim→debian:bookworm-slim, non-root uid 1000,tinias PID 1.docker-compose.yml—read_only: true,cap_drop: [ALL],no-new-privileges: true, CPU + memory limits,HEALTHCHECK.
- Documentation scaffold (mdBook at
book/):- Getting Started chapters: Prerequisites → Run it locally → Watch the automated tests pass → What to read next.
- Ops chapter: Docker (with placeholder cosign verify recipe).
- Light-on-white theme (
book/theme/custom.css).
- STANDARDS.md — the reference document capturing every rule
this project inherits. Reusable by any
<Product>-on-Rustsibling. - SECURITY.md — private disclosure recipe, response SLA, supported versions, CI supply-chain posture inventory.
- HANDOFF.md — entry point for the next contributor.
tasks/backlog/001-domain-deep-dive-original-xtr.md— first task on the roadmap: analyse the originalbuerokratt/XTRand define XTR-on-Rust's domain surface.
Migrating XTR
Two migration guides on this page. The 0.2 → 0.3 section is
short (issue #5 is additive, 0.2.x SOAP DSLs continue to work
unchanged); the 0.1 → 0.2 section is the original audit-v1
migration and remains here as a canonical reference.
0.2.0-rc.1 → 0.3.0-rc
TL;DR — additive release. Existing SOAP DSLs work unchanged. Two externally-visible changes worth reviewing before deploy.
Externally-visible changes
-
DSL
method:is now enforced on both kinds. Previously, non-POST requests to a SOAP DSL were routed to axum's built-in 405 (because the route wasPOST /:group/:svc). The router is nowany /:group/:svc(needed for REST DSLs that declaremethod: GET|PUT|DELETE), so method mismatches surface as XTR's own structured405 method_not_allowedresponse.- Symptom of the change:
GET /some-soap-endpointnow returns{"error":"method_not_allowed","message":"..."}with405status. Previously the same request got axum's bare405 Method Not Allowedwith no body. - No recovery flag needed — no SOAP DSL should be receiving GETs in practice. If yours does, add a REST DSL for the GET path or fix the caller.
- Symptom of the change:
-
New optional
security_server.trust_ca_pathconfig field. Real X-Road Security Server TLS certs are typically issued by an operator-managed private CA. When the CA isn't in the system trust store, the mTLS handshake fails withunknown issuer. Pointtrust_ca_pathat the CA bundle PEM.- No recovery flag needed — the field is optional; absent means "use system trust store" (unchanged 0.2 behaviour).
- Applies to both SOAP and REST lanes.
Added (opt-in, no impact if unused)
- REST passthrough lane (issue #5). DSL files may declare
kind: restand act as X-Road REST endpoints. See the "REST passthrough" chapter of the book for the operator-facing setup guide, orbook/src/rest-passthrough.mdin-repo. - Doctor rules for REST DSLs:
fatal-rest-no-security-server,fatal-rest-ss-not-https,fatal-rest-target-fields-missing,weak-rest-identifier-charset, plus two informational codes. Only fire when a REST DSL is loaded. XtrError::MethodNotAllowed— new error variant. Wire shape{"error":"method_not_allowed","message":"..."}with status405.
Doctor recipe
docker run --rm \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
-v "$(pwd)/DSL:/app/DSL:ro" \
turnerrainer/xtr:0.3.0-rc doctor --strict
- exit 0 — safe to deploy as-is.
- exit 1 with FATAL — the service will not boot or a critical property is off; fix before deploying.
- exit 1 under
--strict— everything works, but a stronger security posture is available.
Mount the DSL tree too — several REST-lane rules only fire when the doctor can see the loaded DSL files.
Prompt template for LLM-assisted upgrade
I'm upgrading XTR from 0.2.0-rc.1 to 0.3.0-rc. My current
xtr.yaml is:
<paste xtr.yaml>
My DSL/ tree contains:
<paste `ls -R DSL/` output>
Please:
1. Tell me if the upgrade is safe (any SOAP DSL that receives
non-POST requests? Any DSL kind change needed?).
2. Suggest whether I should set security_server.trust_ca_path.
3. Show the exact xtr.yaml diff I need.
Facts I want you to use:
- 0.3.0-rc adds a REST passthrough lane (kind: rest DSLs).
- SOAP DSLs work unchanged.
- Method mismatch on SOAP DSLs now returns structured 405.
- security_server.trust_ca_path is new + optional.
Migrating XTR from 0.1.0-rc.2 → 0.2.0-rc
Audience: operators upgrading a live deployment, and LLMs
assisting them.
Fastest path: run xtr-on-rust doctor
against your xtr.yaml, fix every FATAL / BREAK finding it
prints, deploy. Everything else on this page is what the
doctor knows, written out longform for humans.
TL;DR
docker run --rm \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
turnerrainer/xtr:0.2.0-rc doctor --strict
- exit 0 — safe to deploy as-is.
- exit 1 with FATAL — the service will not boot or a critical property is off; fix before deploying.
- exit 1 under
--strict— everything works, but a stronger security posture is available. Address WEAK findings on your schedule.
Machine-readable variant for CI / LLM pipelines:
docker run --rm \
-v "$(pwd)/xtr.yaml:/app/xtr.yaml:ro" \
turnerrainer/xtr:0.2.0-rc doctor --format json
Emits an array of {severity, code, field, headline, rationale, recovery}
objects. The code field is stable across releases — pin
your CI rules to those, not to headlines.
The doctor recipe
xtr-on-rust doctor is a new subcommand shipped in the same
image as the server. It reads xtr.yaml the same way the
server does (--config flag → XTR_CONFIG env → ./xtr.yaml
→ built-in defaults) and emits one finding per issue in
one of four severities:
| Severity | Meaning | Exit code |
|---|---|---|
| FATAL | Server will not boot with this config, or a critical property is broken. | 1 |
| BREAK | Behaviour changed vs 0.1.0-rc.2 and this config lands on the losing side. Set the named recovery flag if you need bit-for-bit equivalence. | 1 (currently no BREAK-only checks; reserved for future minor bumps) |
| WEAK | Currently works, but a stronger posture is available. Recommended for public deployments. | 0 normally, 1 with --strict |
| INFO | Positive observations (successful checks, resource ceilings). | 0 |
Flags
xtr-on-rust doctor [flags]
--config PATH Explicit xtr.yaml path (else default search order)
--format text|json Output format (default text)
--strict Promote WEAK findings to exit code 1
Sample output
Against the shipped xtr.yaml (Ariregister demo posture):
xtr-on-rust doctor — v0.2.0-rc
------------------------------------------------------------
WEAK (1)
• [weak-wsdl-allowlist-empty] wsdl.upstream_host_allowlist is empty
field: wsdl.upstream_host_allowlist
why: Without a pinned host list, a WSDL that resolves
why: an attacker-controlled hostname to a metadata IP
why: still slips past the url_guard's literal-IP check.
why: Pinning the set of upstreams closes the DNS lane.
recover:
xtr.yaml:
wsdl:
upstream_host_allowlist:
- ariregxmlv6.rik.ee
- jvis.envir.ee
INFO (3)
• [info-config-xroad-protocol-ok] ...
• [info-config-source] ...
• [info-limits-summary] ...
------------------------------------------------------------
Summary: 0 FATAL, 0 BREAK, 1 WEAK, 3 INFO
Exit 0 (safe to deploy) — one WEAK finding you may want to address on your own schedule.
Breaking changes reference
Each subsection: what changed, who's affected, how to detect it in your config or in your consumers' behaviour, and the one-line recovery flag.
1. SOAP fault response shape
Change: JSON body for 502 upstream_soap_fault now omits
the detail field by default and caps string (faultstring)
at 200 characters. The message field is shortened.
Before (0.1.0-rc.2):
{
"error": "upstream_soap_fault",
"message": "upstream returned SOAP Fault (Server): DB error: connect to postgres://admin:PASS@10.0.0.5/prod failed",
"code": "Server",
"string": "DB error: connect to postgres://admin:PASS@10.0.0.5/prod failed",
"detail": { "stack": "at internal.jsp:42" }
}
After (0.2.0-rc, default):
{
"error": "upstream_soap_fault",
"message": "upstream returned SOAP Fault (Server)",
"code": "Server",
"string": "DB error: connect to postgres://admin:PASS@10.0.0.5/prod fai… (truncated)"
}
The server logs still carry the full detail at warn! level
via structured tracing fields (fault_code, fault_string,
fault_detail).
Who's affected:
- Any REST consumer reading
response.detail— that key is now absent (JSONundefined, notnull). - Anyone with an alert that regexes the
messagefield for the old"(<code>): <string>"shape. - Anyone whose observability was reading the full
faultstringfor parsing.
Detect in your consumers:
# From a captured 502 body, check whether `detail` is present
jq 'has("detail")' captured.json
# → true = you were reading detail. Set the recovery flag or
# move the parsing to server logs (fault_detail field).
Recovery (bit-for-bit equivalence with 0.1.0-rc.2):
# xtr.yaml
expose_soap_fault_detail: true
Doctor code: weak-error-expose-soap-fault-detail (flagged
when the flag is true).
2. xroad_protocol_version enum validation
Change: values other than "4.0" or "4.1" now hard-fail
startup with an error naming the bad value and the accepted
set. Empty string, typos, or a value you set experimentally
will now refuse to boot.
Before: any string accepted, injected into every
<xroad:protocolVersion> element. Requests failed at the
Security Server with a cryptic error.
After:
Error: xroad_protocol_version '9.9' is not one of the accepted values ["4.0", "4.1"]
Who's affected: only operators who typo'd this field or set it to something outside the accepted set.
Detect:
grep "^xroad_protocol_version:" xtr.yaml
# Value must be exactly "4.0" or "4.1" (quoted).
Recovery:
xroad_protocol_version: "4.0" # or "4.1"
Doctor code: fatal-config-xroad-protocol-invalid.
3. X-Road sidecar identity validation
Change: <wsdl>.meta.yaml sidecars that declare
member_class, member_code, or subsystem_code must match
the corresponding field under client_data in xtr.yaml.
Any mismatch → whole WSDL is skipped with a WARN log.
Empty client_data fields (default state) skip the check
per-field, so an operator who hasn't onboarded to X-Road yet
still gets all their endpoints — but see the WEAK finding
about empty client_data.
Who's affected:
- The shipped
xtr.yamlused to ship placeholder text ("<your-registry-code>"). If you're upgrading and left the placeholder in, sidecar identity validation will reject every real sidecar. - Multi-tenant deployments where a shared WSDL mount contains sidecars claiming different X-Road identities.
Detect:
# 1. Real placeholders sitting in prod config:
grep -E '"<[^>]+>"' xtr.yaml
# → any output = FATAL under doctor
# 2. Existing sidecars naming a different identity than config:
for meta in wsdl/**/*.meta.yaml; do
echo "=== $meta ==="
grep -E "member_(class|code)|subsystem_code" "$meta"
done
# Compare against xtr.yaml's client_data.
Recovery:
Option A — align sidecar values with config:
# xtr.yaml
client_data:
member_class: GOV
member_code: "70000000"
subsystem_code: "myservice"
Then verify every sidecar declares the same triple.
Option B — remove identity fields from sidecars; keep only
overrides that make sense per-endpoint (service_code,
service_url):
# wsdl/vendor/foo.meta.yaml
service_code: fooOperation
service_url: https://foo-vendor.example/soap
Doctor codes:
fatal-client-data-placeholder-member_code,fatal-client-data-placeholder-subsystem_code,weak-client-data-empty(all three identity fields empty).
4. URL guard on WSDL upstreams
Change: every URL discovered in a WSDL <soap:address location=…> or metadata sidecar service_url: override
is validated at ingest. Rejects:
- private / loopback / link-local / CGNAT / ULA IP ranges
- IPv4-mapped-IPv6 (
::ffff:169.254.169.254— the metadata bypass) - non-http(s) schemes (
file://,gopher://, etc.) - plain
http://unlesswsdl.allow_http_upstream: true
Rejected URLs are dropped from the DSL — the WSDL still loads and its operations still generate, but they'll need the Security Server route at request time.
Who's affected:
- Any operator whose WSDL corpus points at a private-IP
upstream inside their network (e.g.
http://10.0.0.5/). - Anyone using plain HTTP upstreams (typically local development mocks).
Detect:
# Scan every WSDL for upstream URLs that will be rejected
grep -rEho '<soap:address location="[^"]+"' wsdl/ \
| sed 's/^.*location="//;s/"$//' \
| while read url; do
case "$url" in
http://10.*|http://192.168.*|http://172.1[6-9].*|http://172.2*.*|http://172.3[0-1].*)
echo "PRIVATE $url" ;;
http://169.254.*)
echo "METADATA $url" ;;
http://*) echo "PLAIN-HTTP $url" ;;
*) : ;; # https or other schemes — case-by-case
esac
done
Recovery:
For legitimate internal-network upstreams:
# xtr.yaml
wsdl:
allow_http_upstream: true # allow plaintext HTTP
upstream_host_allowlist: # pin to internal hosts
- internal-soap.example
Private IPs cannot be recovered — that's by design (SSRF
guard). If your upstream lives on 10.0.0.5, front it with
a proxy on a routable hostname.
Doctor codes: weak-wsdl-allow-http, weak-wsdl-allowlist-empty.
Non-breaking but observable
HTTP client no longer decompresses (M2)
Both executors now build reqwest with .no_gzip(),
.no_brotli(), .no_deflate(). reqwest's default WAS to
decompress transparently.
If any upstream sends Content-Encoding: gzip
unconditionally, XTR now hands the compressed bytes to the
XML parser and it will error with upstream_xml_parse_error.
Detect (against a mock or in staging):
curl -sv -X POST http://localhost:8080/<group>/<service> \
-H content-type:application/json -d '{}' 2>&1 \
| grep -Ei "content-encoding|upstream_xml_parse_error"
No recovery flag yet. If you hit this, open an issue.
XML depth cap 512 → 128
SOAP envelopes rarely nest > 20 levels; 128 leaves ~10x
headroom. If your particular upstream nests deeper than 128,
XTR now returns upstream_xml_parse_error with an
"XML nesting depth exceeded (128)" message.
Schema-include filename restriction
WSDL <xsd:include schemaLocation="…"/> filenames now
restricted to [A-Za-z0-9._-]+. Symlinks under the WSDL
directory are rejected outright. Filenames outside the
charset or symlink-organised XSDs will be silently dropped
from parsing.
Detect:
find wsdl -name "*.xsd" -type l -print # symlinks under wsdl/
find wsdl -type f -name "*.xsd" | grep -vE '^[/A-Za-z0-9._-]+$'
Doctor rule catalogue
Every rule the doctor knows, by code. Codes are stable
across the 0.2.x line — pin CI to these, not to headlines.
| Code | Severity | Fires when |
|---|---|---|
fatal-config-xroad-protocol-invalid | FATAL | xroad_protocol_version outside {"4.0", "4.1"} |
fatal-client-data-placeholder-member_code | FATAL | client_data.member_code contains < or > |
fatal-client-data-placeholder-subsystem_code | FATAL | client_data.subsystem_code contains < or > |
fatal-keystore-env-missing | FATAL | security_server configured, env var absent |
fatal-keystore-env-empty | FATAL | security_server configured, env var set to empty string |
fatal-keystore-file-missing | FATAL | security_server.keystore_path doesn't exist on disk |
weak-wsdl-allow-http | WEAK | wsdl.allow_http_upstream: true |
weak-wsdl-allowlist-empty | WEAK | wsdl.upstream_host_allowlist: [] and wsdl_watch_dir is set |
weak-error-expose-soap-fault-detail | WEAK | expose_soap_fault_detail: true |
weak-client-data-empty | WEAK | all three client_data.* fields empty |
weak-limits-request-too-generous | WEAK | limits.max_request_bytes > 16 MiB |
weak-limits-response-too-generous | WEAK | limits.max_response_bytes > 128 MiB |
weak-limits-timeout-too-long | WEAK | limits.request_timeout_secs > 300 |
weak-paths-dsl-missing | WEAK | dsl_path doesn't exist on disk |
weak-paths-wsdl-watch-missing | WEAK | wsdl_watch_dir set but doesn't exist |
info-* | INFO | positive observations; never affects exit code |
For LLM assistants helping an operator upgrade
Copy-paste this into your Claude / GPT session:
I'm upgrading
turnerrainer/xtrfrom0.1.0-rc.2to0.2.0-rc. Please help me plan the upgrade.
- Here's my current
xtr.yaml:<paste the whole file>- Here's my
docker-compose.yml/Deploymentmanifest:<paste>- Here's what my downstream consumers do with the JSON response body from
POST /:group/:service:
<describe consumers, e.g. "log the entire body via Filebeat, then Kibana queries look for body.detail.stack">Read
MIGRATION.mdat the root of theturnerrainer/XTRrepo. Then:
- Predict what
xtr-on-rust doctor --strictwill report against myxtr.yaml. List FATAL / BREAK / WEAK codes.- For each finding, tell me the minimum-change diff to fix it AND the recovery-flag alternative that preserves
0.1.0-rc.2behaviour.- For breaking change #1 (SOAP fault shape), tell me whether my consumers as described will break, and give me either the recovery flag OR a jq/Elasticsearch migration query I need to run.
- Give me a
docker runcommand to run the actual doctor against the file to verify your prediction.
For CI pipelines
Recommended pre-deploy gate:
# .github/workflows/xtr-config-gate.yml
name: XTR config gate
on:
pull_request:
paths:
- 'xtr.yaml'
- 'wsdl/**'
jobs:
doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: XTR doctor
run: |
docker run --rm \
-v "$PWD/xtr.yaml:/app/xtr.yaml:ro" \
-v "$PWD/wsdl:/app/wsdl:ro" \
turnerrainer/xtr:0.2.0-rc doctor --strict --format json \
| tee doctor.json
- name: Assert no FATAL
run: |
fatal=$(jq '[.[] | select(.severity=="FATAL")] | length' doctor.json)
if [ "$fatal" -gt 0 ]; then
echo "::error::doctor found $fatal FATAL findings"
jq '.[] | select(.severity=="FATAL")' doctor.json
exit 1
fi
Add --strict to the run command to gate on WEAK findings
too when your team is ready for that posture.
Rollback
Every change on this branch is contained in the container
image turnerrainer/xtr:0.2.0-rc. The prior image
turnerrainer/xtr:0.1.0-rc.2 (digest
sha256:61d441d00f75) remains published on Docker Hub +
ghcr.io and is still cosign-signed. Roll back with a pod-spec
image swap; no data migration is involved (XTR is stateless).