Ingress Provider
What it does
The ingress provider takes events from other systems, webhooks and notifications, and hands them to the apps you allow. Each kind of event has a channel: a system signs each event with the channel's secret and posts it, the provider checks the signature and the event's shape, and sends it on, encrypted, to the apps granted the channel. One provider serves any number of channels. See the Overview for how it fits with the other components.
Quick start
Describe the provider in deployment.kdl: its host, its listen address,
and each channel other systems send events to, with the rules for who
may read it.
ingress-provider {
host "providers-1"
listen "0.0.0.0:8080"
ingress-channel "webhook" {
root-key-file "/var/lib/datumhue/ingress-provider/webhook.rootkey"
hmac-secret-file "/var/lib/datumhue/ingress-provider/webhook.hmac"
payload-schema-file "/var/lib/datumhue/ingress-provider/webhook.json"
issuer "https://sign-in.example.com/realms/acme" {
allow claim="groups" array_contains="ops" rights="r"
}
}
}
Make its files with the deployment tool, generate or add-provider, then
put each channel's files in the bundle's
providers-1/, mode 600: a root key of 64 hex characters
(head -c 32 /dev/urandom | xxd -p -c 32), the sender's signing secret
and the JSON Schema of its events.
On the host, install the program, then follow the README.md in the
host's folder of the bundle, which installs the rest and starts it:
sudo install -D -m 755 ingress-provider-linux-x86_64 \
/opt/datumhue/ingress-provider
Its log names its public key, which matches the line for it in the
bundle's pubkeys.txt, and then each announcement.
How it works
Posting an event. A system sends each event as JSON, signed with HMAC-SHA256 over the exact body:
POST /<channel>
X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body>
Content-Type: application/json
<the event>
| Answer | When |
|---|---|
202 Accepted | The event was taken |
400 | The body is not JSON, or lacks the channel's idempotency-header |
401 | The signature is missing or wrong |
404 | No channel has that name |
408 | The event took more than 30 seconds to arrive and be handled |
413 | The body is over 256 KiB |
422 | The body does not match the channel's schema |
503 | The provider is already handling 256 events; send it again shortly |
The provider listens on plain HTTP at listen, behind your load
balancer, which ends TLS. The signature protects each event the whole
way. The provider keeps no events, so several of them behind one load
balancer can each take any event.
Reading a channel. The provider hands the keys that open a
channel's events only to people its rules admit. The keys change every
rotation-period.
Apps get channels by name. An app reads a channel only when it is
granted it by name: --ingress-mount NAME:CHANNEL when the workstation
starts it, ingress-mount=NAME:CHANNEL in a link to the browser
workstation, or from a parent app. No grant covers every channel.
datumhue ingress list shows the channels a deployment has.
Command line
ingress-provider <COMMAND>
| Command | Description |
|---|---|
ingress-provider serve | Start the provider service. |
ingress-provider serve
ingress-provider serve [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--config <CONFIG>, -c | ./ingress_provider.kdl | Path to the configuration file |
--aggregator-url <AGGREGATOR_URL>, -a | none | Override the aggregator-url from the configuration file |
Configuration
The settings file is KDL: one ingress-provider block, then one
ingress-channel block for each channel.
ingress-provider {
secret-key-file "/var/lib/datumhue/ingress-provider/provider.key"
capability-file "/var/lib/datumhue/ingress-provider/provider.caps"
aggregator-url "https://aggregator.example.com"
relay-url "https://aggregator.example.com"
listen "0.0.0.0:8080"
}
ingress-channel "github" {
description "GitHub webhooks"
root-key-file "/etc/datumhue/github.rootkey"
hmac-secret-file "/etc/datumhue/github.hmac"
payload-schema-file "/etc/datumhue/github.schema.json"
idempotency-header "X-GitHub-Delivery"
issuer "https://sign-in.example.com/realms/acme" {
allow claim="groups" array_contains="ops" rights="r"
}
}
Common provider settings
Every provider's settings block takes these keys beside its own. A relative path anywhere in the settings file resolves against the folder that holds the file, not the folder the provider starts in.
| Key | Required | Default | Description |
|---|---|---|---|
aggregator-url | yes | none | The aggregator's public HTTPS URL. --aggregator-url overrides it |
relay-url | yes | none | The aggregator's relay, at the same public HTTPS URL |
discovery-url | no | the aggregator's | Where the provider publishes its address and looks up others |
announce-ttl | no | 300 | How long each announcement stays valid: seconds, or a duration such as "5m", at least 2 seconds. The provider announces again after half of it |
webrtc-bind-addr | no | none | A fixed UDP address for direct browser connections, such as "0.0.0.0:7844". Set it with webrtc-public-addr when the provider runs in a container or behind port forwarding |
webrtc-public-addr | no | none | The host:port browsers reach webrtc-bind-addr at. Every address it resolves to must route to that socket |
The key and the capability file. The secret key is the provider's identity: the provider creates it at its first start when the file is missing, and logs its public key at every start. The capability file holds the capabilities that let the provider announce what it serves. The provider reads it again before every announcement and rewrites it as capabilities renew, so it refuses to start when it cannot write to the file's folder. See Provider authorization.
| Variable | Description |
|---|---|
DATUMHUE_KEYSTORE_PASSPHRASE | Encrypts the secret key the first start creates. While the key is encrypted the variable must be set, and while it is plaintext it must be unset |
DATUMHUE_CERT_PATH | A PEM file of CA certificates to trust beside the public ones, for an aggregator whose certificate a private CA signed. Set, it must name a file of CA certificates only |
RUST_LOG | Log levels, info unless set |
The access log. At info, on the log target datumhue_audit, the
provider logs each access it allows or refuses, each sign-in it cannot
verify, and each session it ends because a new sign-in no longer passes
the rules. A mount or channel's open is logged, and the operations it
then allows are not, one by one. A refusal that repeats is logged with a
count, in repeated, not each time. RUST_LOG=info keeps the access log; RUST_LOG=info,datumhue_audit=off
turns it off alone, and a RUST_LOG of warn or error, or one that
names only other targets, leaves it out.
Each line has the message access and the fields provider, action,
resource, endpoint, iss, sub, outcome, rights, basis,
claim, reason, credential and repeated, with - where a field
has nothing to say. It names the mount, channel or scope, the connecting
program's endpoint id, the signed-in person, the outcome (allow,
deny, refused, verify_failed or session_dropped), for a refusal
the claim that failed, and in repeated how often a refusal came. It
never holds a token, a secret or the value a rule expects.
Ingress provider settings
| Key | Required | Default | Description |
|---|---|---|---|
secret-key-file | no | ./ingress_provider.key | The provider's secret key |
capability-file | no | ./ingress_provider.capabilities | The capabilities to announce each channel |
listen | no | "0.0.0.0:8080" | The address the provider takes events on, behind your load balancer |
Channels
| Key | Required | Default | Description |
|---|---|---|---|
ingress-channel "<name>" | yes | none | One channel, by the name systems post to and apps read. Names are unique and hold no / |
root-key-file | yes | none | The channel's root key: 64 hex characters. The keys apps read with come from it; apps never see it |
hmac-secret-file | yes | none | The secret the sending system signs with. An empty file stops the start |
payload-schema-file | yes | none | The JSON Schema every event must match. A schema that does not parse stops the start |
description | no | none | One line about the channel, which datumhue ingress describe shows |
signature-header | no | "X-Hub-Signature-256" | The header that carries an event's sha256=<hex> signature |
asyncapi-file | no | none | An AsyncAPI document, which datumhue ingress describe shows. It never checks events |
rotation-period | no | 3600 | How often the channel's read key changes: seconds, or a duration such as "1h", more than zero |
idempotency-header | no | none | The header whose value marks repeated events; without it, a hash of the body does |
allow, issuer | yes | none | Who may read the channel, as Access rules describes. A channel without rules does not load |
Access rules
Access rules say who may use a resource. A rule that reads a sign-in
sits in the block of the sign-in service whose tokens it reads, named by
its issuer URL exactly as those tokens carry it in iss. datumhue login prints that URL. Each rule names who it admits and the rights it
grants:
issuer "https://sign-in.example.com/realms/acme" {
require claim="email_verified" equals=#true
allow claim="groups" array_contains="analysts" rights="r"
}
| Rule | Admits everyone the issuer signs in whose |
|---|---|
allow signed-in | sign-in passes the block's require tests |
allow claim="<name>" equals="<value>" | claim is the text, or a list that holds it |
allow claim="<name>" equals=#true | claim is the boolean true (#false for false) |
allow claim="<name>" array_contains="<value>" | claim is a list that holds the text |
rightsis"r"(read) or"rw"(read and write). A rule admits a request when it matches and its rights cover what the request asks for. Rules add up: any one rule that admits a request is enough.requiretakes the sameclaimand match, withoutrights. Everyrequireof a block must hold before any of its rules counts.- A sign-in counts only in the block of the issuer that signed it. The
URL is compared exactly:
https://accounts.google.comandhttps://accounts.google.com/are two issuers. A sign-in from an issuer with no block gets nothing a block grants. equalsis typed.equals="true"matches the texttrue, never the boolean; a number must be quoted and is compared as text.claimnames a top-level claim of the person's ID token. A value nested in another claim, such as Keycloak'srealm_access.roles, never matches: map it to a top-level claim at your sign-in service.- Prefer the groups or roles your sign-in service manages, or
sub, overemail. On Google, addrequire claim="hd" equals="<your domain>"to admit only your Workspace accounts. - The issuer must be one the deployment's Identity Provider serves.
- An
emailclaim counts only when the same token saysemail_verifiedistrue, and aphone_numberclaim only when it saysphone_number_verifiedistrue. Microsoft Entra ID does not sendemail_verified: matchoid,suborgroupsthere. - A sign-in that does not verify is refused, even where
allow anyoneadmits a request without one. - A refused request learns which claim failed, never the value a rule expects.
For a channel, any rule that matches lets the person's apps read it.
A channel only delivers, so its rules grant rights="r"; a rule with
rights="rw" stops the start.
Every channel needs a rule: a channel without one stops the provider at
start. allow anyone rights="r", outside every issuer block, lets
anyone who reaches the provider, directly or through a relay of the
deployment, read the channel with no sign-in; the provider logs a
warning for it at start. Sending to a channel takes its secret, never a
rule.
Network, state and dependencies
Network surface
| Direction | What |
|---|---|
| from other systems | POST /<channel>, plain HTTP at listen, through your load balancer |
| from workstations | A channel's read keys, as its rules allow, and its encrypted events |
| to the aggregator | Announcing each channel, every half announce-ttl |
| to the aggregator | The sign-in services' keys, to verify people |
The provider takes 1024 workstation connections at most.
Persistent state
| Path | Holds |
|---|---|
secret-key-file | The provider's identity. A lost key needs new capabilities |
capability-file | Its capabilities, which it renews itself |
root-key-file | A channel's root key. A new one changes every key apps read with, and they fetch new ones |
hmac-secret-file | A channel's secret, shared with the sending system |
The provider keeps no events: it sends each one on as it arrives.
External dependencies
- Your load balancer, which ends TLS for incoming events and sends
them to
listen. - The aggregator, for announcing and for the sign-in services' keys. Within a minute of losing it, the provider refuses sign-ins until it can read the keys again.
- The Identity Provider, when a channel
has an
issuerblock.
Running as a service
systemd
The deployment tool writes a unit like this one for every provider it
sets up. To run the provider under systemd by hand,
install the program as /opt/datumhue/ingress-provider, its settings as
/, and give the datumhue user the state
folder /.
/:
[Unit]
Description=DatumHue Ingress Provider
After=network.target
[Service]
Type=simple
User=datumhue
Group=datumhue
WorkingDirectory=/var/lib/datumhue/ingress-provider
ExecStart=/opt/datumhue/ingress-provider serve \
--config /etc/datumhue/ingress-provider.kdl
Environment=DATUMHUE_CERT_PATH=/etc/datumhue/ca-cert.pem
EnvironmentFile=/etc/datumhue/keystore.env
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/datumhue/ingress-provider
[Install]
WantedBy=multi-user.target
- The unit keeps
/etc/datumhueread-only, and a relative path in the settings file resolves there. Pointsecret-key-file,capability-fileand every other file the provider writes into/.var/ lib/ datumhue/ ingress-provider/ keystore.envholdsDATUMHUE_KEYSTORE_PASSPHRASE=...; keep it mode 600. Drop theEnvironmentFile=line for a plaintext key.- Drop the
DATUMHUE_CERT_PATHline when the aggregator's certificate comes from a public CA. - On the aggregator's host, add
datumhue-aggregator.servicetoAfter=.
sudo systemctl daemon-reload
sudo systemctl enable --now datumhue-ingress-provider
journalctl -u datumhue-ingress-provider -f
systemctl stop stops the provider the way Ctrl+C does: it stops
announcing and gives its provider seat back, so the next start takes a
seat at once.
Docker
To run the provider in a container, build an image from this
Dockerfile, in the folder that holds the program:
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY ingress-provider-linux-x86_64 /opt/datumhue/ingress-provider
ENTRYPOINT ["/opt/datumhue/ingress-provider"]
CMD ["serve", "--config", "/etc/datumhue/ingress-provider.kdl"]
Run it as the datumhue user that owns the state folder, with the
settings folder read-only:
docker build -t datumhue/ingress-provider .
state=/var/lib/datumhue/ingress-provider
docker run -d --name datumhue-ingress-provider --restart always \
--user "$(id -u datumhue):$(id -g datumhue)" \
--env-file /etc/datumhue/keystore.env \
-e DATUMHUE_CERT_PATH=/etc/datumhue/ca-cert.pem \
-v /etc/datumhue:/etc/datumhue:ro -v "$state:$state" \
datumhue/ingress-provider
The docker-compose.yml the deployment tool writes runs images by these
names, with the same folders.
Publish the listen port to the load balancer: add -p 8080:8080 to
docker run.
Security notes
- The secret decides who may send. Only a system that holds a
channel's secret can post to it. Give each channel its own, and
change it by writing a new
hmac-secret-fileand restarting. - The rules decide who may read. A channel without rules does not
load.
allow anyoneopens a channel to anyone who reaches the provider, directly or through a relay of the deployment, and the provider logs a warning for it at start; give each channel the narrowest rule that works. - Reading ends within two
rotation-periods. Each fetch of a channel's keys passes the rules with the person's current sign-in. Someone the rules stop admitting can still read the events of at most tworotation-periods after the last fetch the rules admitted. - Sign-in key changes apply within a minute. A change to a sign-in service's keys, a revocation included, reaches the provider within a minute.
- Every event matches its schema. An event that does not is refused and never reaches an app.
- Run behind TLS. The provider takes plain HTTP: always put a load balancer that ends TLS in front of it.
- Bodies are never logged. The access log records each channel
open, allowed or refused, and, on the log target
datumhue_audit::push, each event whose signature matches, with its channel, status and size, and each event with a missing or wrong signature, as refused.RUST_turns the event lines off alone.LOG=info, datumhue_ audit:: push=off - Settings apply at start. New channels, schemas, rules and secrets take effect when the provider restarts.
- Protect the key. The deployment tool encrypts it with your
passphrase; a key the provider makes at its first start is plaintext
unless
DATUMHUE_KEYSTORE_PASSPHRASEis set. The provider refuses a key file that others can read.
Troubleshooting
Messages. The provider writes its log to standard output; under
systemd, read it with journalctl -u and the unit's name.
The aggregator is refused as untrusted. It serves the certificate it
made itself: point DATUMHUE_CERT_PATH at its tls/ca-cert.pem.
provider lease acquisition failed. With QuotaExhausted, every
provider seat of the license is taken: stop a provider, or extend the
license.
The seat or announcements are refused. capability-file holds no
current capability for this key: make one with
datumhue-deploy reissue-capabilities.
The provider stops at start, naming relay-url. Set relay-url to
the aggregator's public HTTPS URL.
The provider refuses its key file. A key file that others can read
is refused: chmod 600 it. DATUMHUE_KEYSTORE_PASSPHRASE must be set
for an encrypted key and unset for a plaintext one; the message says
which the key is.
The provider cannot write its capability file. Give the provider's user write access to the folder that holds the file; it rewrites the file as capabilities renew.
The provider stops at start, naming a channel's access rules. A
channel needs at least one rule, a rule that reads a sign-in sits in an
issuer block, and its rules grant rights="r" only. The message says
what to write; datumhue login prints the issuer URL a block names.
Apps see no channel. The log shows announcer running once the
provider announces. Without it, the capability file lacks a current
capability for ingress/<channel>.
Events are refused with 401. The signature is missing, is in
another header than signature-header, or does not match. The sender
must sign the exact bytes of the body with the secret in
hmac-secret-file.
Events are refused with 422. The body is JSON that does not match
payload-schema-file.
Events are refused with 400. The body is not JSON, or lacks the
channel's idempotency-header.
Events are refused with 404. No channel has that name, or the path
is more than one name: post to /<channel>.
An app receives no events. Grant the app the channel by name, with
--ingress-mount or a link's ingress-mount=, or from a parent app.
The person must also pass the channel's rules: without them, no key and
no events.