Configuration
The ferrofed binary reads one TOML file and the environment. It serves the
process shape (health, readiness, the request log, graceful shutdown) and, once
a registry is configured, the federated query POST {base}/v1/query/aql, the
EHR resources and the definition area routed to one node, and, when
[stored_queries] is set, the stored-query registry. The DEMOGRAPHIC area
answers 501 unless federation.demographic_endpoint declares the one
endpoint that serves it, which a request reaches without a targeting header,
and every other path under
{base}/v1/ answers 501. {base} is / unless you set
the base path.
Three pages carry the rest of the configuration: the registry,
its document and the state learned from it; identity
resolution, the cross-reference that finds each member’s
ehr_id; and queries and API areas, the settings a
federated query and each ITS-REST area run under. What the gateway sends a
node lists the id and the headers of each request to a
node.
Running it
ferrofed serve --config /etc/ferrofed/ferrofed.toml
ferrofed config check --config /etc/ferrofed/ferrofed.toml
ferrofed healthcheck --config /etc/ferrofed/ferrofed.toml
ferrofed report --config /etc/ferrofed/ferrofed.toml
--config names the file; without it the file is the one FERROFED_CONFIG
names, and without that every default stands. config check reads and
resolves the configuration exactly as serve would, secrets included, loads
a read-only stored-query directory, prints one line and exits, so a deployment
pipeline can test a file without binding a socket. It opens no store file,
connects to no database and writes nothing to disk, so it runs on a
read-only root filesystem without the gateway’s durable volume. An audit
spool directory is checked where it stands and never created: one that
exists must give its owner write access and its group and other users none,
and hold no file the gateway did not write; one that does not exist needs a
parent directory that does, whose mode lets a user write in it. Only the
modes are read, so whether the gateway’s own user may write there, and on a
filesystem mounted writable, is left to serve, which creates the directory
and refuses to start, naming its key, when it cannot. ferrofed report
writes the archive a complaint or a serious-incident report attaches,
described on the
Complaints and incidents page.
ferrofed admission check --endpoint <id> checks
one member against the admission conditions (Admitting a node).
healthcheck asks the gateway running on this host for its readiness and
exits 0 or 1, the two codes a container runtime’s health check reads
(Health probes).
A configuration the gateway refuses exits with code 78 (EX_CONFIG) and one
line naming the key at fault. It refuses an unknown key, a value of the wrong
type, a zero timeout or body limit, a log filter that does not parse, a secret
set both inline and through its _file sibling, a credentials section
that names no scheme or two, and a credential the Authorization header
cannot carry: a basic user or password holding a control character such as a newline (RFC 7617
§2), or a basic user holding a colon. That refusal names the key the value came from, and never
the value. The gateway never falls back to a default for a value you set.
A key a release renamed is still read under its old name for at least one
release. config check prints a warning: line for it, naming the key that
replaces it and the release that will refuse it, and serve logs the same
at start; setting both the old and the new key is refused
(the compatibility policy). No key
is deprecated in this release.
With a FHIR face ([fhir]), config check also prints a note: line for
each Art 14(1) category the face receives, naming the member its
[[fhir.receive]] entry declares, and one line naming the categories this
release carries that no member receives. These are the categories
GET {fhir-base}/metadata names
(Receiving a document).
A configuration with no [fhir] gets neither line.
ferrofed: note: [[fhir.receive]] Patient-Summaries: the FHIR face receives documents of this Art 14(1) category and writes them to the member node-a
A face with no [[fhir.receive]] entry prints this line instead:
ferrofed: note: [[fhir.receive]] names no receiving member for Patient-Summaries: the FHIR face refuses a document of this Art 14(1) category
It also prints a line for each category served resource by resource,
naming the members its [[fhir.serve]] entry declares, and one line naming
the categories no member serves, which the face answers as not served
(Prescriptions and dispensations):
ferrofed: note: [[fhir.serve]] Electronic-Prescriptions: the FHIR face serves this Art 14(1) category from the members node-a, each on its own endpoint
ferrofed: note: [[fhir.serve]] names no member for Electronic-Dispensations: the FHIR face answers this Art 14(1) category as not served
The startup banner
When the log renders for a person, serve prints a banner before the first
log line: the family’s banner with the wordmark and the version, then one
line each for the product, the maintainer, the manufacturer with
its contact and postal address, the releases the gateway serves, the end
date of its support period, and the deployment facts to check first. Built from
source and run on a terminal with the development configuration of
the quickstart, it prints this:
_____ _____ _____ ____
| ___|__ _ __ _ __ ___ | ___| ____| _ \
| |_ / _ \ '__| '__/ _ \| |_ | _| | | | |
| _| __/ | | | | (_) | _| | |___| |_| |
|_| \___|_| |_| \___/|_| |_____|____/
ferrofed 0.0.9
Product openEHR federation gateway
Maintainer Ruben Talstra · https://github.com/FerroHEALTH/FerroFED
Manufacturer Cadasto B.V. · info@cadasto.com
Address Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands
Federation Tier 1.0.1
ITS-REST 1.1.0
AQL 1.1.0
openehr-* 0.0.87
Support none, this build is no release
Base path /
Listen 0.0.0.0:8080
Registry 4 members, 4 endpoints
Stored queries off
Unencrypted credentials.node-a-query
credentials.node-b-query
credentials.node-c-query
credentials.node-d-query
DEVELOPMENT: this deployment runs the development profile, which relaxes
checks a production deployment must pass, such as encryption to a loopback
service. It must not hold or reach real patient data.
The Support line of a release from v0.0.10 on says that only the newest
release is supported and names the last day of its support period, until
which fixed releases are published, as
newest release only, fixes until YYYY-MM-DD, five years from its release
date (SECURITY.md).
The release build compiles the date in, and the gateway never reads it from
the network. A build from source and a pre-release have no support period,
and the line says so. Once the date has passed, the line reads
ended on YYYY-MM-DD, and an UNSUPPORTED notice under it says the release
receives no security fixes and to upgrade to the newest release, in red on
a terminal with colour. config check
prints the same notice as a warning: line, serve logs it at WARN, and
OPTIONS {base}/ names the date as supported_until, or null for a build
with none (Regulation (EU) 2024/2847 Art 13(19)).
The Registry line counts the members and endpoints of the registry
document. The gateway reads the document once, before the banner, and serves
that same read, so the counts are those of the registry it serves. The line
reads none when no document is set, and says the document does not load
when it cannot be read, in which case the boot stops on the next lines with
the reason. The development notice prints only under
profile = "development", in red on a terminal with colour and in the same
words without it, and an operator reads the same profile from
GET {base}/operator/deployment
(The operator surface). The
Unencrypted lines name, by key, each credential or
patient identifier that travels unencrypted, which only the development
profile allows
(What must travel encrypted).
Colour follows the terminal, and an explicit format = "pretty" keeps it
into a pipe. A NO_COLOR environment variable that is set and not empty
switches colour off in both the banner and the log, whatever the format
(https://no-color.org).
The banner prints only when the log renders as pretty: with
telemetry.format = "pretty", or with auto when stdout is a terminal. With
json, or with auto and stdout piped to a collector, the first line on
stdout is a JSON log line. config check, healthcheck and
admission check print no banner. The banner shows counts, an address, a
path, switches and configuration keys, and never a credential, a URL, a
header value or anything from a request.
The file
The sections, and the page that covers each:
| Key or section | What it sets | Page |
|---|---|---|
profile | production, the default, or development, which relaxes the transport rules below | What must travel encrypted |
[server], [telemetry], [credentials], [signing] | the listener, the console, the onward credentials, the signing keys | this page |
server.max_concurrent_requests, [server.caller_rate], federation.max_in_flight_per_node, federation.max_node_answer_size | the overload limits and the answer bound | Overload protection |
federation.offset_strategy, federation.max_offset_window, federation.max_node_rows | paging across nodes, and the most rows read of one node for a query with no LIMIT, 9999 by default (a node is asked for one row more, so set it one under the largest page your nodes serve) | Queries and API areas |
[telemetry.otlp] | the trace export and the metrics push to one OTLP collector | Tracing, Metrics |
[metrics], [config] | the admin listener; config.ui, off by default, and the store of the configuration revisions the admin listener serves under managed | Metrics, Configuration revisions |
[registry] | the registry document and its form, or the mCSD directory of [registry.mcsd] the registry is read from; a member’s own exchange-format endpoint is an hl7-fhir-rest endpoint of the document | The registry, A member’s own exchange-format endpoint |
[pixm] | the cross-reference; a [dev] table is refused | Identity resolution |
[pdqm] | the PDQm Supplier asked for the master identity of an identifier the cross-reference does not map | Demographics first |
[xcpd] | the XCPD localizer and its audit repository | XCPD localization, The audit repository |
[nl_gf.nvi] | the NVI localizer of the Dutch Generic Functions | Dutch localization |
[nl_gf.mitz] | the Mitz consent pre-filter of the Dutch Generic Functions | Dutch consent |
[pmir] | the PMIR identity feed: the subscription and the route the Registry sends to | The identity feed |
[audit] | where the access records and the audit records of the PIXm, PDQm, mCSD and PMIR transactions go; outside development a gateway with a registry needs destination = "repository" | The audit trail |
[access_log] | the category map every access record is classified with: template ids and archetype ids to Art 14(1) categories, national categories, or none; [access_log.retention], the years each record is kept by category and origin, at least three; [access_log.labels], the security labels each record carries by category and origin; and [[access_log.emergency_purpose]], the purposes of use that mark an emergency access (Art 11(5)) | The access log, Labelling the access records |
[federation] | the federation id, node selection, budgets, completeness, paging, aggregates and the optional facilities | The registry, Queries and API areas |
[stored_queries] | the stored-query registry and its backend | Queries and API areas |
[store] | FerroFED’s own PostgreSQL database, which a contact-point issuer and the postgres stored-query backend need: its connection string, a secret that requires TLS, the CA it is verified by, and its bounds | FerroFED’s own store |
[emergency] | the limit of the emergency accesses a national contact point relays for one health professional: threshold, 5 unless set, and window, 86400 unless set; a looser limit is refused (eHDSI Section II Security Services 10.0.0 §2.5) | The emergency access limit |
[fhir] | the FHIR R4 face of the European exchange format: its base, the operator, the FHIRconnect mappings, the categories it receives ([[fhir.receive]]), the members that serve prescriptions and dispensations ([[fhir.serve]]), receive_namespaces, the identifier systems a received document’s or dispensation’s patient is resolved in, and document_url_lifetime, how long a document URL of the document list stays valid (5min unless set); it needs [pdqm], which the summary header is asked of; [eehrxf.terminology], the optional FHIR terminology server the face asks for $translate, $lookup and $validate-code, over https outside development | The patient summary over FHIR, Terminology, Prescriptions and dispensations over FHIR |
[usage_report] | the installation report to FerroPULSE, on by default: enabled, state_dir, the writable directory the instance id lives in and without which nothing is sent, deployment (helm, compose, binary or unknown) and endpoint, the collector | The usage report |
The bindings and their features
The sections of a regional or national binding belong to that binding, and
each binding is one Cargo feature of the ferrofed-server crate:
| Feature | Binding | Sections |
|---|---|---|
| always built | the access log, no binding | [access_log], [audit] |
binding-ihe | IHE, Annex A | [pixm], [pdqm], [xcpd], [registry.mcsd], [pmir], and registry.format = "fhir", which reads the document with the mCSD reader |
binding-nl | Dutch Generic Functions, Annex B; it turns on binding-ihe, which resolves patients | [nl_gf] ([nl_gf.nvi], [nl_gf.mitz]), [credentials."<id>".nuts] |
Both features are on by default, so the released binary and the container
image carry every binding. A build without one, such as
cargo build -p ferrofed-server --no-default-features --features binding-ihe,
refuses that binding’s sections as unknown keys. The access record needs no
binding: every build writes it through [audit] (The audit
trail), so a build with either binding, or none, runs a registry
outside development.
[server]
listen = "127.0.0.1:8080" # the socket address to bind
base_path = "" # the path of the base URL every route sits under, empty for the root; see The base path
trusted_proxies = [] # the proxies whose forwarded client address is taken; see The public address
forwarded_header = "forwarded" # or "x-forwarded-for": the header those proxies name the client in
request_timeout = "30s" # a request past this answers 408; see Timeouts
drain_delay = "0ms" # after SIGTERM, readiness is 503 and the listener accepts this long; see Health probes
shutdown_timeout = "30s" # then the drain is bounded by this; unset, the request timeout, and never shorter
header_read_timeout = "30s" # a client that sends no request head within this, counted from its first byte, is closed
idle_timeout = "2min" # a kept-alive connection with no next request within this is closed
body_limit = "1MiB" # a body past this answers 413
max_concurrent_requests = 512 # one more at once answers 503 overloaded; see Overload protection
retry_after = "1s" # the Retry-After of that 503, in whole seconds
compression = true # gzip or brotli for a client that accepts it; false leaves every answer as written
compression_min_size = "1KiB" # an answer shorter than this is never compressed
# A per-caller rate, off unless set; see Overload protection.
[server.caller_rate]
requests_per_second = 10 # sustained, per verified issuer and client_id
burst = 20 # at once after a quiet spell; one more answers 429 rate-limited
# TLS on the listener, off unless set; see TLS on the listeners. Unset, the
# listener speaks plain HTTP for a proxy that terminates TLS in front of it.
# [server.tls]
# certificate_file = "/run/secrets/ferrofed/listener.crt" # the chain, its own certificate first
# key_file = "/run/secrets/ferrofed/listener.key"
# client_auth = "required" # none (the default), optional or required: ask a client certificate
# client_ca_file = "/run/secrets/ferrofed/proxy-ca.pem" # the CA it must chain to: set with client_auth, refused without it
# min_version = "1.3" # the default; "1.2" keeps TLS 1.2 clients
# reload_interval = "1h" # optional: read the files again on this period, beside SIGHUP
# healthcheck_identity_file = "/run/secrets/ferrofed/healthcheck.pem" # what ferrofed healthcheck presents then
# The gateway's own keys beside [server].
[gateway]
# public_url = "https://gateway.example.org" # the base URL clients reach; see The public address
bindings_drain_timeout = "5s" # after the drain, the bindings stop their processes within this; see Health probes
# replica = "gateway-eu-1" # the name every operator report carries; unset, HOSTNAME, else "unnamed"; see The replica name
[telemetry]
format = "auto" # auto, json or pretty; auto is json unless stdout is a terminal
filter = "info,hyper=warn,tower=warn,h2=warn" # what the console logs; never what is traced
# environment = "production" # optional: deployment.environment.name on every exported span and metric
# The trace export and the metrics push, off without this table; see Tracing and Metrics.
[telemetry.otlp]
endpoint = "http://127.0.0.1:4317" # an OTLP gRPC collector, http:// beside the gateway or https:// over TLS
traces = true # export the spans
metrics = true # push the metrics
sample_ratio = 1.0 # the share of requests whose spans are exported, 0.0 to 1.0
# trust_roots_file and client_identity_file add the TLS of an https:// collector.
# The metrics surface, off by default; see Metrics.
[metrics]
listen = "127.0.0.1:9464" # the admin listener: GET /metrics and the stored-query distribution; loopback unless allow_remote
# scrape_clients = ["prometheus"] # the clients whose client-credentials token GET /metrics admits; needed off loopback outside development, unless [metrics.tls] requires a client certificate
# [metrics.tls] takes certificate_file, key_file, client_auth, client_ca_file, min_version and reload_interval, as [server.tls] does.
# Outbound credentials, one section per endpoint id. Each section names one
# scheme: a user and a password, or a grant; see Onward credentials.
[credentials."clinic-b".credentials]
user = "ferrofed"
password_file = "/run/secrets/clinic-b-password"
# OAuth 2.0 client credentials with a signed JWT client assertion (§13.1).
[credentials."cdr-c".credentials.oauth]
token_endpoint = "https://auth.cdr-c.example.org/oauth2/token"
client_id = "ferrofed-gateway"
client_auth = "private_key_jwt"
private_key_file = "/run/secrets/cdr-c-client.pem" # its public half is in the gateway's JWK Set
scope = "system/aql-*.s system/composition-*.cru"
resource = "https://cdr-c.example.org/openehr" # optional, RFC 8707
# audience = "cdr-c" # optional
# The Nuts grant of the Dutch Generic Functions (Annex B §B.4): a DPoP-bound
# token for a Verifiable Presentation of the gateway's credentials.
[credentials."cdr-d".nuts]
authorization_server = "https://nuts.cdr-d.example.org/oauth2/cdr-d"
scope = "openehr-query"
# client_id = "https://nuts.gateway.example.org/oauth2/gateway" # optional
did = "did:web:gateway.example.org"
kid = "did:web:gateway.example.org#key-1"
key_file = "/run/secrets/nuts-holder.pem"
dpop_key_file = "/run/secrets/cdr-d-dpop.pem"
[[credentials."cdr-d".nuts.credential]]
input_descriptor = "organization_credential"
file = "/run/secrets/nuts-organization-credential.jwt"
# A FAPI 2.0 authorization server, the BgZ/eOverdracht track (Annex B
# §B.4a): an ES256 private_key_jwt assertion naming the issuer, a DPoP-bound
# token, and RFC 9396 authorization_details.
[credentials."cdr-e".credentials.oauth]
issuer = "https://as.cdr-e.example.org"
security_profile = "fapi2"
client_id = "urn:oid:2.16.528.1.1007.3.3.<URA>"
client_auth = "private_key_jwt"
assertion_audience = "issuer"
private_key_file = "/run/secrets/fapi2-client.pem"
dpop_key_file = "/run/secrets/cdr-e-dpop.pem"
authorization_details = '''[{"type": "nl-gis-v1", "purpose_of_use": "http://terminology.hl7.org/CodeSystem/v3-ActReason|TREAT"}]'''
# The gateway's signing keys, which sign the caller's identity on every
# request to a node, and are published as a JWK Set beside every oauth
# grant's client key. Required whenever a registry is configured, by
# registry.document or by [registry.mcsd].
[signing]
key_file = "/run/secrets/ferrofed-signing-key.pem"
# previous_key_file = "/run/secrets/ferrofed-signing-key-previous.pem"
# next_key_file = "/run/secrets/ferrofed-signing-key-next.pem" # published ahead, never signs
# next_key_algorithm = "ES256" # unset, the current key's algorithm
jwks_uri = "https://gateway.example.org/.well-known/jwks.json"
node_jwks_cache = "1h" # how long the nodes cache the JWK Set
rotation_overlap = "65min" # at least 300 + node_jwks_cache
Secrets, inline or from a file
Every secret has a _file sibling, read at boot and trimmed, so a secret
can come from a mounted file and never sit in the configuration or the
environment. A secret written inline, or set through a FERROFED__
variable, is accepted as well, under every profile, production included;
setting both forms of one secret is refused, naming the inline key. Prefer
the file: an inline value sits in ferrofed.toml and in every copy of it.
The credentials are read and checked at boot and again on each
reload, and the node client of an endpoint with a
credentials section sends them on every request to that endpoint.
The gateway never prints a secret. A password or a client secret, inline, from
the environment or from a _file sibling, shows as *** wherever the
configuration is rendered: a debug log line, a panic message, a test failure.
A URL that may carry a credential, such as the stored-query store’s
url or telemetry.otlp.endpoint, shows with its userinfo and its query
replaced: postgres://***@db.example.org:5432/ferrofed?***. A libpq
key/value connection string shows as *** whole. The value itself is sent
unchanged.
The endpoint table of a service
Every outbound service a binding names is written as one endpoint table,
the EndpointSection of ferrohealth-smart that every FerroHEALTH product
reads for an outbound integration: [pixm.manager.endpoint] under each
[[pixm.manager]], [pdqm.endpoint], [pmir.endpoint],
[registry.mcsd.endpoint], [xcpd.gateway.endpoint] under each
[[xcpd.gateway]], [nl_gf.nvi.endpoint] and [nl_gf.mitz.endpoint], as
[audit.repository.endpoint] and [eehrxf.terminology.endpoint] are:
[pdqm.endpoint]
url = "https://pdq.example.org/fhir/" # required
timeout = "1s" # one whole call, every try and wait included
# connect_timeout = "500ms" # opening the connection; unset, timeout bounds it
# retry = { max_attempts = 1, initial_backoff = "200ms", max_backoff = "5s" }
[pdqm.endpoint.tls]
client_identity_file = "/run/secrets/pdq-client.pem"
[pdqm.endpoint.credentials]
user = "ferrofed-pdq-consumer"
password_file = "/run/secrets/pdq-password"
urlis the service’s base. It carries no credential: one with a user name or a password in it is refused naming the key, as an endpoint URL in the registry document is.timeoutbounds one call. Each table keeps its own default, so a key you leave out keeps the value the service had before it moved underendpoint:1sfor[pdqm]and[nl_gf.mitz],5sfor[pmir], and10sfor the others.retrytries a call again when the service could not be reached, the try timed out, or it answered5xx. Each identity, localization and consent table tries a call once unless you raisemax_attempts, so a failure fails the step at once within the query’s budget.tlsis the TLS material of Mutual TLS to the identity services.credentialstakes a user and a password, or theoauthclient-credentials grant authenticated by a client secret or a client assertion (Identity resolution), never anutsgrant. Each grant a node’s section can name is described in Onward credentials. Mitz takes the same: a user and a password, or theoauthgrant, whose token each question carries as a bearer token. A grant a table does not take is refused at load, naming it, and never read as no credential.[nl_gf.nvi.endpoint.credentials]takes a user and a password and refusesoauth; the NVI’snutsgrant sits in[nl_gf.nvi.nuts](Dutch localization). An XCPD responding gateway takes no credential: the XUA assertion is the gateway’s, and a[xcpd.gateway.endpoint.credentials]is refused.
When a service refuses a token with 401, the gateway drops the token and
sends the call once more with a fresh one, and a second 401 fails the
call (RFC 6750 §3.1). The
url, credentials and tls keys sat on the table itself before release
0.0.19; a configuration that still sets one there is refused, naming the
endpoint key that replaces it (Upgrading).
A PIX Manager asked by GET or POST
ITI-83 is an HTTP GET with the parameters in the URL: “the HTTP GET
operation shall be used” (PIXm 3.1.0 §2:3.83.4.1.2), and FHIR R4 requires a
server to support that GET (FHIR R4 Operations §3.2.0.1). The patient
identifier is then in the request URL, where a proxy, a load balancer or the
Manager’s access log can record it. method = "get" is the default, because
every PIXm Manager answers it.
FHIR R4 invokes an operation “generally” by a POST of a Parameters
resource to the operation’s endpoint (FHIR R4 Operations §3.2.0.1), and the
PIXm IG gives the request in that form (its Query Parameters In profile and
request example). With method = "post" the gateway posts the same
sourceIdentifier and targetSystem parameters as valueStrings, with
Content-Type: application/fhir+json, to [base]/Patient/$ihe-pix, and the
request URL carries no parameter. PIXm does not require a Manager to accept a
POST, so set it only for a Manager that says it does:
[[pixm.manager]]
method = "post" # the patient identifier travels in the body, never the URL
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
Any other value is refused naming the key. The answer is read the same way
under either method, and the audit record of each exchange holds the request
as sent: the URL of a GET, or the request line, media type and body of a
POST (The audit trail).
Mutual TLS to the identity services
A national identity service often authenticates the gateway by its client
certificate instead of, or beside, a password or a token. Every outbound
service takes its TLS material in the tls table of its endpoint
(The endpoint table of a service), and a
node in the tls sub-table of its [credentials."<endpoint id>"]:
[[pixm.manager]]
[pixm.manager.endpoint]
url = "https://pix.example.org/fhir/"
[pixm.manager.endpoint.tls]
client_identity_file = "/run/secrets/pix-client.pem" # mutual TLS
trust_roots_file = "/etc/ferrofed/pix-roots.pem" # optional
# min_version = "1.2" # "1.3" unless lowered
client_identity, or itsclient_identity_filesibling, holds the PEM client certificate chain and its private key, which the gateway presents in the TLS handshake. It is a secret: it shows as***wherever the configuration is rendered, and setting both forms is refused naming<table>.endpoint.tls.client_identity.trust_roots_filenames a PEM bundle of trust roots the service’s certificate chains to, added to the platform’s roots.min_versionis the oldest TLS version the gateway speaks to the service:"1.3"unless lowered to"1.2". Lower it only for a service that speaks no TLS 1.3, such as a MyHealth@EU responding gateway (Localization).- Every file is read at boot and on each reload of a reloadable table. A file that cannot be read, or material that holds no certificate and key in PEM, is refused naming its key, without quoting the file.
- The keys sat on the table itself before release 0.0.18; set there, they are refused as unknown keys (Upgrading).
Each of these services is reached through the outbound client of
ferrohealth-smart its endpoint table builds, with no redirect followed, the
credential marked sensitive, and the TLS material above. A MyHealth@EU
responding gateway’s client completes a connection only over a chain its
revocation check cleared (Localization).
What must travel encrypted
Every outbound connection in the configuration is held to one of three rules, by what it carries.
Credentials and patient identifiers. Outside profile = "development",
a URL that a configured credential or a patient identifier is sent to must
be https. serve, config check, admission check, conformance run
and every reload refuse anything else, with exit
code 78 and one line naming the URL’s key and what would travel over it,
never a value. The rule covers:
- the URL of a registry endpoint that has a
[credentials."<id>"]section, which receives its basic credentials or the access token itsoauthgrant obtains; - the
token_endpointof anoauthsection, which receives the client secret or the client assertion; - the
authorization_serverof anutssection, which receives the gateway’s credentials in a Verifiable Presentation; - the
issuerof a node’soauthsection, whose token endpoint receives the client assertion and, under anonwardtable, the callers’ tokens; - the
endpoint.urlof every PIX Manager, which is asked for patient identifiers with or without[pixm.manager.endpoint.credentials]; - the
endpoint.urlof[pdqm], the PDQm Supplier, which is sent the patient identifier with or without[pdqm.endpoint.credentials]; - the
endpoint.urlof every XCPD responding gateway, which is sent the patient identifier and, when one is configured, the XUA assertion; - the
endpoint.urlof[nl_gf.nvi], which is sent the pseudonymised BSN and, when one is configured, its credentials; telemetry.otlp.endpointwhen it carries a user name or a password; anhttps://collector meets the rule.
ferrofed: cannot start: the url of endpoint hospital-a in registry.document is not an https URL, and credentials.hospital-a would travel over it in cleartext: outside profile = "development" a credential or a patient identifier is sent only encrypted
A node URL no credential is sent to may stay http, for example a node on a
private network whose transport a sidecar protects with mutual TLS: the
gateway sends a node its own ehr_id, never the patient identifier (§5.4,
N33). A node the gateway presents its own client certificate to
(Mutual TLS to a node)
is https under every profile.
A database password. FerroFED’s own store is reached over TLS under
every profile: its connection string must set sslmode=require, or
config check, the start and a reload refuse it, naming store.url
(FerroFED’s own store). Outside the development
profile, the revision store’s connection string, config.url, must set
sslmode=require when it carries a password and reaches its host over the
network. The driver reads sslmode as disable, prefer (its default,
which falls back to no TLS) or require, and refuses any other value, so
disable and prefer are refused, naming config.url, by the same
commands and on a reload. A revision store connection with no password, or
whose every host is a Unix socket and that names no hostaddr, crosses no
network with a secret and may leave sslmode as it is
(Configuration revisions).
Under the development profile the same configuration starts, so the
quickstart can reach its nodes over http
inside its Docker network. Each credential or patient identifier that
travels unencrypted is named by key in the startup banner, in a WARN log
line at boot and after each reload, and on stderr by config check.
Trust anchors. A URL the gateway verifies its callers against, an
issuer itself, the key set its metadata names, or an introspection endpoint
in [auth], must be https, or http to a loopback
host, under every profile, development included. A key set fetched in the
clear would let anyone on the network substitute keys and forge callers.
The profile takes a restart. A reload whose file changes profile is
refused (class profile), and the running configuration stays. Every
decision the development profile admits, these transport rules and an
http XCPD gateway, reads the profile the process started with.
The specification assumes a protected transport and leaves it to the security profiles (§2.2, §13); no specification governs these rules, which are FerroFED’s own design.
The environment
Any key can be set or overridden with FERROFED__<SECTION>__<KEY>, upper or
lower case, with __ between the segments:
FERROFED__SERVER__LISTEN=0.0.0.0:8080
FERROFED__CREDENTIALS__HOSPITAL_A__CREDENTIALS__PASSWORD_FILE=/run/secrets/hospital-a-password
A value reads as TOML syntax when it is one (9, true) and as the string it
is otherwise. An override that names no key, or a key the file does not
define, is refused like any other unknown key.
Configuration revisions
With [config] ui = "managed", every change to the configuration becomes a
revision: checked as config check checks the file, kept in history with a
diff, applied atomically or marked pending a restart, and rolled back in one
step. Operators read and save revisions through the admin listener’s API;
Configuration revisions describes it. The
listen address, the profile, [server], [metrics], [auth] and
[config] stay file-only, and a revision holds only _file references,
never a secret. With off, the default, nothing changes.
The console manages members and stored queries (#577), and onward credentials and binding sections with write-only secrets, a confirmation after a change applies and a second operator’s approval (#578; Credentials and binding sections). Any revision exports as TOML, and TOML imports as a revision you check before you apply it (#579; Export and import).
The base path
server.base_path is the path of the base URL the gateway is served at,
{base} in the specification, and every route in the table below sits under
it (§4.1, N28). It is empty by default, so the gateway serves at the root. Set
it to mount the gateway under a path of your choosing, for example behind a
reverse proxy that does not strip the path:
[server]
base_path = "/fed/openehr"
The gateway then serves GET /fed/openehr/, OPTIONS /fed/openehr/,
GET /fed/openehr/health/readiness, POST /fed/openehr/v1/query/aql and the rest of
the table, serves {base} without the trailing slash as {base}/, and
answers 404 for every path outside the base, the root included, so point a
health probe at {base}/health/liveness and {base}/health/readiness; ferrofed healthcheck asks under the base
by itself. The specification reserves no prefix, and
the gateway reserves none either: /rest/openehr is a valid base when you
choose it, and is not served unless you do. The base is checked at boot: it
is empty, or starts with / and has no trailing /, no query or fragment,
and no empty, . or .. segment, or the gateway refuses to start and names
server.base_path. Tell clients the full base URL, scheme,
host and this path, through the registry or service discovery; nodes never
see it, because the gateway asks each node at the node’s own base URL.
Write that full URL once in gateway.public_url, and the keys that repeat
it follow from it (The public address).
The HTTP surface
Every route is under the base path; with the default, the
root, {base}/ is /.
| Route | Answers |
|---|---|
GET {base}/ | the product name and version, and its manufacturer with the postal address and the contact |
OPTIONS {base}/ | the federation’s self-description (§7a.2) |
GET {base}/health/liveness | 200 while the process is up |
GET {base}/health | the status readiness answers, with the state alone |
GET {base}/health/readiness | 200 while the gateway serves and its own subsystems (the configuration, the registry, the outbound clients, the stored-query store) are up; 503 before boot completes and from the moment SIGTERM or SIGINT arrives, with the phase and each subsystem’s state, never its detail; no member node and no identity source gates it |
GET {base}/operator/dependencies | to a caller with the operator scope, always 200, with the state the gateway last observed of each member endpoint, of the resolver, of the consent pre-filter and of the mCSD directory: up, degraded, failing, down or unknown, with directory_fault while the directory is degraded; endpoint ids, states, the fault’s class and the replica name only |
GET {base}/operator/deployment | to a caller with the operator scope, always 200, with the profile the process started with, the product version and the replica name; no setting value |
GET and POST {base}/v1/query/aql | the federated RESULT_SET, the GET form reading the request from its query string; 501 when no registry is configured |
{base}/v1/ehr/{ehr_id} and below | routed to the one node that owns the ehr_id, found in the order of §12.5.1: the openEHR-federation-endpoint header, the session’s resolution binding, the ehr_id index, then for a read the ask-all probe; answered as that node answered; 501 when no registry is configured |
GET {base}/v1/ehr?subject_id=…&subject_namespace=… | the subject resolved at the gateway, and GET /v1/ehr/{ehr_id} sent to the one member that holds it, at that member’s own base, answered as that node answered; 501 when no registry is configured |
{base}/v1/definition/ and below | routed to the one node openEHR-federation-endpoint names, never merged; without the header a 400; a template upload naming * or several endpoints fanned out to each when federation.fan_out_template_upload is set; stored-query definitions held at the gateway when [stored_queries] is set, and distributed to the members a PUT names when federation.fan_out_stored_queries is set beside it; without [stored_queries], routed like every other definition request; 501 when no registry is configured |
{base}/v1/demographic/ and below | 501, never federated; when federation.demographic_endpoint is set, routed to that endpoint, with or without openEHR-federation-endpoint naming it; a header naming another endpoint is a 400 |
any other path under {base}/v1/ | 501 |
| any other path | 404 |
Every response carries an x-request-id: the client’s value when it is short
printable ASCII, the gateway’s own id otherwise.
The metrics are not on this surface: they have a listener of their own, off by default (Metrics).
The replica name
Each gateway process keeps its integrity incidents, its learned routing
table and its dependency states to itself, so every report of the
operator surface and the
export of the identity state names the process
that produced it. [server] replica sets the name: 1 to 64 ASCII letters,
digits, ., _ and -. Any other value is refused at load with
gateway.replica is no replica name. Unset, the name is the HOSTNAME the
process runs with, which Kubernetes sets to the pod’s own name, and
unnamed when HOSTNAME is unset or is no such name. A start is never
refused for its host name.
Give each replica a name you can tell apart, or leave it to the pod name. The name is not a secret and names no patient, and it never reaches a node.
TLS on the listeners
The client listener and the admin listener each run in one of two modes.
- Plain HTTP, the default. Use it behind a reverse proxy, a load balancer or a service mesh that terminates TLS and reaches the gateway over a network you trust, such as the loopback interface or a pod’s own network namespace. Bearer tokens and patient identifiers cross the hop from the proxy to the gateway in the clear, so pick this mode only where that hop cannot be read.
- TLS, with
[server.tls]or[metrics.tls]. Use it when the hop from the proxy to the gateway crosses a network others share, or when your policy forbids a cleartext hop inside the network (NEN 7510 is one such policy). Setclient_auth = "required"with aclient_ca_fileto admit only the proxy: a client that presents no certificate that CA signed fails its handshake and reaches nothing.
[server.tls]
certificate_file = "/run/secrets/ferrofed/listener.crt"
key_file = "/run/secrets/ferrofed/listener.key"
client_auth = "required"
client_ca_file = "/run/secrets/ferrofed/proxy-ca.pem"
healthcheck_identity_file = "/run/secrets/ferrofed/healthcheck.pem"
certificate_fileholds the chain the listener presents, its own certificate first, andkey_fileits private key, both PEM.client_authsays whether the listener asks a client certificate:none, the default, asks none;optionalverifies one a client presents;requiredrefuses a client that presents none.client_ca_fileholds the CA certificates a client certificate must chain to. It is required withoptionalorrequired, and refused withnone, where it would do nothing (a client sends a certificate only when the listener asks for one, RFC 8446 §4.3.2).min_versionis the oldest TLS version the listener speaks:"1.3", the default, or"1.2"to keep a TLS 1.2 client."1.1"and"1.0"are refused (RFC 8996).reload_interval, optional, reads the files again on that period, besideSIGHUPand an applied configuration revision.healthcheck_identity_file,[server.tls]only, holds a client certificate chain and its key, PEM, whichferrofed healthcheckpresents to a listener that requires one. It is refused anywhere else.
The listener speaks TLS 1.3 (RFC 8446) alone by default. With min_version = "1.2" it speaks TLS 1.2 too, and picks TLS 1.3 whenever the client offers
it, as BCP 195 asks (RFC 9325 §3.1.1); its TLS 1.2 cipher suites are then the
four ECDHE AES-GCM suites RFC 9325 §4.2 recommends, each with forward
secrecy. It offers HTTP/2 and HTTP/1.1 in ALPN. A client that does not
finish its handshake within 10 seconds is dropped, and one that does not
send a request head within server.header_read_timeout (30 seconds by
default, counted from the head’s first byte) is disconnected. A kept-alive
HTTP/1.1 connection that sends no next request within
server.idle_timeout (120 seconds by default) is closed with a clean end
of stream; keep it longer than the idle timeout of the clients and proxies in
front, so they close an idle connection first.
config check reads every file, and refuses, with exit code 78 and the key
named, a missing certificate or key, a file that holds no PEM, a key that
does not match its certificate, and a client CA that is no usable trust
anchor. It never prints the content of a file.
To renew the certificate, write the new files over the old ones and send
SIGHUP, as for a registry reload. Every handshake after the
signal presents the new certificate and checks clients against the new CA
file; connections already open keep the certificate they began with. Files
that do not form a valid pair refuse the whole reload, logged with their
table, and the running certificate, registry and [auth] stay. Where no
signal reaches the process, POST /admin/reload on the admin listener runs
the same reload. A changed path in [server.tls] or
[metrics.tls] takes a restart.
ferrofed healthcheck follows [server.tls]: it asks over https on
loopback and accepts exactly the certificate certificate_file holds. If
the files were replaced and the gateway has not been sent SIGHUP, it
reports so and exits 1.
What the log records
One line per request: the method, the matched route, the status, the latency,
the gateway’s request id (request_id) and whether the client sent its own
(client_named). A façade query carries the patient identifier, so the line
never carries a request body, the AQL text, a header value, the request path,
or a query value other than the ITS-REST paging parameters offset and
fetch, and those only when they are one to ten digits. That list is fixed
in code: serve and config check refuse [telemetry] logged_query_parameters, so a deployment cannot widen it. The line is
written under the target ferrohealth_telemetry::request. The route is a path template,
never the path the client sent: a request the gateway routes or answers
under {base}/v1/ is logged under the template of the ITS-REST operation it
addresses, so a routed composition read is logged as
/v1/ehr/{ehr_id}/composition/{uid_based_id}, with the configured base path
in front of it once, and its ehr_id and version uid stay out of the line.
An OPTIONS request is logged under the template of the resource it
describes. A path that names no route, and any other method the ITS-REST
operation does not declare, is logged as <unmatched>. The client’s own
x-request-id is a header value too, and it is never logged, so a request the client named is found in the log by its time, route
and status, and in a node’s log by the request_id of that line. A request
the gateway answers before its handler finishes has its line too, with the
status it answered: 500 for a handler panic, 408 past the request
timeout, 413 over the body ceiling. A handler panic is also logged under
the gateway’s id, without its message, which could quote a value the handler
held. Every panic in ferrofed serve, inside a request or not, writes one
more line, “a thread panicked; the message is withheld”, with the thread,
the source file and line and, inside a request, the gateway’s
request_id. The panic message is never written:
the gateway replaces Rust’s default panic hook, which prints it to stderr,
so a panic writes nothing to stderr. A federated query the gateway fails with a 500 also logs “the
federated query failed” with its error code and the same request_id as its
request line. A fan-out template upload, a stored-query distribution or
repair, and a drift check whose task for one member panics fail the same
way, with a 500, and log that the fan-out could not tell what became of
every member.