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

  1. Getting started — install, run, add a SOAP service, add a REST service
  2. Configurationxtr.yaml reference (every field, every default)
  3. WSDL folder-drop — auto-generate SOAP DSLs from WSDLs
  4. REST passthrough — REST-lane DSL reference (wire protocol, headers, security posture)
  5. Security Server setup — mTLS keystore + trust CA (required for both lanes' X-Road routing)
  6. Doctor & migration — validate xtr.yaml before deploy
  7. 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)

  1. Drop the WSDL under wsdl/<group>/<subsystem>/:
    wsdl/my-vendor/my-service/api.wsdl
    
  2. Create a sidecar api.meta.yaml next to it with the X-Road identity:
    member_class: GOV
    member_code: "70000123"
    subsystem_code: my-service
    
  3. Restart XTR. Every wsdl:operation in the WSDL becomes a POST /my-vendor/my-service-<operation> endpoint.

Full details: WSDL folder-drop.

Hand-written SOAP DSL (fallback)

  1. 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>
    
  2. Restart XTR. Available at POST /example/lookup.

Field-by-field:

FieldPurpose
kind: soapOptional. 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:

PlaceholderRenders
{{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.

  1. 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
    
  2. 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 (42Value::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:

  1. --config <path> CLI flag
  2. XTR_CONFIG=<path> env var
  3. ./xtr.yaml or ./xtr.yml in the working directory
  4. 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

FieldDefaultLanePurpose
dsl_path./DSLBothDirectory walked for *.yml / *.yaml DSL files.
port8080BothHTTP listen port.
limits.max_request_bytes1048576 (1 MiB)BothInbound REST body cap. Overflow → 413.
limits.max_response_bytes16777216 (16 MiB)BothUpstream response cap. Overflow → 502, connection torn down.
limits.request_timeout_secs30BothPer outbound request. Timeout → 504.

X-Road identity

FieldDefaultLanePurpose
xroad_instanceee-testBothSOAP: {{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""BothSOAP: <xroad:client>. REST: X-Road-Client. Empty skips sidecar identity check.
client_data.member_code""BothSame as above.
client_data.subsystem_code""BothCorrectly spelled (fixes JVM bug #1). Same as above.

WSDL folder-drop (SOAP only)

FieldDefaultLanePurpose
wsdl_watch_dirabsentSOAPFeature off when unset. See WSDL folder-drop.
wsdl.allow_http_upstreamfalseSOAPWhen false, <soap:address> URLs must be https://. Set true only for local test setups. Audit-v1 C1.
wsdl.upstream_host_allowlist[]SOAPOptional. When non-empty, every WSDL upstream host must appear on the list. Closes the DNS-rebinding lane. Audit-v1 C1.

SOAP fault exposure

FieldDefaultLanePurpose
expose_soap_fault_detailfalseSOAPWhen 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)

FieldDefaultLanePurpose
security_serverabsentBothRequired by REST DSLs. Required by SOAP DSLs that omit service:. Absent → those DSLs error at request time.
security_server.urlrequired if section setBothURL 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_pathrequired if section setBothPKCS12 identity file for mTLS. Same file serves both lanes.
security_server.keystore_password_envXTR_KEYSTORE_PASSWORDBothEnv var name to read the PKCS12 password from. Never a default value — fixes JVM bug #16.
security_server.trust_ca_pathabsentBothOptional 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

VariablePurpose
XTR_CONFIGAlternative path to xtr.yaml (bypasses cwd search).
XTR_KEYSTORE_PASSWORDPassword for the PKCS12 identity. Required whenever security_server: is set.
RUST_LOGtracing_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

SeverityMeaningExit code
FATALServer will not boot with this config.1
BREAKBehaviour 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
WEAKCurrently works, but a stronger posture is available.0 normally; 1 under --strict
INFOPositive 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)

SeverityCodeFires when
FATALfatal-config-xroad-protocol-invalidxroad_protocol_version isn't "4.0" or "4.1" (audit-v1 M1).
INFOinfo-config-xroad-protocol-okProtocol version accepted.
INFOinfo-config-sourceWhich file was loaded (--config / XTR_CONFIG / ./xtr.yaml).
INFOinfo-config-defaultsNo config file found; using built-in defaults.
INFOinfo-limits-summarySnapshot of resource ceilings that will apply.

X-Road identity (SOAP + REST)

SeverityCodeFires when
FATALfatal-client-data-placeholder-member_codeclient_data.member_code still holds <placeholder> text.
FATALfatal-client-data-placeholder-subsystem_codeSame for subsystem_code.
WEAKweak-client-data-emptyAll three client_data.* fields empty — envelope/header will have no identity.

Security Server + mTLS (used by REST + Security-Server-routed SOAP)

SeverityCodeFires when
FATALfatal-keystore-env-missingsecurity_server: is set but its keystore_password_env variable is unset.
FATALfatal-keystore-env-emptyEnv var is set but empty.
FATALfatal-keystore-file-missingkeystore_path doesn't exist on disk.
INFOinfo-keystore-env-presentEnv var resolved.

WSDL folder-drop (SOAP lane)

SeverityCodeFires when
WEAKweak-wsdl-allow-httpwsdl.allow_http_upstream: true (audit-v1 C1).
WEAKweak-wsdl-allowlist-emptywsdl_watch_dir set but wsdl.upstream_host_allowlist is [].
INFOinfo-wsdl-allowlist-pinnedNon-empty allowlist.

SOAP fault exposure

SeverityCodeFires when
WEAKweak-error-expose-soap-fault-detailexpose_soap_fault_detail: true (audit-v1 H3).

REST lane (issue #5)

SeverityCodeFires when
FATALfatal-rest-no-security-serverREST DSL(s) loaded but security_server: is unset. Every REST request would 500.
FATALfatal-rest-ss-not-httpssecurity_server.url doesn't start with https:// (X-Road REST §4.7).
FATALfatal-rest-target-fields-missingA REST DSL has empty target.member_class / member_code / subsystem_code / service_code.
WEAKweak-rest-identifier-charsetA REST DSL's target identifiers contain characters outside spec §4.8 (A-Za-z0-9'()+,-.=?).
INFOinfo-rest-lane-readyREST DSL(s) present and SS configured.
INFOinfo-rest-trust-ca-systemUsing system trust store for SS TLS — flag reminder to set trust_ca_path if the SS uses a private CA.

Resource limits

SeverityCodeFires when
WEAKweak-limits-request-too-generousmax_request_bytes > 16 MiB.
WEAKweak-limits-response-too-generousmax_response_bytes > 128 MiB.
WEAKweak-limits-timeout-too-longrequest_timeout_secs > 300.

Path checks

SeverityCodeFires when
WEAKweak-paths-dsl-missingdsl_path doesn't exist.
WEAKweak-paths-wsdl-watch-missingwsdl_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":  "..."
}
# .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 locationGenerated DSLURL
wsdl/foo/api.wsdlDSL/foo/<op>.ymlPOST /foo/<op>
wsdl/foo/bar/N.wsdlDSL/foo/bar-<op>.ymlPOST /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 fileAction
Doesn't existWrite
Has markerOverwrite
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:

SubsystemsEndpointsBoot
Ariregister only33~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

FieldRequiredPurpose
kind: restSelects the REST lane. Omitted → SOAP.
method:HTTP method the DSL contracts. Inbound mismatch → 405.
target.member_classProvider identity — from RIA registration.
target.member_codeProvider identity.
target.subsystem_codeProvider identity.
target.service_codeThe service code registered under the subsystem.
target.pathAppended after {service_code}. Leading slash optional. Include any versioning (/v1/…).
allowed_query_paramsAbsent → forward all (spec §4.5 default). [] → drop all. Non-empty → allow-list.
forward_bodyDefault 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:

HeaderDirectionBehaviour
X-Road-ClientoutboundMandatory (§4.3). XTR always sets this from config. Inbound values are stripped — callers cannot spoof identity.
X-Road-IdoutboundIf caller sets one, forwarded verbatim. Else XTR generates a UUID (§4.3).
X-Road-UserIdoutboundForwarded verbatim from caller. XTR never synthesises it.
AcceptoutboundForwarded unmodified (§4.3).
Content-TypeoutboundForwarded unmodified (§4.3).
Cache-Control, PragmaoutboundForwarded unmodified (§4.3).
User-defined (X-Custom-* etc.)outboundForwarded unmodified (§4.3).
HostoutboundStripped — reqwest sets it from the SS URL.
Hop-by-hop (Connection, TE, Upgrade, Transfer-Encoding, Keep-Alive, Proxy-*, Trailer)outboundStripped.
X-Road-Service, X-Road-Request-Hash, X-Road-Request-Id, X-Road-Error, X-Road-Idinbound (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 goalYou need
Call Ariregister (shipped demo)Nothing — works out of the box
Any other X-Road test serviceAn ee-test Security Server (~half a day, free)
Real Estonian production serviceFull 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:

  1. Add NIIS APT repo (country-specific: ubuntu-22.04-current-ee for ee-test).
  2. apt install xroad-securityserver-ee — interactive wizard sets admin UI address + initial password.
  3. Log in to the admin UI at https://<vm-fqdn>:4000/.
  4. Import RIA's ee-test configuration anchor via the admin UI.
  5. Generate a signing keypair + CSR, paste CSR into RIA's self-service portal, upload the returned cert.
  6. Register your subsystem (ee-test auto-approves in minutes).
  7. 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):

  1. Admin UI → Keys and Certificates → generate a software-token key with usage sign + auth.
  2. Export as PKCS12 with a strong password.
  3. Move .p12 onto 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.

  1. Admin UI → System Parameters → TLS Certificate (or copy from /etc/xroad/ssl/).
  2. Export as PEM.
  3. 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

SymptomCause
keystore_load_failed: parsing PKCS12 at startupWrong 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 issuerThe 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_timeoutFirewall — outbound 5500 to your SS's peers is blocked.
SSL routines::wrong version number in XTR logssecurity_server.url port is wrong — should be 5500 (message port), not 4000 (admin UI).
Subsystem stuck in GLOBALERROR in admin UIRegistration 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:

VariantExtras
upstream_soap_faultcode, string, detail (SOAP lane only; detail present only when expose_soap_fault_detail: true)
request_too_large / upstream_body_too_largelimit (byte cap exceeded)

Status table

StatuserrorLaneCause
404template_not_foundBothNo DSL matched /<group>/<service>.
405method_not_allowedBothInbound HTTP method doesn't match the DSL's declared method:. SOAP DSLs are POST-only; REST DSLs contract whichever method they declare.
413request_too_largeBothBody exceeded limits.max_request_bytes.
500template_expansion_failedSOAPHandlebars render error at request time. Startup validation catches most; anything reaching here is exotic (e.g. runtime helper failure).
500keystore_load_failedBoth.p12 couldn't be read/parsed. Both lanes share the mTLS identity, so either triggers this.
500internal_errorBothUnexpected. 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.
502upstream_http_errorSOAPUpstream returned non-2xx AND the body wasn't a parseable SOAP Fault. REST lane does not translate — see "REST passthrough" below.
502upstream_soap_faultSOAPUpstream returned <Fault> (on HTTP 200 OR wrapped in HTTP 5xx). Both SOAP 1.1 and 1.2 shapes handled.
502upstream_xml_parse_errorSOAPResponse wasn't valid XML. Includes XXE-guard rejections (custom entities) and nesting-depth cap.
502upstream_body_too_largeBothResponse exceeded limits.max_response_bytes. Connection torn down.
504upstream_timeoutBothUpstream 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-Error header 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

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: rest with a target: block; XTR constructs the /r1/{instance}/{class}/{code}/{subsystem}/{service_code}[{path}] URL (§4.1) with percent-encoded identifier segments (§4.2), sets X-Road-Client (§4.3) and X-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 to Policy::none() per §4.4. See the "REST passthrough" chapter of the mdBook for the operator-facing setup guide.
  • security_server.trust_ca_path config 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 new any route 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 production RestLaneExecutor::new code path end-to-end with a real handshake. Complements the plain-HTTP router tests in tests/it_rest_passthrough.rs.

Changed

  • XRoadTemplate is now { method, kind: TemplateKind } where TemplateKind is Soap or Rest. Existing DSL files without a kind: field deserialise as Soap — full backward compatibility.
  • POST /:group/:service route widened to any /:group/:service. DSL method: is now enforced at the handler; mismatches return 405 method_not_allowed for both SOAP and REST kinds.
  • Executor gained dispatch_rest(); existing dispatch() renamed to dispatch_soap(). External callers were only via the router.
  • The mTLS client builder is now a shared build_mtls_client() helper used by both SecurityServerExecutor (SOAP) and RestLaneExecutor (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 to docker run now APPEND as argv to the binary instead of REPLACING CMD. Bare docker 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-slimdebian: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.

  1. SOAP fault response shape (H3). The JSON body for a 502 upstream_soap_fault no longer includes the detail field; string (faultstring) is capped at 200 characters; message is 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 structured tracing fields.
    • Recover exact-equivalence by setting expose_soap_fault_detail: true in xtr.yaml.
  2. xroad_protocol_version enum 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_version to one of the accepted values.
  3. X-Road sidecar identity validation (H2). If a <wsdl>.meta.yaml sidecar declares member_class, member_code, or subsystem_code, they must equal the corresponding client_data field in xtr.yaml. Mismatch → whole WSDL is skipped with a WARN. Empty client_data fields (default state) skip the check per-field so pre-onboarding operators still get all endpoints.

    • The shipped xtr.yaml uses placeholder member_code: "<your-registry-code>". Any real sidecar with a real member code will now be rejected against the placeholder — set client_data before 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).
  4. URL guard on WSDL upstreams (C1). Every URL discovered in a WSDL <soap:address location=…> or metadata sidecar service_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: true for plaintext HTTP upstreams, or by adding legitimate internal hostnames to wsdl.upstream_host_allowlist. Private-IP upstreams cannot be recovered — that is by design.

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 sends Content-Encoding: gzip unconditionally, 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 doctor subcommand — new. Validates the operator's xtr.yaml against 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). See MIGRATION.md for 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.rs validates every URL discovered in a <soap:address location=…/> or metadata-sidecar service_url: override. Rejects http by 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_000 per-document event budget in src/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_schema now applies filename charset restriction, symlink rejection via symlink_metadata, and canonicalisation with starts_with(wsdl_dir) containment check.
  • H2 (X-Road client impersonation via sidecar) — sidecar member_class / member_code / subsystem_code are now validated against client_data at load time. Mismatch → refuse to load the sidecar (WSDL is skipped with a WARN).
  • H3 (SOAP fault detail leak)IntoResponse for UpstreamSoapFault now strips detail and caps faultstring at 200 chars by default. Full detail always logged at warn! level for operators. Config expose_soap_fault_detail: bool re-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. Added tests/tls_defaults_enforced.rs integration test (post-h2ck.me-v1 nit): spins a self-signed TLS server via rcgen + tokio-native-tls on 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 in read_bounded stays meaningful regardless of upstream content-encoding.
  • M3 (attribute-safe Handlebars helper) — registered {{xml_attr foo}} helper that emits &quot; &apos; &lt; &gt; &amp; 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_dir config field. At boot, XTR scans <dir>/<group>/*.wsdl, parses each, and generates DSL/<group>/<operation>.yml per wsdl:operation.
  • SOAP-1.1 document/literal parser in src/wsdl/ — supports inline anonymous complexTypes, named top-level complexTypes (with lazy resolution + cycle guard), xsd:include via a local-filesystem loader (offline discipline preserved), xsd:import skipped as framework, xsd:annotation skipped as documentation. Bail-out-on-unsupported for xsd: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.yaml sidecar opts into X-Road envelope wrapping (member_class/member_code/subsystem_code → auto-generated <xroad:*> header block).
  • Generator recognises the X-Road TURVASERVER placeholder in <soap:address location=…/> and omits service: so the executor routes via security_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 run now boots with 33 Ariregister endpoints live via WSDL ingestion.
  • .gitignore/DSL/ar/*.yml ignored (regenerated per boot from the WSDL). Hand-written DSLs (like DSL/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 (Estonian ariregistri_kood instead of English reg_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.

  • SsExecutorSecurityServerExecutor
  • src/executor/ss.rssrc/executor/security_server.rs
  • Executor.ss field → Executor.security_server
  • All prose in book/, docs/, tasks/, comments — same.
  • SOAP protocol literals unchanged (SOAP-ENV:Server, env:Server etc 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 (&nbsp;, &copy;) 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 main branch — orphan commit with a README redirecting to dev. Reserved for the future v1.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.md Status section now surfaces the domain design.
  • book/src/introduction.md first paragraph points at docs/DESIGN.md.
  • STANDARDS.md §13 extended — task tracking now allows optional epic subdirectories (tasks/backlog/epic-<slug>/ mirrored to done/ on completion). New "Linking rule" clause: every commit must reference at least one task file.

Task tracking

  • Task 001 (deep-dive) moved from backlog/ to done/ 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) — placeholder main.rs that prints a scaffold notice and exits. MSRV pinned to 1.88.
  • CI workflows (.github/workflows/):
    • tests.yml — matrix on ubuntu-latest + ubuntu-24.04-arm, cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --release --no-fail-fast.
    • security.ymlcargo audit --deny warnings + cargo deny check all on push/PR/daily cron.
    • publish.yml — multi-arch (linux/amd64 + linux/arm64) Docker Hub + GHCR publish on release tag or workflow_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 to main.
  • 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 into deny.toml's [advisories].ignore).
  • Hardened container:
    • Dockerfile — multi-stage rust:1.88-slimdebian:bookworm-slim, non-root uid 1000, tini as PID 1.
    • docker-compose.ymlread_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-Rust sibling.
  • 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 original buerokratt/XTR and 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.10.3.0-rc

TL;DR — additive release. Existing SOAP DSLs work unchanged. Two externally-visible changes worth reviewing before deploy.

Externally-visible changes

  1. 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 was POST /:group/:svc). The router is now any /:group/:svc (needed for REST DSLs that declare method: GET|PUT|DELETE), so method mismatches surface as XTR's own structured 405 method_not_allowed response.

    • Symptom of the change: GET /some-soap-endpoint now returns {"error":"method_not_allowed","message":"..."} with 405 status. Previously the same request got axum's bare 405 Method Not Allowed with 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.
  2. New optional security_server.trust_ca_path config 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 with unknown issuer. Point trust_ca_path at 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: rest and act as X-Road REST endpoints. See the "REST passthrough" chapter of the book for the operator-facing setup guide, or book/src/rest-passthrough.md in-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 status 405.

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.20.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:

SeverityMeaningExit code
FATALServer will not boot with this config, or a critical property is broken.1
BREAKBehaviour 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)
WEAKCurrently works, but a stronger posture is available. Recommended for public deployments.0 normally, 1 with --strict
INFOPositive 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 (JSON undefined, not null).
  • Anyone with an alert that regexes the message field for the old "(<code>): <string>" shape.
  • Anyone whose observability was reading the full faultstring for 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.yaml used 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:// unless wsdl.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.

CodeSeverityFires when
fatal-config-xroad-protocol-invalidFATALxroad_protocol_version outside {"4.0", "4.1"}
fatal-client-data-placeholder-member_codeFATALclient_data.member_code contains < or >
fatal-client-data-placeholder-subsystem_codeFATALclient_data.subsystem_code contains < or >
fatal-keystore-env-missingFATALsecurity_server configured, env var absent
fatal-keystore-env-emptyFATALsecurity_server configured, env var set to empty string
fatal-keystore-file-missingFATALsecurity_server.keystore_path doesn't exist on disk
weak-wsdl-allow-httpWEAKwsdl.allow_http_upstream: true
weak-wsdl-allowlist-emptyWEAKwsdl.upstream_host_allowlist: [] and wsdl_watch_dir is set
weak-error-expose-soap-fault-detailWEAKexpose_soap_fault_detail: true
weak-client-data-emptyWEAKall three client_data.* fields empty
weak-limits-request-too-generousWEAKlimits.max_request_bytes > 16 MiB
weak-limits-response-too-generousWEAKlimits.max_response_bytes > 128 MiB
weak-limits-timeout-too-longWEAKlimits.request_timeout_secs > 300
weak-paths-dsl-missingWEAKdsl_path doesn't exist on disk
weak-paths-wsdl-watch-missingWEAKwsdl_watch_dir set but doesn't exist
info-*INFOpositive observations; never affects exit code

For LLM assistants helping an operator upgrade

Copy-paste this into your Claude / GPT session:

I'm upgrading turnerrainer/xtr from 0.1.0-rc.2 to 0.2.0-rc. Please help me plan the upgrade.

  1. Here's my current xtr.yaml:
    <paste the whole file>
    
  2. Here's my docker-compose.yml / Deployment manifest:
    <paste>
    
  3. 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.md at the root of the turnerrainer/XTR repo. Then:

  • Predict what xtr-on-rust doctor --strict will report against my xtr.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.2 behaviour.
  • 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 run command 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).