commit 528615e2d4155cb56037f48deb9a72eca7cce44b
parent 93b9ea2c03898bec1c154f1f76009f1aa4258006
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 9 Aug 2026 16:03:35 +0200
use taler-rust INI parser and listen logic
Diffstat:
28 files changed, 4940 insertions(+), 778 deletions(-)
diff --git a/debian/README.Debian b/debian/README.Debian
@@ -27,7 +27,13 @@ applied the same way, in order. /usr/share/kych/sql/drop.sql removes
everything again.
To use a remote database or password authentication instead, set DATABASE in
-/etc/kych/kych.conf to a full connection URI.
+/etc/kych/kych.conf to a full connection URI. A password does not have to live
+in the main file: with
+
+ @inline-secret@ kych-oauth2-gateway /etc/kych/secrets/database.conf
+
+the DATABASE line can sit in a file of its own, restricted to root:kych, while
+kych.conf itself stays readable.
2. Configure
------------
@@ -52,14 +58,29 @@ that.
systemctl enable --now kych
systemctl status kych
+Socket activation (optional)
+----------------------------
+
+With the shipped SERVE = unix the daemon binds /run/kych/kych.sock itself and
+kych.socket must stay disabled -- the two would fight over the same path. To
+let systemd own the socket instead, so that it exists from boot and the daemon
+starts on the first connection, change SERVE to "systemd" in
+/etc/kych/kych.conf, comment out UNIXPATH, and swap the units:
+
+ systemctl disable --now kych.service
+ systemctl enable --now kych.socket
+
+The socket path, its group (www-data) and its mode (0660) are the same either
+way, so nothing changes for the reverse proxy. kych.socket is the place to
+edit them in this mode; UNIXPATH_MODE is not consulted.
+
Reverse proxy
-------------
The gateway speaks plain HTTP on the Unix socket /run/kych/kych.sock and does
-not terminate TLS. It is not socket-activated -- it creates the socket itself,
-which is why the package ships no .socket unit. The socket is created with
-group www-data and mode 0660, so a proxy running as www-data can reach it. No
-web-server snippets are shipped; for nginx the essential part is
+not terminate TLS. The socket has group www-data and mode 0660, so a proxy
+running as www-data can reach it. No web-server snippets are shipped; for
+nginx the essential part is
location / {
proxy_pass http://unix:/run/kych/kych.sock;
diff --git a/debian/README.source b/debian/README.source
@@ -20,6 +20,12 @@ Notes for whoever touches this next
taler-rust) and is fine for the "-b" builds above; producing a source package
would need a format decision and an orig tarball first.
+* The crate has one path dependency, ../taler-config, which is the vendored
+ copy of the GNU Taler configuration parser, logging setup and socket-binding
+ logic; see taler-config/README for what it is and how to re-sync it. Cargo
+ builds it as part of the main crate, so it needs no separate step -- but
+ "debian/rules clean" has to remove its target/ as well.
+
* The build is not offline: cargo fetches crates from crates.io, into
debian/cargo (CARGO_HOME) so that nothing outside the build tree is written.
There is no Cargo.lock in the repository, so builds are not reproducible
@@ -69,15 +75,12 @@ there as well, with a "manpages/" prefix on the first field:
Keep the two lists identical, or the same source will produce a different NAME
line depending on who builds it.
-Known documentation bug
------------------------
-
-kych.conf(5) documents the "@INLINE@" include directive, which the parser does
-not implement -- and does not reject either: the directive line is read as the
-start of a key and swallows the line after it, so that setting disappears
-silently. The directive is documented with a note saying so, because it is the
-behaviour KyCH ought to have (every other GNU Taler component supports it, and
-without it there is no way to keep database credentials out of the main
-configuration file). Remove the note, not the documentation, once it works.
+taler-docs also carries shared fragments that kych.conf.5.rst now duplicates in
+its own words: frags/common-conf-syntax.rst for the file format and
+manpages/frags/common-serve-options.rst for SERVE/BIND_TO/PORT/UNIXPATH, both
+pulled in with ".. include::" by the sibling components. Switching to them
+would be the right move once the page lives in taler-docs -- but it cannot
+happen here, because the fragments are not in this repository and the Debian
+build renders these sources standalone.
-- Taler Systems SA <deb@taler.net>
diff --git a/debian/changelog b/debian/changelog
@@ -1,3 +1,9 @@
+kych (0.0.0-111-g93b9ea2c) UNRELEASED; urgency=medium
+
+ * Build from git (0.0.0-111-g93b9ea2c).
+
+ -- Christian Grothoff <grothoff@gnu.org> Sun, 09 Aug 2026 15:51:52 +0200
+
kych (0.0.0) UNRELEASED; urgency=medium
* Initial Debian packaging of the KyCH OAuth2 gateway.
diff --git a/debian/copyright b/debian/copyright
@@ -11,6 +11,15 @@ Comment:
Stated by the COPYING file at the root of the source tree. Note that the
individual source files still carry no license headers of their own.
+Files: taler-config/*
+Copyright:
+ (C) 2024-2026 Taler Systems SA
+License: AGPL-3+
+Comment:
+ Vendored from https://git.taler.net/taler-rust.git; unlike the rest of the
+ tree these files do carry their AGPL-3+ headers. See taler-config/README for
+ which upstream file each one came from.
+
Files: kych_oauth2_gateway/oauth2_gatewaydb/versioning.sql
Copyright:
(C) 2010 Hubert depesz Lubaczewski
diff --git a/debian/etc/kych/kych.conf b/debian/etc/kych/kych.conf
@@ -8,25 +8,24 @@
#
# Syntax notes, because they are easy to get wrong:
#
-# * This is a plain INI file. Comments must stand on a line of
-# their own, so write
+# * Comments must stand on a line of their own, so write
# PORT = 8080
# and never
# PORT = 8080 # the port
-# which configures a port of "8080 # the port". The same mistake
-# after a section header, "[client_x] # ...", is worse: it makes
-# the whole file fail to load.
+# which configures a port of "8080 # the port".
#
# * Lists are written in braces, comma-separated: {a, b, c}.
#
-# * Unlike other GNU Taler components, this file is *not* parsed by
-# GNUnet's configuration library. kych.conf(5) documents an
-# @INLINE@ include directive, but it is not implemented -- and not
-# rejected either: such a line silently swallows the setting on the
-# line after it. There is likewise no @inline-secret@ and no
-# conf.d/ directory. Everything lives here, including the database
-# credentials and the OAuth 2.0 client secrets, which is why the
-# package keeps this file at mode 0640, owned root:kych.
+# * @inline@ pulls in another file relative to this one, and
+# @inline-secret@ <section> <file> pulls one section out of one --
+# which is how the database password and the client secrets are
+# kept out of a world-readable main file. See kych.conf(5).
+#
+# * $DATADIR and friends expand in options that name a path, as do
+# environment variables and ${VAR:-default}.
+#
+# * Files in /usr/share/kych/config.d/ are read before this one, and
+# this one wins.
#
# * The gateway re-reads nothing at runtime: restart the service
# after editing ("systemctl restart kych").
@@ -36,28 +35,32 @@
# --- How the gateway listens -------------------------------------
#
-# Either a Unix domain socket (UNIXPATH) or a TCP socket (HOST and
-# PORT together), never both -- the gateway refuses to start if both
-# or neither are configured.
-#
-# The shipped systemd unit creates /run/kych for this, and runs the
-# daemon as user kych with group www-data, so a reverse proxy running
-# as www-data can reach the socket at mode 0660. The gateway speaks
-# plain HTTP and does not terminate TLS; put nginx or Apache in front
-# of it.
-UNIXPATH = /run/kych/kych.sock
+# One of "unix", "tcp" or "systemd". The gateway speaks plain HTTP
+# and does not terminate TLS; put nginx or Apache in front of it.
+SERVE = unix
-# Permissions for the socket file, in octal. Upstream's default is
-# 666; 660 is enough here because the socket is group www-data.
+# For SERVE = unix: where to put the listening socket, and its
+# permissions in octal. The shipped systemd unit creates /run/kych
+# for this and runs the daemon as user kych with group www-data, so a
+# reverse proxy running as www-data can reach the socket at 0660.
+UNIXPATH = /run/kych/kych.sock
UNIXPATH_MODE = 660
-# TCP alternative. If you enable these, comment out UNIXPATH above,
-# and bind to the loopback interface only unless the gateway sits
-# behind a firewall -- none of its endpoints are safe to expose
+# For SERVE = tcp. Bind to the loopback interface unless the gateway
+# sits behind a firewall -- none of its endpoints are safe to expose
# directly to the internet.
-#HOST = 127.0.0.1
+#BIND_TO = 127.0.0.1
#PORT = 8080
+# For SERVE = systemd, the gateway takes the already-bound socket from
+# the service manager instead of binding one. Switching to it is two
+# steps, since the shipped kych.socket listens on the same path that
+# UNIXPATH names above:
+#
+# SERVE = systemd (here, and comment out UNIXPATH)
+# systemctl disable --now kych.service
+# systemctl enable --now kych.socket
+
# --- Database ----------------------------------------------------
#
# PostgreSQL connection string. The default below connects over the
@@ -67,6 +70,8 @@ UNIXPATH_MODE = 660
#
# For a remote database use the URI form instead, e.g.
# DATABASE = postgres://kych:SECRET@db.example.com/kych
+# and consider keeping that line in a file of its own:
+# @inline-secret@ kych-oauth2-gateway secrets/database.conf
#
# The schema must be loaded before first start; see README.Debian.
DATABASE = postgres:///kych?host=/var/run/postgresql
@@ -154,7 +159,8 @@ VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_pl
#CLIENT_ID = exchange-prod-01
# Shared secret. Stored bcrypt-hashed in the database; keep this file
-# readable only by root and the kych user.
+# readable only by root and the kych user, or move the secrets into a
+# file of their own with @inline-secret@.
#CLIENT_SECRET = SECRET
# Base URL of the SWIYU verifier this client's verifications go
@@ -167,7 +173,7 @@ VC_CLAIMS = {family_name, given_name, birth_date, sex, place_of_origin, birth_pl
# and the same value must be repeated on the /token request.
#REDIRECT_URI = https://exchange.example.com/kyc-proof/kych
-# Issuers whose credentials this client accepts. Despite what
-# kych.conf(5) says, this is not optional in practice: /authorize
-# fails with accepted_issuer_dids_not_configured when it is unset.
+# Issuers whose credentials this client accepts. Optional as far as
+# the parser is concerned, but required in practice: /authorize fails
+# with accepted_issuer_dids_not_configured when it is unset.
#ACCEPTED_ISSUER_DIDS = {did:tdw:example:issuer}
diff --git a/debian/kych.dirs b/debian/kych.dirs
@@ -0,0 +1,4 @@
+# Drop-in directory read before /etc/kych/kych.conf on every start. The
+# configuration parser warns on every start if it is missing, and it is where
+# a future version of this package would put shipped defaults.
+usr/share/kych/config.d
diff --git a/debian/kych.service b/debian/kych.service
@@ -17,11 +17,18 @@ Group=www-data
# loads but the QR code silently fails.
WorkingDirectory=/usr/share/kych
-# Creates /run/kych (0755, kych:www-data) before start and removes it on stop.
-# This is where UNIXPATH in the shipped kych.conf puts the listening socket.
+# Creates /run/kych (0755, kych:www-data) before start. This is where UNIXPATH
+# in the shipped kych.conf puts the listening socket.
RuntimeDirectory=kych
RuntimeDirectoryMode=0755
+# Keep the directory across a stop. Under SERVE = systemd the socket inode
+# inside it belongs to kych.socket, which goes on listening while the service
+# is down -- the default (removing the directory on stop) would delete that
+# socket out from under it and no client could connect again. With the shipped
+# SERVE = unix this only leaves an empty directory on a tmpfs behind.
+RuntimeDirectoryPreserve=yes
+
ExecStart=/usr/bin/kych-oauth2-gateway -c /etc/kych/kych.conf
Restart=always
diff --git a/debian/kych.socket b/debian/kych.socket
@@ -0,0 +1,19 @@
+[Unit]
+Description=Socket for the KyCH OAuth 2.0 gateway
+Documentation=https://docs.taler.net/taler-kych-manual.html
+
+[Socket]
+# Only used when kych.conf says SERVE = systemd; with the shipped SERVE = unix
+# the daemon binds this path itself and this unit must stay disabled. The two
+# paths are deliberately the same, so that the reverse proxy configuration does
+# not change when an administrator switches between the two.
+ListenStream=/run/kych/kych.sock
+
+# The reverse proxy runs as www-data and connects to the socket; kych.service
+# runs with Group=www-data for the same reason in the non-activated case.
+SocketUser=root
+SocketGroup=www-data
+SocketMode=0660
+
+[Install]
+WantedBy=sockets.target
diff --git a/debian/rules b/debian/rules
@@ -1,7 +1,9 @@
#!/usr/bin/make -f
# The Cargo crate lives in a subdirectory, not at the top of the source tree.
+# It has one path dependency, taler-config/, which cargo builds along with it.
CRATE = kych_oauth2_gateway
+VENDORED = taler-config
# Man pages are generated from the reStructuredText that also feeds the website
# build in the taler-docs repository, so that there is a single source for
@@ -45,12 +47,12 @@ override_dh_auto_test:
override_dh_auto_install:
override_dh_auto_clean:
- rm -rf $(CARGO_HOME) $(CRATE)/target $(MANOUT)
+ rm -rf $(CARGO_HOME) $(CRATE)/target $(VENDORED)/target $(MANOUT)
-# The daemon is not socket-activated: it binds UNIXPATH itself rather than
-# accepting a descriptor from systemd, so there is no .socket unit to enable.
-# Do not enable or start the service on install -- it cannot run before the
-# administrator has created the database and edited kych.conf (see
-# README.Debian).
+# Both units ship disabled. The service cannot run before the administrator
+# has created the database and edited kych.conf (see README.Debian), and
+# kych.socket is only correct once SERVE is switched to "systemd" -- with the
+# shipped SERVE = unix the daemon binds UNIXPATH itself and an enabled socket
+# unit would fight it for the same path.
override_dh_installsystemd:
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade
diff --git a/documentation/taler-docs/manpages/kych-client-management.1.rst b/documentation/taler-docs/manpages/kych-client-management.1.rst
@@ -13,7 +13,9 @@ Synopsis
========
**kych-client-management**
-**-c** *CONFIG*
+[**-c** *CONFIG*]
+[**-L** *LEVEL*]
+[**-v**]
*COMMAND*
[*OPTIONS*]
@@ -28,8 +30,8 @@ delete, list and synchronize client registrations.
This tool exists because the running gateway resolves clients from the
*database* and never from the configuration file. The ``[client_*]`` sections
of kych.conf(5) are only a declarative source for the **sync** command below;
-adding a section and restarting the gateway has no effect. The configuration
-file is nevertheless required for every invocation, because the database
+adding a section and restarting the gateway has no effect. A configuration
+file is nevertheless needed for every invocation, because the database
connection string is read from it.
Client secrets are stored bcrypt-hashed and cannot be recovered, only
@@ -40,8 +42,18 @@ Global Options
==============
**-c** *CONFIG* \| **--config=**\ *CONFIG*
- Use the configuration file *CONFIG*. This option is required for all
- commands.
+ Use the configuration file *CONFIG*. Without it the standard search path
+ applies, ending at ``/etc/kych/kych.conf``; see kych.conf(5).
+
+**-L** *LEVEL* \| **--log=**\ *LEVEL*
+ Set the logging verbosity: ``ERROR``, ``WARN``, ``INFO``, ``DEBUG`` or
+ ``TRACE``, or the numbers 1 to 5. Defaults to ``INFO``. This affects the
+ diagnostics on standard error only; the output of the commands themselves
+ goes to standard output regardless.
+
+**-v** \| **--verbose**
+ Also log what the libraries have to say, ``sqlx`` in particular, which is
+ the way to see the statements this tool runs.
**-h** \| **--help**
Print short help. Also accepted after a command, as in
diff --git a/documentation/taler-docs/manpages/kych-oauth2-gateway.1.rst b/documentation/taler-docs/manpages/kych-oauth2-gateway.1.rst
@@ -13,8 +13,9 @@ Synopsis
========
**kych-oauth2-gateway**
-**-c** *FILE*
+[**-c** *FILE*]
[**-L** *LEVEL*]
+[**-v**]
**kych-oauth2-gateway**
[**-h** | **-V**]
@@ -45,20 +46,14 @@ collects it.
Its options are as follows:
**-c** *FILE* \| **--config=**\ *FILE*
- Read the configuration from *FILE*. This option is required; there is no
- default location, and the server exits if it is omitted.
- See kych.conf(5).
+ Read the configuration from *FILE*. Without it the standard search path
+ applies, ending at ``/etc/kych/kych.conf``. See kych.conf(5).
-**-L** *LEVEL* \| **--log-level=**\ *LEVEL*
- Set the logging verbosity. Accepted values are ``OFF``, ``ERROR``,
- ``WARN``, ``INFO``, ``DEBUG`` and ``TRACE``, or the numbers 0 to 5; case is
- ignored. Defaults to ``INFO``.
-
- The level applies to the gateway's own modules only; the SQL layer is
- pinned to ``WARN`` regardless. An unrecognised value is *not* an error: the
- offending filter directives are reported on standard error and then
- discarded, which leaves the server running with almost all logging
- disabled.
+**-L** *LEVEL* \| **--log=**\ *LEVEL*
+ Set the logging verbosity. Accepted values are ``ERROR``, ``WARN``,
+ ``INFO``, ``DEBUG`` and ``TRACE``, or the numbers 1 to 5; case is ignored.
+ Defaults to ``INFO``. An unrecognised value is refused before the server
+ starts.
.. note::
@@ -66,12 +61,23 @@ Its options are as follows:
and the full request bodies exchanged with the verifier. Such logs
contain both secrets and personal data.
+**-v** \| **--verbose**
+ Also log what the libraries have to say. Without it, messages from
+ ``sqlx``, ``axum``, ``hyper``, ``h2``, ``reqwest``, ``rustls`` and ``mio``
+ are dropped whatever **-L** says, which is what keeps ``DEBUG`` readable.
+
**-h** \| **--help**
Print short help on options and exit.
**-V** \| **--version**
Print version information and exit.
+Each line of the log carries a timestamp with the machine's UTC offset, the
+level, the source and, for anything logged while handling a request, a random
+ten-character identifier of that request in parentheses - which is how the
+several lines belonging to one verification can be told apart under load.
+Under systemd the timestamp is omitted, since the journal records its own.
+
Endpoints
=========
@@ -134,10 +140,19 @@ behind a reverse proxy.
Environment
===========
-``RUST_LOG``
- If set, it replaces the logging configuration entirely and **--log-level**
- is ignored. The value is a *tracing* filter, for example
- ``kych_oauth2_gateway=debug,sqlx=warn``.
+``LISTEN_FDS``, ``LISTEN_PID``
+ Read when ``SERVE = systemd``: the listening socket is taken from the
+ service manager instead of being bound. Ignored otherwise.
+
+``JOURNAL_STREAM``
+ Set by systemd. Its presence suppresses the timestamp on each log line,
+ because the journal already records one.
+
+``XDG_CONFIG_HOME``, ``HOME``
+ Searched for a configuration file when **-c** is not given, and expanded in
+ options that name a path. ``$PATH`` is searched for the
+ ``kych-oauth2-gateway`` binary itself, to derive ``$PREFIX`` and the other
+ variables of the ``[paths]`` section.
``PGHOST``, ``PGPORT``, ``PGUSER``, ``PGPASSWORD``, ``PGDATABASE``, ``PGSSLMODE``
Supply defaults for any part that the ``DATABASE`` connection string leaves
@@ -149,8 +164,12 @@ Files
=====
``/etc/kych/kych.conf``
- Configuration file, as installed by the Debian package. The path is not
- built in; it must be given with **-c**.
+ Configuration file, as installed by the Debian package, and the last of the
+ four locations searched when **-c** is not given. See kych.conf(5).
+
+``/usr/share/kych/config.d/``
+ Read before the configuration file itself, so that packaged defaults can be
+ overridden rather than edited.
``./js/``
The QR code helper served under the ``/js`` URL prefix is read from a path
@@ -160,16 +179,17 @@ Files
will render without its QR code.
``/run/kych/kych.sock``
- Listening socket created by the packaged service, per ``UNIXPATH``.
+ Listening socket of the packaged service: created by the server itself per
+ ``UNIXPATH`` with the shipped ``SERVE = unix``, or by ``kych.socket`` with
+ ``SERVE = systemd``.
Security
========
The server speaks plain HTTP and never terminates TLS; run it behind a
-reverse proxy. It is not socket-activated - it creates and unlinks
-``UNIXPATH`` itself - and it performs no privilege separation, so run it as
-an unprivileged user.
+reverse proxy. It performs no privilege separation, so run it as an
+unprivileged user.
Access control differs per endpoint, and none of it is a substitute for a
proxy that restricts who may reach what. In particular ``/notification``
@@ -181,11 +201,11 @@ by knowledge of the OAuth 2.0 *state* value.
Exit Status
===========
-The server runs until terminated by a signal. It exits non-zero before
-serving any request if the configuration cannot be read or is inconsistent
-(for instance if both ``HOST``/``PORT`` and ``UNIXPATH`` are set, or
-neither), if the database is unreachable, or if the listening socket cannot
-be bound.
+On ``SIGINT`` or ``SIGTERM`` the server stops accepting connections, lets the
+requests it is already handling finish, and exits zero. It exits non-zero
+before serving any request if the configuration cannot be read or is
+incomplete (for instance if ``SERVE = unix`` without a ``UNIXPATH``), if the
+database is unreachable, or if the listening socket cannot be bound.
Examples
diff --git a/documentation/taler-docs/manpages/kych.conf.5.rst b/documentation/taler-docs/manpages/kych.conf.5.rst
@@ -13,51 +13,91 @@ Description
===========
kych-oauth2-gateway(1) and kych-client-management(1) read their configuration
-from an INI-style file named with the **-c** option; the Debian package
-installs one at ``/etc/kych/kych.conf``. It holds one
-``[kych-oauth2-gateway]`` section with the settings of the service itself,
-and any number of ``[client_*]`` sections describing OAuth 2.0 clients.
+from a file in the GNU Taler configuration format, the same one every other
+GNU Taler component reads. It holds one ``[kych-oauth2-gateway]`` section with
+the settings of the service itself, and any number of ``[client_*]`` sections
+describing OAuth 2.0 clients.
-The file contains the database credentials and the client secrets, and should
-not be world-readable.
+Both tools take the file from the **-c** option. Without it, they read the
+first of
-Nothing is re-read at run time; restart the service after editing.
+* ``$XDG_CONFIG_HOME/kych.conf``
+* ``$HOME/.config/kych.conf``
+* ``/etc/kych.conf``
+* ``/etc/kych/kych.conf``
-A configuration file may include another, by using the ``@INLINE@``
-directive: in ``main.conf``, writing ``@INLINE@ sub.conf`` includes the
-entirety of ``sub.conf`` at that point in ``main.conf``.
+that exists; the Debian package installs the last of these. Before any of
+them, every file in ``$PREFIX/share/kych/config.d/`` is read, so that
+package-supplied defaults can be overridden rather than edited. Later
+settings win over earlier ones.
-.. note::
+The file contains the database credentials and the client secrets, and should
+not be world-readable.
- ``@INLINE@`` is **not implemented**. The parser used by KyCH is a plain
- INI reader with no notion of includes, and it does not diagnose the
- directive either: the line is taken to be the beginning of a key, and it
- swallows the line that follows it, so the setting on that next line is
- silently lost. Until this is fixed, keep everything in a single file - and
- in particular note that the ``@inline-secret@`` mechanism other GNU Taler
- components use to keep credentials out of the main configuration is not
- available here.
+Nothing is re-read at run time; restart the service after editing.
Syntax
------
-Comments are introduced by ``#`` or ``;`` and **must stand on a line of their
-own**. The parser has no notion of a trailing comment, and the two places
-where one might be written fail differently:
+Comments are introduced by ``#`` or ``%`` and **must stand on a line of their
+own**; ``;`` is not a comment character. The parser has no notion of a
+trailing comment, and the two places where one might be written fail
+differently:
* after a value, as in ``PORT = 8080 # the port``, the ``#`` and everything
after it become part of the value, which then fails to parse as a number;
-* after a section header, as in ``[client_x] # the exchange``, the whole
- file is rejected with ``doesn't support inline comment``.
+* after a section header, as in ``[client_x] # the exchange``, the line is no
+ longer recognised as a section header at all and the file is rejected with
+ ``Expected section header, option assignment or directive``.
+
+Section names are case-insensitive, and so are option names. Values are
+trimmed of surrounding whitespace; a value wrapped in double quotes keeps
+whatever whitespace is inside them, with the quotes removed.
+
+Several options take a list. The braces are conventional but optional, and
+items are separated by commas, by whitespace, or by both, so ``{ES256, ES384}``,
+``ES256, ES384`` and ``ES256 ES384`` are the same list. An option that is
+present but names no item at all is an error.
+
+Options that name a *path* - currently only ``UNIXPATH`` - expand ``$VAR`` and
+``${VAR}`` from the ``[paths]`` section and from the environment, and
+``${VAR:-default}`` supplies a fallback. ``$PREFIX``, ``$BINDIR``,
+``$LIBEXECDIR``, ``$DOCDIR``, ``$ICONDIR``, ``$LOCALEDIR``, ``$LIBDIR`` and
+``$DATADIR`` are predefined, derived from where ``kych-oauth2-gateway`` was
+found on ``$PATH``.
+
+
+Including other files
+---------------------
+
+A directive on a line of its own pulls in another file, relative to the
+directory of the file the directive appears in. A directive ends the current
+section, so an option written after one needs its section header repeated.
+
+@inline@ FILE
+ Include the whole of ``FILE`` at this point.
+
+@inline-matching@ GLOB
+ Include every file matching ``GLOB``, in glob order.
-Values are trimmed of surrounding whitespace. Several options take a list,
-written in braces and separated by commas, for example ``{ES256, ES384}``.
-The list parsers are not uniformly strict - ``VC_ALGORITHMS`` and
-``VC_CLAIMS`` insist on the braces, while ``ALLOWED_SCOPES`` and
-``ACCEPTED_ISSUER_DIDS`` also accept a bare comma- or space-separated list -
-but writing the braces everywhere is correct and portable.
+@inline-secret@ SECTION FILE
+ Include only ``[SECTION]`` from ``FILE``, and merge it into the section of
+ that name. This is how the database password and the client secrets are
+ kept out of the main configuration file: the main file stays readable to
+ whoever needs to inspect it, and only the small included file has to be
+ restricted. A missing file is a warning rather than an error, on the theory
+ that a component which does not need the secret should still start.
+
+For example, in ``/etc/kych/kych.conf``::
+
+ @inline-secret@ kych-oauth2-gateway secrets/database.conf
+
+with ``/etc/kych/secrets/database.conf`` holding::
+
+ [kych-oauth2-gateway]
+ DATABASE = postgres://kych:SECRET@db.example.com/kych
GLOBAL OPTIONS
@@ -69,33 +109,44 @@ The following options are from the ``[kych-oauth2-gateway]`` section.
Server Binding
^^^^^^^^^^^^^^
-The server can listen on either a TCP socket or a Unix domain socket, but not
-both. Configuring both, or neither, is an error and the server refuses to
-start.
+SERVE
+ How the server obtains its listening socket. Required. One of:
+
+ ``unix``
+ Bind a Unix domain socket at ``UNIXPATH``.
+
+ ``tcp``
+ Bind a TCP socket at ``BIND_TO`` and ``PORT``.
-HOST
- IP address or hostname to bind the TCP server to, e.g. ``127.0.0.1`` or
- ``0.0.0.0``. Required when using TCP mode. Must be specified together with
- ``PORT``. The server speaks plain HTTP; do not bind an address reachable
- from outside the host.
+ ``systemd``
+ Take an already-bound socket from the service manager, as passed in
+ ``$LISTEN_FDS``. Either kind of socket is accepted, and nothing else in
+ this section is read. Starting the daemon this way without an activated
+ socket fails with ``Missing systemd activated socket``.
+
+ In every case the server speaks plain HTTP and does not terminate TLS; put
+ a reverse proxy in front of it.
+
+BIND_TO
+ IP address to bind the TCP server to, e.g. ``127.0.0.1`` or ``::``.
+ Required when ``SERVE = tcp``. Do not bind an address reachable from
+ outside the host.
PORT
TCP port number to listen on, e.g. ``8080``.
- Required when using TCP mode. Must be specified together with ``HOST``.
+ Required when ``SERVE = tcp``.
UNIXPATH
Path to the Unix domain socket file, e.g. ``/run/kych/kych.sock``.
- Required when using Unix socket mode. Cannot be used together with
- ``HOST``/``PORT``. The server creates the socket itself - it does not
- accept one from systemd - and unlinks a leftover socket of a previous run
- at startup. The directory must already exist.
+ Required when ``SERVE = unix``. The server creates the socket itself and
+ unlinks a leftover socket of a previous run at startup. The directory must
+ already exist.
UNIXPATH_MODE
- File permissions for the Unix domain socket in octal notation.
- Default: ``666``.
- Only used when ``UNIXPATH`` is set. The socket's group is the group the
- server runs as, so ``660`` plus a shared group is the usual way to restrict
- it to the reverse proxy.
+ File permissions for the Unix domain socket, in octal.
+ Required when ``SERVE = unix``. The socket's group is the group the server
+ runs as, so ``660`` plus a shared group is the usual way to restrict it to
+ the reverse proxy.
Database
@@ -162,15 +213,13 @@ VC_FORMAT
VC_ALGORITHMS
List of acceptable cryptographic algorithms for the credential and for the
key binding JWT.
- Required, and must not be empty. Format is a bracketed comma-separated
- list. Example: ``{ES256}`` or ``{ES256, ES384}``.
+ Required, and must not be empty. Example: ``{ES256}`` or ``{ES256, ES384}``.
VC_CLAIMS
The complete set of claim names that exist in the verifiable credential
type. This defines which claims are valid and can be requested by clients
via the ``scope`` parameter. The gateway validates that all requested
- claims are in this set. Required, and must not be empty. Format is a
- bracketed comma-separated list.
+ claims are in this set. Required, and must not be empty.
Example: ``{family_name, given_name, birth_date, age_over_18}``.
@@ -183,7 +232,7 @@ ALLOWED_SCOPES
in ``VC_CLAIMS``. If not set, clients may request any claim from
``VC_CLAIMS``.
Use this to limit what data clients can access without changing the
- credential configuration. Format is a bracketed comma-separated list.
+ credential configuration.
Example: ``{family_name, age_over_18}``.
This is the operator's lever for data minimisation, and it is worth
@@ -242,7 +291,6 @@ REDIRECT_URI
ACCEPTED_ISSUER_DIDS
List of trusted issuer DIDs for verifiable credentials.
- Format is a bracketed comma-separated list.
Example: ``{did:tdw:issuer1, did:tdw:issuer2}``.
.. note::
@@ -259,14 +307,15 @@ EXAMPLE CONFIGURATION
::
[kych-oauth2-gateway]
- # TCP binding (use either TCP or Unix socket, not both)
- #HOST = 127.0.0.1
- #PORT = 8080
-
- # Unix socket binding
+ # Where to listen: unix, tcp or systemd
+ SERVE = unix
UNIXPATH = /run/kych/kych.sock
UNIXPATH_MODE = 660
+ # For SERVE = tcp instead
+ #BIND_TO = 127.0.0.1
+ #PORT = 8080
+
# Database connection
DATABASE = postgres:///kych?host=/var/run/postgresql
@@ -285,10 +334,11 @@ EXAMPLE CONFIGURATION
VC_ALGORITHMS = {ES256}
VC_CLAIMS = {family_name, given_name, birth_date, nationality}
- # Client configuration
+ # Client configuration, with the secrets kept in a separate file
+ @inline-secret@ client_merchant secrets/merchant.conf
+
[client_merchant]
CLIENT_ID = merchant_prod_01
- CLIENT_SECRET = supersecretkey
VERIFIER_URL = https://verifier.swiyu.admin.ch
VERIFIER_MANAGEMENT_API_PATH = /management/api/verifications
REDIRECT_URI = https://merchant.example.com/kyc/callback
diff --git a/documentation/taler-docs/taler-kych-manual.rst b/documentation/taler-docs/taler-kych-manual.rst
@@ -178,9 +178,12 @@ Configuration
Configuration file location
---------------------------
-KyCH reads its configuration from a file in INI format. The default location
-is ``/etc/kych/kych.conf``. You can specify an alternative path using the
-``--config`` or ``-c`` command-line option.
+KyCH reads its configuration from a file in the GNU Taler configuration
+format, the same one the rest of the Taler stack uses: INI-like, with
+``@inline@`` includes and ``$VAR`` expansion in paths. The default location is
+``/etc/kych/kych.conf``. You can specify an alternative path using the
+``--config`` or ``-c`` command-line option. See kych.conf(5) for the format
+and for the full list of options.
Main configuration section
@@ -192,15 +195,16 @@ The main configuration is specified in the ``[kych-oauth2-gateway]`` section:
:caption: /etc/kych/kych.conf
[kych-oauth2-gateway]
- # Server binding - choose either TCP or Unix socket (not both)
+ # Server binding: unix, tcp or systemd
+ SERVE = unix
- # For TCP binding:
- #HOST = 127.0.0.1
- #PORT = 8080
-
- # For Unix socket binding:
+ # For SERVE = unix:
UNIXPATH = /run/kych/kych.sock
- UNIXPATH_MODE = 666
+ UNIXPATH_MODE = 660
+
+ # For SERVE = tcp:
+ #BIND_TO = 127.0.0.1
+ #PORT = 8080
# Database connection string
DATABASE = postgres://kych:password@localhost/kych
@@ -218,27 +222,33 @@ The main configuration is specified in the ``[kych-oauth2-gateway]`` section:
Server binding options
----------------------
-KyCH can listen on either a TCP socket or a Unix domain socket, but not both
-simultaneously. For production deployments behind a reverse proxy (recommended),
-Unix sockets provide better security by avoiding network exposure.
+``SERVE`` selects one of three ways to obtain the listening socket. For
+production deployments behind a reverse proxy (recommended), a Unix socket
+avoids network exposure altogether.
+
+**Unix socket binding (SERVE = unix):**
+
+Use this for production deployments behind nginx or another reverse proxy.
-**TCP binding:**
+- ``UNIXPATH``: Path to the Unix domain socket file (e.g., ``/run/kych/kych.sock``).
+ Ensure the directory exists and is writable by the KyCH process.
+- ``UNIXPATH_MODE``: Octal permission mode for the socket file. Use ``660``
+ so that only the reverse proxy user, which shares KyCH's group, can reach it.
+
+**TCP binding (SERVE = tcp):**
Use TCP binding for development or when KyCH must be accessible over the network.
-- ``HOST``: The IP address to bind to. Use ``127.0.0.1`` for localhost-only
+- ``BIND_TO``: The IP address to bind to. Use ``127.0.0.1`` for localhost-only
access or ``0.0.0.0`` to accept connections on all interfaces.
- ``PORT``: The TCP port to listen on (e.g., ``8080``).
-**Unix socket binding:**
+**Socket activation (SERVE = systemd):**
-Use Unix sockets for production deployments behind nginx or another reverse proxy.
-This avoids exposing KyCH directly to the network.
-
-- ``UNIXPATH``: Path to the Unix domain socket file (e.g., ``/run/kych/kych.sock``).
- Ensure the directory exists and is writable by the KyCH process.
-- ``UNIXPATH_MODE``: Octal permission mode for the socket file (default: ``666``).
- Set to ``660`` if only the reverse proxy user needs access.
+KyCH takes the listening socket from the service manager rather than binding
+one, so systemd owns the socket, its permissions and its lifetime, and the
+service can start on the first connection. Nothing else needs to be
+configured; the Debian package ships a ``kych.socket`` unit for it.
Database configuration
diff --git a/kych_oauth2_gateway/Cargo.lock b/kych_oauth2_gateway/Cargo.lock
@@ -245,6 +245,12 @@ dependencies = [
[[package]]
name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
@@ -290,6 +296,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -385,30 +400,24 @@ dependencies = [
]
[[package]]
-name = "const-oid"
-version = "0.9.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
-
-[[package]]
-name = "const-random"
-version = "0.1.18"
+name = "compact_str"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [
- "const-random-macro",
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
]
[[package]]
-name = "const-random-macro"
-version = "0.1.16"
+name = "const-oid"
+version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
-dependencies = [
- "getrandom 0.2.17",
- "once_cell",
- "tiny-keccak",
-]
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
@@ -476,12 +485,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
-name = "crunchy"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
-
-[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -492,6 +495,37 @@ dependencies = [
]
[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -503,12 +537,6 @@ dependencies = [
]
[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-
-[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -532,15 +560,6 @@ dependencies = [
]
[[package]]
-name = "dlv-list"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
-dependencies = [
- "const-random",
-]
-
-[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -776,6 +795,12 @@ dependencies = [
]
[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -796,12 +821,6 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.14.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
-
-[[package]]
-name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
@@ -1168,6 +1187,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1192,15 +1248,14 @@ dependencies = [
"mockito",
"rand 0.8.7",
"reqwest",
- "rust-ini",
"serde",
"serde_json",
"sqlx",
+ "taler-config",
"tokio",
"tower",
"tower-http",
"tracing",
- "tracing-subscriber",
"urlencoding",
"uuid",
]
@@ -1232,7 +1287,7 @@ version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"libc",
"plain",
"redox_syscall 0.9.1",
@@ -1255,6 +1310,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
+name = "listenfd"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba"
+dependencies = [
+ "libc",
+ "uuid",
+ "winapi",
+]
+
+[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1276,15 +1342,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
-name = "matchers"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
-dependencies = [
- "regex-automata",
-]
-
-[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1417,12 +1474,6 @@ dependencies = [
]
[[package]]
-name = "num-conv"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
-
-[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1452,15 +1503,6 @@ dependencies = [
]
[[package]]
-name = "num_threads"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
-dependencies = [
- "libc",
-]
-
-[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1478,7 +1520,7 @@ version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"cfg-if",
"foreign-types",
"libc",
@@ -1516,16 +1558,6 @@ dependencies = [
]
[[package]]
-name = "ordered-multimap"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
-dependencies = [
- "dlv-list",
- "hashbrown 0.14.5",
-]
-
-[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1609,6 +1641,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1618,12 +1665,6 @@ dependencies = [
]
[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1727,7 +1768,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
]
[[package]]
@@ -1736,7 +1777,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
]
[[package]]
@@ -1843,22 +1884,12 @@ dependencies = [
]
[[package]]
-name = "rust-ini"
-version = "0.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
-dependencies = [
- "cfg-if",
- "ordered-multimap",
-]
-
-[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
@@ -1931,7 +1962,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -2216,7 +2247,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
- "bitflags",
+ "bitflags 2.13.1",
"byteorder",
"bytes",
"chrono",
@@ -2260,7 +2291,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
- "bitflags",
+ "bitflags 2.13.1",
"byteorder",
"chrono",
"crc",
@@ -2324,6 +2355,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2394,7 +2431,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -2410,6 +2447,29 @@ dependencies = [
]
[[package]]
+name = "taler-config"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "axum",
+ "clap",
+ "compact_str",
+ "glob",
+ "indexmap",
+ "jiff",
+ "listenfd",
+ "rand 0.8.7",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
+[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2452,47 +2512,6 @@ dependencies = [
]
[[package]]
-name = "time"
-version = "0.3.55"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
-dependencies = [
- "deranged",
- "libc",
- "num-conv",
- "num_threads",
- "powerfmt",
- "serde_core",
- "time-core",
- "time-macros",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
-
-[[package]]
-name = "time-macros"
-version = "0.2.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
-dependencies = [
- "num-conv",
- "time-core",
-]
-
-[[package]]
-name = "tiny-keccak"
-version = "2.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
-dependencies = [
- "crunchy",
-]
-
-[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2612,7 +2631,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
"bytes",
"futures-core",
"futures-util",
@@ -2630,7 +2649,6 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
- "tracing",
"url",
]
@@ -2696,15 +2714,10 @@ version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
- "matchers",
"nu-ansi-term",
- "once_cell",
- "regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
- "time",
- "tracing",
"tracing-core",
"tracing-log",
]
@@ -2926,6 +2939,28 @@ dependencies = [
]
[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml
@@ -17,10 +17,13 @@ name = "kych-client-management"
path = "src/bin/client_management_cli.rs"
[dependencies]
+# Configuration parsing, logging and socket binding, vendored from taler-rust
+taler-config = { path = "../taler-config" }
+
# Web framework
axum = "0.8.6"
tokio = { version = "1.48.0", features = ["full"] }
-tower-http = { version = "0.6.6", features = ["trace", "fs"] }
+tower-http = { version = "0.6.6", features = ["fs"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
@@ -30,16 +33,14 @@ serde_json = "1.0.145"
reqwest = { version = "0.12", features = ["json"] }
# Configuration
-rust-ini = "0.21.3"
clap = { version = "4.5.49", features = ["derive"] }
# Utilities
uuid = { version = "1.18.1", features = ["v4", "serde"] }
chrono = { version = "0.4.42", features = ["serde"] }
-# Logging
+# Logging (the subscriber itself is set up by taler-config)
tracing = "0.1.41"
-tracing-subscriber = { version = "0.3.20", features = ["env-filter", "local-time"] }
# Error handling
anyhow = "1.0.100"
diff --git a/kych_oauth2_gateway/README b/kych_oauth2_gateway/README
@@ -49,8 +49,11 @@ Configuration
Copy kych.conf.example to kych.conf and configure:
[kych-oauth2-gateway]
- UNIXPATH = /path/to/socket # Unix socket path (or use HOST/PORT)
- DATABASE = postgres://... # PostgreSQL connection string
+ # Note: comments must stand on a line of their own, never after a value.
+ SERVE = unix
+ UNIXPATH = /path/to/socket
+ UNIXPATH_MODE = 660
+ DATABASE = postgres://...
# Cryptographic parameters
NONCE_BYTES = 32
diff --git a/kych_oauth2_gateway/src/bin/client_management_cli.rs b/kych_oauth2_gateway/src/bin/client_management_cli.rs
@@ -2,7 +2,8 @@
//!
//! Command-line tool for managing OAuth2 Gateway clients.
//!
-//! Usage:
+//! Usage (`--config` is optional; without it the standard search path applies,
+//! ending at /etc/kych/kych.conf):
//! kych-client-management --config kych.conf list
//! kych-client-management --config kych.conf show <client_id>
//! kych-client-management --config kych.conf create --client-id <id> --secret <secret> ...
@@ -12,17 +13,20 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
-use kych_oauth2_gateway_lib::{config::Config, db};
+use kych_oauth2_gateway_lib::{
+ config::{CONFIG_SOURCE, Config},
+ db,
+};
use std::collections::HashSet;
+use taler_config::{CommonArgs, taler_main};
#[derive(Parser, Debug)]
-#[command(name = "client-mgmt")]
+#[command(name = "kych-client-management")]
#[command(version)]
-#[command(about = "OAuth2 Gateway client management CLI")]
+#[command(about = "manage OAuth 2.0 clients of the KyCH gateway")]
struct Args {
- /// Path to kych.conf configuration file
- #[arg(long, short = 'c', required = true)]
- config: String,
+ #[command(flatten)]
+ common: CommonArgs,
#[command(subcommand)]
command: Commands,
@@ -94,18 +98,20 @@ enum Commands {
},
}
-#[tokio::main]
-async fn main() -> Result<()> {
+fn main() {
let args = Args::parse();
- let config = Config::from_file(&args.config)
- .context(format!("Failed to load config from '{}'", args.config))?;
+ taler_main(CONFIG_SOURCE, args.common, async |cfg| {
+ run(Config::parse(cfg)?, args.command).await
+ })
+}
- let pool = db::create_pool(&config.database.url)
+async fn run(config: Config, command: Commands) -> Result<()> {
+ let pool = db::create_pool(config.database.clone())
.await
.context("Failed to connect to database")?;
- match args.command {
+ match command {
Commands::List => cmd_list_clients(&pool).await?,
Commands::Show { client_id } => cmd_show_client(&pool, &client_id).await?,
Commands::Create {
diff --git a/kych_oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs
@@ -1,60 +1,39 @@
-use anyhow::{Context, Result};
-use serde::{Deserialize, Serialize};
-use ini::Ini;
+//! KyCH configuration.
+//!
+//! The file format, the search path for it and the accessors used below all
+//! come from `taler-config`, the vendored copy of the GNU Taler configuration
+//! parser; see `../../taler-config/README`. Only the meaning of the options
+//! is KyCH's own.
+
use std::collections::HashSet;
-use std::path::Path;
+
+use sqlx::postgres::PgConnectOptions;
+use taler_config::{
+ config::{Config as TalerConfig, Section, ValueErr, parser::ConfigSource},
+ serve::Serve,
+};
+
+/// Where the tools look for their configuration when `-c` is not given:
+/// `$XDG_CONFIG_HOME/kych.conf`, `~/.config/kych.conf`, `/etc/kych.conf`,
+/// `/etc/kych/kych.conf`. The executable name is what `$PREFIX` and the other
+/// `[paths]` variables are derived from, by locating it on `$PATH`.
+pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("kych", "kych", "kych-oauth2-gateway");
const MAIN_SECTION: &str = "kych-oauth2-gateway";
+const CLIENT_SECTION_PREFIX: &str = "client_";
+const DEFAULT_VERIFIER_MANAGEMENT_API_PATH: &str = "/management/api/verifications";
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone)]
pub struct Config {
- pub server: ServerConfig,
- pub database: DatabaseConfig,
+ pub serve: Serve,
+ pub database: PgConnectOptions,
pub crypto: CryptoConfig,
pub vc: VcConfig,
pub allowed_scopes: Option<Vec<String>>,
pub clients: Vec<ClientConfig>,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ServerConfig {
- pub host: Option<String>,
- pub port: Option<u16>,
- pub socket_path: Option<String>,
- pub socket_mode: u32,
-}
-
-impl ServerConfig {
- pub fn validate(&self) -> Result<()> {
- let has_tcp = self.host.is_some() || self.port.is_some();
- let has_unix = self.socket_path.is_some();
-
- if has_tcp && has_unix {
- anyhow::bail!("Cannot specify both TCP (HOST/PORT) and Unix socket (UNIXPATH)");
- }
-
- if !has_tcp && !has_unix {
- anyhow::bail!("Must specify either TCP (HOST/PORT) or Unix socket (UNIXPATH)");
- }
-
- if has_tcp && (self.host.is_none() || self.port.is_none()) {
- anyhow::bail!("HOST and PORT must both be specified for TCP");
- }
-
- Ok(())
- }
-
- pub fn is_unix_socket(&self) -> bool {
- self.socket_path.is_some()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct DatabaseConfig {
- pub url: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone)]
pub struct CryptoConfig {
pub nonce_bytes: usize,
pub token_bytes: usize,
@@ -62,7 +41,18 @@ pub struct CryptoConfig {
pub authorization_code_ttl_minutes: i64,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone)]
+pub struct VcConfig {
+ pub vc_type: String,
+ pub vc_format: String,
+ pub vc_algorithms: Vec<String>,
+ pub vc_claims: HashSet<String>,
+}
+
+/// One `[client_*]` section. Note that the running gateway never looks at
+/// these: they are the input to `kych-client-management sync`, which writes
+/// them to the `oauth2gw.clients` table the gateway does read.
+#[derive(Debug, Clone)]
pub struct ClientConfig {
pub section_name: String,
pub client_id: String,
@@ -73,322 +63,246 @@ pub struct ClientConfig {
pub accepted_issuer_dids: Option<String>,
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct VcConfig {
- pub vc_type: String,
- pub vc_format: String,
- pub vc_algorithms: Vec<String>,
- pub vc_claims: HashSet<String>,
-}
-
impl Config {
- pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
- let ini = Ini::load_from_file(path.as_ref())
- .context("Failed to load config file")?;
-
- let main_section = ini
- .section(Some(MAIN_SECTION))
- .context(format!("Missing [{}] section", MAIN_SECTION))?;
-
- let host = main_section.get("HOST")
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
- let port = main_section
- .get("PORT")
- .filter(|s| !s.is_empty())
- .map(|s| s.parse::<u16>())
- .transpose()
- .context("Invalid PORT")?;
- let socket_path = main_section.get("UNIXPATH")
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
- let socket_mode = main_section
- .get("UNIXPATH_MODE")
- .filter(|s| !s.is_empty())
- .map(|s| u32::from_str_radix(s, 8))
- .transpose()
- .context("Invalid UNIXPATH_MODE (expected octal, e.g. 666)")?
- .unwrap_or(0o666);
-
- let server = ServerConfig {
- host,
- port,
- socket_path,
- socket_mode,
- };
-
- server.validate()?;
-
- let database = DatabaseConfig {
- url: main_section
- .get("DATABASE")
- .context("Missing DATABASE")?
- .to_string(),
- };
+ /// Interpret an already-parsed configuration file.
+ pub fn parse(cfg: &TalerConfig) -> Result<Self, ValueErr> {
+ let main = cfg.section(MAIN_SECTION);
let crypto = CryptoConfig {
- nonce_bytes: main_section
- .get("NONCE_BYTES")
- .context("Missing NONCE_BYTES")?
- .parse()
- .context("Invalid NONCE_BYTES")?,
- token_bytes: main_section
- .get("TOKEN_BYTES")
- .context("Missing TOKEN_BYTES")?
- .parse()
- .context("Invalid TOKEN_BYTES")?,
- authorization_code_bytes: main_section
- .get("AUTH_CODE_BYTES")
- .context("Missing AUTH_CODE_BYTES")?
- .parse()
- .context("Invalid AUTH_CODE_BYTES")?,
- authorization_code_ttl_minutes: main_section
- .get("AUTH_CODE_TTL_MINUTES")
- .unwrap_or("10")
- .parse()
- .context("Invalid AUTH_CODE_TTL_MINUTES")?,
- };
-
- let allowed_scopes = match main_section.get("ALLOWED_SCOPES") {
- Some(raw) if !raw.trim().is_empty() => Some(parse_allowed_scopes(raw)?),
- _ => None,
+ nonce_bytes: main.number("NONCE_BYTES").require()?,
+ token_bytes: main.number("TOKEN_BYTES").require()?,
+ authorization_code_bytes: main.number("AUTH_CODE_BYTES").require()?,
+ authorization_code_ttl_minutes: main.number("AUTH_CODE_TTL_MINUTES").default(10)?,
};
- let vc_type = main_section
- .get("VC_TYPE")
- .filter(|s| !s.is_empty())
- .context("missing required config: VC_TYPE")?
- .to_string();
-
- let vc_format = main_section
- .get("VC_FORMAT")
- .filter(|s| !s.is_empty())
- .context("missing required config: VC_FORMAT")?
- .to_string();
-
- let vc_algorithms = parse_bracketed_list(
- main_section
- .get("VC_ALGORITHMS")
- .context("missing required config: VC_ALGORITHMS")?,
- "VC_ALGORITHMS",
- )?;
- if vc_algorithms.is_empty() {
- anyhow::bail!("VC_ALGORITHMS must contain at least one algorithm");
- }
-
- let vc_claims_list = parse_bracketed_list(
- main_section
- .get("VC_CLAIMS")
- .context("missing required config: VC_CLAIMS")?,
- "VC_CLAIMS",
- )?;
- if vc_claims_list.is_empty() {
- anyhow::bail!("VC_CLAIMS must contain at least one claim");
- }
- let vc_claims: HashSet<String> = vc_claims_list.into_iter().collect();
-
let vc = VcConfig {
- vc_type,
- vc_format,
- vc_algorithms,
- vc_claims,
+ vc_type: main.str("VC_TYPE").require()?,
+ vc_format: main.str("VC_FORMAT").require()?,
+ vc_algorithms: list(&main, "VC_ALGORITHMS").require()?,
+ vc_claims: list(&main, "VC_CLAIMS").require()?.into_iter().collect(),
};
let mut clients = Vec::new();
- for (section_name, properties) in ini.iter() {
- let section_name = match section_name {
- Some(name) if name.starts_with("client_") => name,
- _ => continue,
- };
-
- let client_id = properties.get("CLIENT_ID")
- .context(format!("Missing CLIENT_ID in section [{}]", section_name))?
- .to_string();
- let client_secret = properties.get("CLIENT_SECRET")
- .context(format!("Missing CLIENT_SECRET in section [{}]", section_name))?
- .to_string();
- let verifier_url = properties.get("VERIFIER_URL")
- .context(format!("Missing VERIFIER_URL in section [{}]", section_name))?
- .to_string();
- let verifier_management_api_path = properties.get("VERIFIER_MANAGEMENT_API_PATH")
- .unwrap_or("/management/api/verifications")
- .to_string();
- let redirect_uri = properties.get("REDIRECT_URI")
- .filter(|s| !s.is_empty())
- .context(format!("Missing REDIRECT_URI in section [{}]", section_name))?
- .to_string();
- let accepted_issuer_dids = properties.get("ACCEPTED_ISSUER_DIDS")
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
-
+ for section in cfg.sections() {
+ if !section.name.starts_with(CLIENT_SECTION_PREFIX) {
+ continue;
+ }
clients.push(ClientConfig {
- section_name: section_name.to_string(),
- client_id,
- client_secret,
- verifier_url,
- verifier_management_api_path,
- redirect_uri,
- accepted_issuer_dids,
+ section_name: section.name.to_owned(),
+ client_id: section.str("CLIENT_ID").require()?,
+ client_secret: section.str("CLIENT_SECRET").require()?,
+ verifier_url: section.str("VERIFIER_URL").require()?,
+ verifier_management_api_path: section
+ .str("VERIFIER_MANAGEMENT_API_PATH")
+ .default(DEFAULT_VERIFIER_MANAGEMENT_API_PATH.to_owned())?,
+ redirect_uri: section.str("REDIRECT_URI").require()?,
+ accepted_issuer_dids: section.str("ACCEPTED_ISSUER_DIDS").opt()?,
});
}
Ok(Config {
- server,
- database,
+ serve: Serve::parse(&main)?,
+ database: main.postgres("DATABASE").require()?,
crypto,
vc,
- allowed_scopes,
+ allowed_scopes: list(&main, "ALLOWED_SCOPES").opt()?,
clients,
})
}
}
-fn parse_allowed_scopes(raw: &str) -> Result<Vec<String>> {
- let trimmed = raw.trim();
- let trimmed = trimmed.strip_prefix('{').unwrap_or(trimmed);
- let trimmed = trimmed.strip_suffix('}').unwrap_or(trimmed);
-
- let scopes: Vec<String> = trimmed
- .split(|c: char| c == ',' || c.is_whitespace())
- .map(|s| s.trim())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string())
- .collect();
-
- if scopes.is_empty() {
- anyhow::bail!("ALLOWED_SCOPES must contain at least one scope");
- }
-
- Ok(scopes)
-}
-
-fn parse_bracketed_list(value: &str, field_name: &str) -> Result<Vec<String>> {
- let trimmed = value.trim();
- if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
- anyhow::bail!("invalid {} format: expected {{item1, item2, ...}}", field_name);
- }
- let inner = &trimmed[1..trimmed.len() - 1];
- let items: Vec<String> = inner
- .split(',')
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .collect();
- Ok(items)
+/// A `{a, b, c}` list. The braces are optional and both commas and whitespace
+/// separate, so `{a, b}`, `a, b` and `a b` all parse; an option that is present
+/// but holds no item at all is an error, since every list KyCH reads needs at
+/// least one entry to be useful.
+fn list<'cfg, 'arg>(
+ section: &Section<'cfg, 'arg>,
+ option: &'arg str,
+) -> taler_config::config::Value<'arg, Vec<String>> {
+ section.value("list", option, |raw| {
+ let trimmed = raw.trim();
+ let inner = match trimmed.strip_prefix('{') {
+ Some(rest) => rest
+ .strip_suffix('}')
+ .ok_or_else(|| format!("unbalanced braces in '{trimmed}'"))?,
+ None => trimmed,
+ };
+ let items: Vec<String> = inner
+ .split(|c: char| c == ',' || c.is_whitespace())
+ .map(str::trim)
+ .filter(|it| !it.is_empty())
+ .map(str::to_owned)
+ .collect();
+ if items.is_empty() {
+ return Err("expected at least one item".to_owned());
+ }
+ Ok(items)
+ })
}
#[cfg(test)]
mod tests {
use super::*;
- #[test]
- fn test_server_validate_tcp_ok() {
- let server = ServerConfig {
- host: Some("127.0.0.1".to_string()),
- port: Some(8080),
- socket_path: None,
- socket_mode: 0o666,
- };
-
- assert!(server.validate().is_ok());
+ /// A configuration with every required option set, so that each test can
+ /// override or drop exactly the one it is about.
+ const MINIMAL: &str = "\
+[kych-oauth2-gateway]
+SERVE = unix
+UNIXPATH = /run/kych/kych.sock
+UNIXPATH_MODE = 660
+DATABASE = postgres:///kych
+NONCE_BYTES = 32
+TOKEN_BYTES = 32
+AUTH_CODE_BYTES = 32
+VC_TYPE = betaid-sdjwt
+VC_FORMAT = vc+sd-jwt
+VC_ALGORITHMS = {ES256}
+VC_CLAIMS = {given_name, family_name, age_over_18}
+";
+
+ fn parse(extra: &str) -> Result<Config, String> {
+ let cfg = TalerConfig::from_mem(&format!("{MINIMAL}{extra}")).unwrap();
+ Config::parse(&cfg).map_err(|e| e.to_string())
}
- #[test]
- fn test_server_validate_unix_ok() {
- let server = ServerConfig {
- host: None,
- port: None,
- socket_path: Some("/tmp/kych.sock".to_string()),
- socket_mode: 0o666,
- };
-
- assert!(server.validate().is_ok());
+ fn without(option: &str) -> Result<Config, String> {
+ let stripped: String = MINIMAL
+ .lines()
+ .filter(|line| !line.starts_with(option))
+ .collect::<Vec<_>>()
+ .join("\n");
+ let cfg = TalerConfig::from_mem(&stripped).unwrap();
+ Config::parse(&cfg).map_err(|e| e.to_string())
}
#[test]
- fn test_server_validate_both_err() {
- let server = ServerConfig {
- host: Some("127.0.0.1".to_string()),
- port: Some(8080),
- socket_path: Some("/tmp/kych.sock".to_string()),
- socket_mode: 0o666,
- };
-
- assert!(server.validate().is_err());
+ fn minimal() {
+ let cfg = parse("").unwrap();
+ assert!(matches!(cfg.serve, Serve::Unix { .. }));
+ assert_eq!(32, cfg.crypto.nonce_bytes);
+ // Not in MINIMAL, so the documented default applies.
+ assert_eq!(10, cfg.crypto.authorization_code_ttl_minutes);
+ assert_eq!(vec!["ES256".to_owned()], cfg.vc.vc_algorithms);
+ assert_eq!(3, cfg.vc.vc_claims.len());
+ assert!(cfg.vc.vc_claims.contains("age_over_18"));
+ assert_eq!(None, cfg.allowed_scopes);
+ assert!(cfg.clients.is_empty());
}
#[test]
- fn test_server_validate_neither_err() {
- let server = ServerConfig {
- host: None,
- port: None,
- socket_path: None,
- socket_mode: 0o666,
- };
-
- assert!(server.validate().is_err());
+ fn missing_options_name_themselves() {
+ assert_eq!(
+ "Missing number option NONCE_BYTES in section [kych-oauth2-gateway]",
+ without("NONCE_BYTES").unwrap_err()
+ );
+ assert_eq!(
+ "Missing string option VC_TYPE in section [kych-oauth2-gateway]",
+ without("VC_TYPE").unwrap_err()
+ );
+ assert_eq!(
+ "Missing list option VC_CLAIMS in section [kych-oauth2-gateway]",
+ without("VC_CLAIMS").unwrap_err()
+ );
+ assert_eq!(
+ "Missing Postgres URI option DATABASE in section [kych-oauth2-gateway]",
+ without("DATABASE").unwrap_err()
+ );
}
#[test]
- fn test_server_validate_missing_port_err() {
- let server = ServerConfig {
- host: Some("127.0.0.1".to_string()),
- port: None,
- socket_path: None,
- socket_mode: 0o666,
- };
-
- assert!(server.validate().is_err());
- }
-
- #[test]
- fn test_parse_allowed_scopes_variants() {
- let scopes = parse_allowed_scopes("{a, b c}").unwrap();
- assert_eq!(scopes, vec!["a", "b", "c"]);
-
- let scopes = parse_allowed_scopes(" a b c ").unwrap();
- assert_eq!(scopes, vec!["a", "b", "c"]);
-
- let scopes = parse_allowed_scopes("a,b,c").unwrap();
- assert_eq!(scopes, vec!["a", "b", "c"]);
+ fn lists_accept_braces_commas_and_spaces() {
+ for raw in ["{a, b, c}", "a, b, c", "a b c", "{a b, c}", " {a,b,c} "] {
+ let cfg = parse(&format!("VC_ALGORITHMS = {raw}\n")).unwrap();
+ assert_eq!(vec!["a", "b", "c"], cfg.vc.vc_algorithms, "for '{raw}'");
+ }
}
#[test]
- fn test_parse_allowed_scopes_empty_err() {
- assert!(parse_allowed_scopes("").is_err());
- assert!(parse_allowed_scopes(" ").is_err());
- assert!(parse_allowed_scopes("{}").is_err());
- assert!(parse_allowed_scopes("{ }").is_err());
+ fn empty_and_malformed_lists_are_rejected() {
+ assert_eq!(
+ "Invalid list option VC_ALGORITHMS in section [kych-oauth2-gateway]: \
+ expected at least one item",
+ parse("VC_ALGORITHMS = {}\n").unwrap_err()
+ );
+ assert_eq!(
+ "Invalid list option VC_ALGORITHMS in section [kych-oauth2-gateway]: \
+ unbalanced braces in '{ES256'",
+ parse("VC_ALGORITHMS = {ES256\n").unwrap_err()
+ );
+ // An option present but empty reads as absent, so this is "missing".
+ assert_eq!(
+ "Missing list option VC_ALGORITHMS in section [kych-oauth2-gateway]",
+ parse("VC_ALGORITHMS =\n").unwrap_err()
+ );
}
#[test]
- fn test_parse_bracketed_list_valid() {
- let items = parse_bracketed_list("{a, b, c}", "TEST").unwrap();
- assert_eq!(items, vec!["a", "b", "c"]);
+ fn allowed_scopes_are_optional() {
+ let cfg = parse("ALLOWED_SCOPES = {age_over_18}\n").unwrap();
+ assert_eq!(Some(vec!["age_over_18".to_owned()]), cfg.allowed_scopes);
}
#[test]
- fn test_parse_bracketed_list_single_item() {
- let items = parse_bracketed_list("{ES256}", "TEST").unwrap();
- assert_eq!(items, vec!["ES256"]);
+ fn serve_modes() {
+ let cfg = parse("").unwrap();
+ let Serve::Unix { path, .. } = cfg.serve else {
+ panic!("expected a UNIX domain socket")
+ };
+ assert_eq!("/run/kych/kych.sock", path);
+
+ let cfg = TalerConfig::from_mem(
+ "[kych-oauth2-gateway]\nSERVE = systemd\nDATABASE = postgres:///kych\n\
+ NONCE_BYTES = 32\nTOKEN_BYTES = 32\nAUTH_CODE_BYTES = 32\nVC_TYPE = t\n\
+ VC_FORMAT = f\nVC_ALGORITHMS = {ES256}\nVC_CLAIMS = {a}\n",
+ )
+ .unwrap();
+ assert!(matches!(
+ Config::parse(&cfg).unwrap().serve,
+ Serve::Systemd
+ ));
}
#[test]
- fn test_parse_bracketed_list_extra_whitespace() {
- let items = parse_bracketed_list("{ a , b }", "TEST").unwrap();
- assert_eq!(items, vec!["a", "b"]);
+ fn client_sections() {
+ let cfg = parse(
+ "\n[client_exchange]\n\
+ CLIENT_ID = exchange-prod-01\n\
+ CLIENT_SECRET = s3cret\n\
+ VERIFIER_URL = https://verifier.example.com\n\
+ REDIRECT_URI = https://exchange.example.com/kyc-proof/kych\n\
+ ACCEPTED_ISSUER_DIDS = {did:tdw:example:issuer}\n",
+ )
+ .unwrap();
+
+ assert_eq!(1, cfg.clients.len());
+ let client = &cfg.clients[0];
+ assert_eq!("client_exchange", client.section_name);
+ assert_eq!("exchange-prod-01", client.client_id);
+ // Not given, so the default path applies.
+ assert_eq!(
+ DEFAULT_VERIFIER_MANAGEMENT_API_PATH,
+ client.verifier_management_api_path
+ );
+ assert_eq!(
+ Some("{did:tdw:example:issuer}".to_owned()),
+ client.accepted_issuer_dids
+ );
}
#[test]
- fn test_parse_bracketed_list_missing_braces() {
- let result = parse_bracketed_list("a, b", "TEST");
- assert!(result.is_err());
- let err = result.unwrap_err().to_string();
- assert!(err.contains("TEST"));
+ fn client_section_errors_name_the_section() {
+ assert_eq!(
+ "Missing string option CLIENT_SECRET in section [client_exchange]",
+ parse("\n[client_exchange]\nCLIENT_ID = exchange-prod-01\n").unwrap_err()
+ );
}
+ /// Sections whose name does not start with `client_` are ignored, and so
+ /// is the synthetic `[paths]` section the parser adds.
#[test]
- fn test_parse_bracketed_list_empty() {
- let items = parse_bracketed_list("{}", "TEST").unwrap();
- assert!(items.is_empty());
+ fn unrelated_sections_are_ignored() {
+ let cfg = parse("\n[paths]\nDATADIR = /tmp\n\n[something-else]\nKEY = value\n").unwrap();
+ assert!(cfg.clients.is_empty());
}
}
diff --git a/kych_oauth2_gateway/src/db/mod.rs b/kych_oauth2_gateway/src/db/mod.rs
@@ -1,7 +1,7 @@
// Database module for OAuth2 Gateway
// Provides database connection pooling and business logic operations
-use sqlx::{PgPool, postgres::PgPoolOptions};
+use sqlx::{PgPool, postgres::{PgConnectOptions, PgPoolOptions}};
use anyhow::{Result, Context};
pub mod sessions;
@@ -12,7 +12,7 @@ pub mod authorization_codes;
/// Create a PostgreSQL connection pool
///
/// # Arguments
-/// * `database_url` - PostgreSQL connection string (e.g., "postgresql://user:pass@localhost/dbname")
+/// * `options` - connection parameters, as parsed from the DATABASE option
///
/// # Returns
/// Connection pool ready for use
@@ -20,10 +20,10 @@ pub mod authorization_codes;
/// # Notes
/// Assumes the database schema is already set up via migrations.
/// Run scripts/setup_test_db.sh to initialize the database.
-pub async fn create_pool(database_url: &str) -> Result<PgPool> {
+pub async fn create_pool(options: PgConnectOptions) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(10)
- .connect(database_url)
+ .connect_with(options)
.await
.context("Failed to connect to PostgreSQL")?;
diff --git a/kych_oauth2_gateway/src/main.rs b/kych_oauth2_gateway/src/main.rs
@@ -1,95 +1,47 @@
-use anyhow::Result;
use axum::{
Router,
routing::{get, post},
};
use clap::Parser;
-use kych_oauth2_gateway_lib::{config::Config, db, handlers, state::AppState};
-use std::{fs, os::unix::fs::PermissionsExt};
-use tower_http::{services::ServeDir, trace::TraceLayer};
-use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
+use kych_oauth2_gateway_lib::{
+ config::{CONFIG_SOURCE, Config},
+ db, handlers,
+ state::AppState,
+};
+use taler_config::{CommonArgs, serve::serve, taler_main};
+use tower_http::services::ServeDir;
#[derive(Parser, Debug)]
-#[command(version)]
+#[command(version, about = "OAuth 2.0 gateway for SWIYU credential verification")]
struct Args {
- #[arg(short = 'c', long = "config", value_name = "FILE")]
- config: String,
-
- #[arg(short = 'L', long = "log-level", value_name = "LEVEL", default_value = "INFO")]
- log_level: String,
+ #[command(flatten)]
+ common: CommonArgs,
}
-#[tokio::main]
-async fn main() -> Result<()> {
+fn main() {
let args = Args::parse();
- let level = args.log_level.to_lowercase();
- let filter = format!(
- "kych_oauth2_gateway={},kych_oauth2_gateway_lib={},tower_http={},sqlx=warn",
- level, level, level
- );
-
- tracing_subscriber::registry()
- .with(
- tracing_subscriber::EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| filter.into()),
- )
- .with(
- tracing_subscriber::fmt::layer()
- .compact()
- .with_ansi(false)
- .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()),
- )
- .init();
-
- tracing::info!("Starting Kych OAuth2 Gateway v{}", env!("CARGO_PKG_VERSION"));
- tracing::info!("Loading configuration from: {}", args.config);
-
- let config = Config::from_file(&args.config)?;
-
- tracing::info!("Connecting to database: {}", config.database.url);
- let pool = db::create_pool(&config.database.url).await?;
-
- let state = AppState::new(config.clone(), pool);
-
- let app = Router::new()
- .route("/config", get(handlers::config))
- .route("/setup/{client_id}", post(handlers::setup))
- .route("/authorize/{nonce}", get(handlers::authorize))
- .route("/token", post(handlers::token))
- .route("/info", get(handlers::info))
- .route("/notification", post(handlers::notification_webhook))
- .route("/status/{verification_id}", get(handlers::status))
- .route("/finalize/{verification_id}", get(handlers::finalize))
- .nest_service("/js", ServeDir::new("js"))
- .layer(TraceLayer::new_for_http())
- .with_state(state);
-
- if config.server.is_unix_socket() {
- let socket_path = config.server.socket_path.as_ref().unwrap();
- let socket_mode = config.server.socket_mode;
-
- if std::path::Path::new(socket_path).exists() {
- tracing::warn!("Removing left-over `{}' from previous execution", socket_path);
- std::fs::remove_file(socket_path)?;
- }
-
- let listener = tokio::net::UnixListener::bind(socket_path)?;
- let permissions = std::fs::Permissions::from_mode(socket_mode);
- fs::set_permissions(socket_path, permissions)?;
- tracing::info!("set socket '{}' to mode {:o}", socket_path, socket_mode);
-
- axum::serve(listener, app).await?;
- } else {
- let host = config.server.host.as_ref().unwrap();
- let port = config.server.port.unwrap();
- let addr = format!("{}:{}", host, port);
-
- let listener = tokio::net::TcpListener::bind(&addr).await?;
- tracing::info!("Server listening on {}", addr);
-
- axum::serve(listener, app).await?;
- }
-
- Ok(())
+ taler_main(CONFIG_SOURCE, args.common, async |cfg| {
+ tracing::info!(target: "kych", "Starting KyCH OAuth2 gateway v{}", env!("CARGO_PKG_VERSION"));
+
+ let config = Config::parse(cfg)?;
+ let pool = db::create_pool(config.database.clone()).await?;
+ let listen = config.serve.clone();
+ let state = AppState::new(config, pool);
+
+ let app = Router::new()
+ .route("/config", get(handlers::config))
+ .route("/setup/{client_id}", post(handlers::setup))
+ .route("/authorize/{nonce}", get(handlers::authorize))
+ .route("/token", post(handlers::token))
+ .route("/info", get(handlers::info))
+ .route("/notification", post(handlers::notification_webhook))
+ .route("/status/{verification_id}", get(handlers::status))
+ .route("/finalize/{verification_id}", get(handlers::finalize))
+ .nest_service("/js", ServeDir::new("js"))
+ .with_state(state);
+
+ serve(app, &listen).await?;
+ Ok(())
+ })
}
diff --git a/kych_oauth2_gateway/tests/handlers_integration.rs b/kych_oauth2_gateway/tests/handlers_integration.rs
@@ -6,7 +6,7 @@ use axum::{
Router,
};
use kych_oauth2_gateway_lib::{
- config::{ClientConfig, Config, CryptoConfig, DatabaseConfig, ServerConfig, VcConfig},
+ config::Config,
db::{authorization_codes, clients, sessions},
handlers,
models::{
@@ -15,8 +15,8 @@ use kych_oauth2_gateway_lib::{
},
state::AppState,
};
-use std::collections::HashSet;
use mockito::Server;
+use taler_config::config::Config as TalerConfig;
use serde_json::Value;
use sqlx::{PgPool, postgres::PgPoolOptions};
use tower::util::ServiceExt;
@@ -40,42 +40,28 @@ async fn get_pool() -> Option<PgPool> {
}
}
+/// Build the gateway configuration the way the daemon does, by parsing one,
+/// so that these tests break if the file format and the handlers drift apart.
fn test_config(database_url: &str) -> Config {
- Config {
- server: ServerConfig {
- host: Some("127.0.0.1".to_string()),
- port: Some(8080),
- socket_path: None,
- socket_mode: 0o666,
- },
- database: DatabaseConfig {
- url: database_url.to_string(),
- },
- crypto: CryptoConfig {
- nonce_bytes: 32,
- token_bytes: 32,
- authorization_code_bytes: 32,
- authorization_code_ttl_minutes: 10,
- },
- vc: VcConfig {
- vc_type: "betaid-sdjwt".to_string(),
- vc_format: "vc+sd-jwt".to_string(),
- vc_algorithms: vec!["ES256".to_string()],
- vc_claims: [
- "first_name",
- "last_name",
- "family_name",
- "given_name",
- "birth_date",
- "age_over_18",
- ]
- .iter()
- .map(|s| s.to_string())
- .collect::<HashSet<String>>(),
- },
- allowed_scopes: None,
- clients: Vec::<ClientConfig>::new(),
- }
+ let text = format!(
+ "\
+[kych-oauth2-gateway]
+SERVE = tcp
+BIND_TO = 127.0.0.1
+PORT = 8080
+DATABASE = {database_url}
+NONCE_BYTES = 32
+TOKEN_BYTES = 32
+AUTH_CODE_BYTES = 32
+AUTH_CODE_TTL_MINUTES = 10
+VC_TYPE = betaid-sdjwt
+VC_FORMAT = vc+sd-jwt
+VC_ALGORITHMS = {{ES256}}
+VC_CLAIMS = {{first_name, last_name, family_name, given_name, birth_date, age_over_18}}
+"
+ );
+ Config::parse(&TalerConfig::from_mem(&text).expect("test config does not parse"))
+ .expect("test config is not valid")
}
fn build_app(state: AppState) -> Router {
diff --git a/taler-config/Cargo.lock b/taler-config/Cargo.lock
@@ -0,0 +1,2106 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libredox"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"
+dependencies = [
+ "bitflags 2.13.1",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.1",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "listenfd"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba"
+dependencies = [
+ "libc",
+ "uuid",
+ "winapi",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-postgres",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-postgres",
+ "syn 2.0.119",
+ "url",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags 2.13.1",
+ "byteorder",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "taler-config"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "axum",
+ "clap",
+ "compact_str",
+ "glob",
+ "indexmap",
+ "jiff",
+ "listenfd",
+ "rand",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "nu-ansi-term",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/taler-config/Cargo.toml b/taler-config/Cargo.toml
@@ -0,0 +1,45 @@
+[package]
+name = "taler-config"
+version = "0.0.1"
+edition = "2024"
+rust-version = "1.85"
+authors = ["Taler Systems SA <deb@taler.net>"]
+homepage = "https://taler.net/"
+repository = "https://git.taler.net/kych.git"
+license-file = "../COPYING"
+description = "Taler configuration parsing, logging and socket binding, vendored from taler-rust"
+publish = false
+
+[lib]
+doctest = false
+
+[dependencies]
+# config.rs
+glob = "0.3"
+indexmap = "2.7"
+thiserror = "2.0"
+url = "2.5"
+serde = "1.0"
+serde_json = "1.0"
+sqlx = { version = "0.8", default-features = false, features = ["postgres"] }
+
+# config.rs and log.rs
+compact_str = "0.9"
+# tz-system is what lets log timestamps carry the machine's UTC offset; without
+# it TalerFmt falls back to UTC and says so on every start.
+jiff = { version = "0.2", default-features = false, features = ["std", "tz-system"] }
+tracing = "0.1"
+
+# lib.rs and log.rs
+clap = { version = "4.5", features = ["derive"] }
+tracing-subscriber = "0.3"
+anyhow = "1.0"
+
+# serve.rs
+axum = "0.8"
+listenfd = "1.0"
+rand = "0.8"
+tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "signal"] }
+
+[dev-dependencies]
+tempfile = "3.15"
diff --git a/taler-config/README b/taler-config/README
@@ -0,0 +1,86 @@
+taler-config -- vendored from taler-rust
+========================================
+
+This crate is not developed here. It is a copy of the configuration parser,
+the logging setup and the socket-binding logic of the GNU Taler Rust tree,
+taken so that KyCH reads its configuration, writes its logs and binds its
+listening socket exactly the way every other Taler component does.
+
+Upstream: https://git.taler.net/taler-rust.git
+Vendored: commit 48c40c365ac671cbbe1ac2fb359e1a5cf7e165a3 (2026-08-04)
+
+Fix bugs upstream first, then re-vendor. Local changes here are a maintenance
+cost paid on every sync, so the list below is meant to stay short.
+
+
+What came from where
+--------------------
+
+ src/config.rs common/taler-common/src/config.rs
+ src/log.rs common/taler-common/src/log.rs (verbatim)
+ src/lib.rs common/taler-common/src/lib.rs (CommonArgs, taler_main)
+ src/serve.rs common/taler-api/src/lib.rs (Serve, Listener)
+ common/taler-api/src/config.rs (Serve::parse)
+ common/taler-api/src/api.rs (serve, shutdown_signal,
+ dyn_event, logger_middleware)
+
+Everything else in taler-common and taler-api -- amounts, payto URIs, IBAN,
+the Taler error codes, the Wire Gateway and Revenue APIs, the database pool
+and migration runner -- is deliberately absent. KyCH speaks OAuth 2.0 and
+OpenID4VP, not the Taler wire protocol, and pulling those in would drag
+aws-lc-rs, regex and the 150 kB generated error-code table into the Debian
+build for nothing.
+
+
+Deliberate differences from upstream
+------------------------------------
+
+config.rs
+
+ * `validate_base_url` is inlined at the top of the file instead of being
+ imported from `taler_common::types`.
+ * The `hex`, `b32` and `b64` accessors are gone; they call
+ `taler_common::encoding`.
+ * The `currency`, `amount` and `payto` accessors are gone; they need
+ `taler_common::types`. The `amount` test went with them.
+ * The `regex` accessor is gone, so the regex crate is not a dependency.
+ * `map_config!` expands to `$crate::config::MapErr` rather than the
+ hard-coded `::taler_common::config::MapErr`, which also makes the macro
+ usable from inside this crate.
+ * A `unix_mode` test was added, since KyCH is the first component where a
+ wrong UNIXPATH_MODE is a likely operator mistake.
+
+ Each removal is marked with a comment where the code used to be, so a diff
+ against upstream stays readable.
+
+lib.rs
+
+ * Only `CommonArgs` and `taler_main` were taken. `ExpoBackoffDecorr` has no
+ user here, and the mimalloc `#[global_allocator]` is a whole-program
+ decision that a library has no business making for its dependents.
+ * `CommonArgs` fields are public, and `--verbose` is not `hide = true`:
+ kych.conf(5) and kych-oauth2-gateway(1) document it.
+
+serve.rs
+
+ * `serve()` is a free function taking an `axum::Router` rather than a method
+ on the `TalerRouter` trait, and it does not call upstream's `finalize()`:
+ that adds a CORS layer and Taler-shaped 404/405 bodies, and KyCH's
+ endpoints answer with OAuth 2.0 error objects instead.
+ * The request logger keeps the task-id scope, the method/path/status/duration
+ line and `dyn_event!`, but drops the `LoggedError` branch, which formats a
+ Taler error code.
+ * `logger_middleware` uses rand 0.8 (`thread_rng`, `gen_range`) to match the
+ rest of KyCH; upstream is on rand 0.9 (`rng`, `random_range`). Change both
+ together or the tree ends up building two copies of rand.
+ * Graceful shutdown listens for SIGINT and SIGTERM only. The `lifetime`
+ request counter, used by upstream's tests to stop a server after N
+ requests, is not vendored.
+
+
+Notes
+-----
+
+The `#[cfg(test)] mod test` blocks came along with the code; `cargo test` from
+this directory runs them, and they are the fastest way to tell whether a
+re-vendored file still behaves.
diff --git a/taler-config/src/config.rs b/taler-config/src/config.rs
@@ -0,0 +1,1342 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025-2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::{
+ borrow::Cow,
+ fmt::{Debug, Display},
+ fs::Permissions,
+ os::unix::fs::PermissionsExt,
+ path::PathBuf,
+ str::FromStr,
+ sync::Arc,
+ time::Duration,
+};
+
+use compact_str::CompactString;
+use indexmap::IndexMap;
+use jiff::{SignedDuration, Span};
+use url::Url;
+
+/// Vendored from `taler_common::types::validate_base_url`; the rest of that
+/// module (amounts, payto, IBAN) is not needed here.
+fn validate_base_url(url: &Url) -> Result<(), String> {
+ if url.scheme() != "http" && url.scheme() != "https" {
+ Err(format!(
+ "only 'http' and 'https' are accepted for baseURL got '{}''",
+ url.scheme()
+ ))
+ } else if !url.has_host() {
+ Err(format!("missing host in baseURL got '{url}'"))
+ } else if url.query().is_some() {
+ Err(format!(
+ "require no query in baseURL got '{}'",
+ url.query().unwrap()
+ ))
+ } else if url.fragment().is_some() {
+ Err(format!(
+ "require no fragment in baseURL got '{}'",
+ url.fragment().unwrap()
+ ))
+ } else if !url.path().ends_with('/') {
+ Err(format!("baseURL path must end with / got '{}'", url.path()))
+ } else {
+ Ok(())
+ }
+}
+
+pub mod parser {
+ use std::{
+ borrow::Cow,
+ fmt::Display,
+ io::{BufRead, BufReader},
+ path::PathBuf,
+ str::FromStr,
+ sync::Arc,
+ };
+
+ use indexmap::IndexMap;
+ use tracing::{trace, warn};
+
+ use super::{Config, ValueErr};
+ use crate::config::{Inner, Line, Location, make_lowercase};
+
+ #[derive(Debug, thiserror::Error)]
+ pub enum ConfigErr {
+ #[error("config error, {0}")]
+ Parser(#[from] ParserErr),
+ #[error("invalid config, {0}")]
+ Value(#[from] ValueErr),
+ }
+
+ #[derive(Debug)]
+
+ pub enum ParserErr {
+ IO(Cow<'static, str>, PathBuf, std::io::Error),
+ Line(Cow<'static, str>, PathBuf, usize, Option<String>),
+ }
+
+ impl Display for ParserErr {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ParserErr::IO(action, path, err) => write!(
+ f,
+ "Could not {action} at '{}': {}",
+ path.to_string_lossy(),
+ err.kind()
+ ),
+ ParserErr::Line(msg, path, line, cause) => {
+ if let Some(cause) = cause {
+ write!(f, "{msg} at '{}:{line}': {cause}", path.to_string_lossy())
+ } else {
+ write!(f, "{msg} at '{}:{line}'", path.to_string_lossy())
+ }
+ }
+ }
+ }
+ }
+
+ impl std::error::Error for ParserErr {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ None
+ }
+
+ fn description(&self) -> &str {
+ "description() is deprecated; use Display"
+ }
+
+ fn cause(&self) -> Option<&dyn std::error::Error> {
+ self.source()
+ }
+ }
+
+ fn io_err(
+ action: impl Into<Cow<'static, str>>,
+ path: impl Into<PathBuf>,
+ err: std::io::Error,
+ ) -> ParserErr {
+ ParserErr::IO(action.into(), path.into(), err)
+ }
+ fn line_err(
+ msg: impl Into<Cow<'static, str>>,
+ path: impl Into<PathBuf>,
+ line: usize,
+ ) -> ParserErr {
+ ParserErr::Line(msg.into(), path.into(), line, None)
+ }
+ fn line_cause_err(
+ msg: impl Into<Cow<'static, str>>,
+ cause: impl Display,
+ path: impl Into<PathBuf>,
+ line: usize,
+ ) -> ParserErr {
+ ParserErr::Line(msg.into(), path.into(), line, Some(cause.to_string()))
+ }
+ pub struct Parser {
+ sections: IndexMap<String, IndexMap<String, Line>>,
+ files: Vec<PathBuf>,
+ install_path: PathBuf,
+ buf: String,
+ }
+
+ impl Parser {
+ pub fn empty() -> Self {
+ Self {
+ sections: IndexMap::new(),
+ files: Vec::new(),
+ install_path: PathBuf::new(),
+ buf: String::new(),
+ }
+ }
+
+ pub fn load_env(&mut self, src: ConfigSource) -> Result<(), ParserErr> {
+ let ConfigSource { project_name, .. } = src;
+
+ // Load default path
+ let dir = src
+ .install_path()
+ .map_err(|(p, e)| io_err("find installation path", p, e))?;
+ self.install_path = dir.clone();
+
+ let paths = IndexMap::from_iter(
+ [
+ ("PREFIX", dir.join("")),
+ ("BINDIR", dir.join("bin")),
+ ("LIBEXECDIR", dir.join(project_name).join("libexec")),
+ ("DOCDIR", dir.join("share").join("doc").join(project_name)),
+ ("ICONDIR", dir.join("bin").join("share").join("icons")),
+ ("LOCALEDIR", dir.join("share").join("locale")),
+ ("LIBDIR", dir.join("lib").join(project_name)),
+ ("DATADIR", dir.join("share").join(project_name)),
+ ]
+ .map(|(a, b)| {
+ (
+ a.to_owned(),
+ Line {
+ content: b.to_string_lossy().into_owned(),
+ loc: None,
+ },
+ )
+ }),
+ );
+ self.sections.insert("paths".to_owned(), paths);
+
+ // Load default configs
+ let cfg_dir = dir.join("share").join(project_name).join("config.d");
+ match std::fs::read_dir(&cfg_dir) {
+ Ok(entries) => {
+ for entry in entries {
+ match entry {
+ Ok(entry) => self.parse_file(entry.path(), 0)?,
+ Err(err) => {
+ warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err));
+ }
+ }
+ }
+ }
+ Err(err) => {
+ warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err))
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn parse_str(&mut self, str: &str) -> Result<(), ParserErr> {
+ self.parse(
+ std::io::Cursor::new(str),
+ PathBuf::from_str("mem").unwrap(),
+ 0,
+ )
+ }
+
+ pub fn parse_file(&mut self, src: PathBuf, depth: u8) -> Result<(), ParserErr> {
+ trace!(target: "config", "load file at '{}'", src.to_string_lossy());
+ match std::fs::File::open(&src) {
+ Ok(file) => self.parse(BufReader::new(file), src, depth + 1),
+ Err(e) => Err(io_err("read config", src, e)),
+ }
+ }
+
+ fn parse<B: BufRead>(
+ &mut self,
+ mut reader: B,
+ src: PathBuf,
+ depth: u8,
+ ) -> Result<(), ParserErr> {
+ let file = self.files.len();
+ self.files.push(src.clone());
+ let src = &src;
+
+ let mut current_section: Option<&mut IndexMap<String, Line>> = None;
+ let mut line = 0;
+
+ loop {
+ // Read a new line
+ line += 1;
+ self.buf.clear();
+ match reader.read_line(&mut self.buf) {
+ Ok(0) => break,
+ Ok(_) => {}
+ Err(e) => return Err(io_err("read config", src, e)),
+ }
+ // Trim whitespace
+ let l = self.buf.trim_ascii();
+
+ if l.is_empty() || l.starts_with(['#', '%']) {
+ // Skip empty lines and comments
+ continue;
+ } else if let Some(directive) = l.strip_prefix("@") {
+ // Parse directive
+ let Some((name, arg)) = directive.split_once('@') else {
+ return Err(line_err(format!("Invalid directive line '{l}'"), src, line));
+ };
+ let arg = arg.trim_ascii_start();
+ // Exit current section
+ current_section = None;
+ // Check current file has a parent
+ let Some(parent) = src.parent() else {
+ return Err(line_err("no parent", src, line));
+ };
+ // Check recursion depth
+ if depth > 128 {
+ return Err(line_err("Recursion limit in config inlining", src, line));
+ }
+
+ match make_lowercase(name).as_ref() {
+ "inline" => self.parse_file(parent.join(arg), depth)?,
+ "inline-matching" => {
+ let paths =
+ glob::glob(&parent.join(arg).to_string_lossy()).map_err(|e| {
+ line_cause_err("Malformed glob regex", e, src, line)
+ })?;
+ for path in paths {
+ let path =
+ path.map_err(|e| line_cause_err("Glob error", e, src, line))?;
+ self.parse_file(path, depth)?;
+ }
+ }
+ "inline-secret" => {
+ let (section, secret_file) = arg.split_once(" ").ok_or_else(||
+ line_err(
+ "Invalid configuration, @inline-secret@ directive requires exactly two arguments",
+ src,
+ line
+ )
+ )?;
+
+ let section = section.to_lowercase();
+ let mut secret_cfg = Parser::empty();
+
+ if let Err(e) = secret_cfg.parse_file(parent.join(secret_file), depth) {
+ if let ParserErr::IO(_, path, err) = e {
+ warn!(target: "config", "{}", io_err(format!("read secret section [{section}]"), &path, err))
+ } else {
+ return Err(e);
+ }
+ } else if let Some(secret_section) =
+ secret_cfg.sections.swap_remove(§ion)
+ {
+ self.sections
+ .entry(section)
+ .or_default()
+ .extend(secret_section);
+ } else {
+ warn!(target: "config", "{}", line_err(format!("Configuration file at '{secret_file}' loaded with @inline-secret@ does not contain section [{section}]"), src, line));
+ }
+ }
+ unknown => {
+ return Err(line_err(
+ format!("Invalid directive '{unknown}'"),
+ src,
+ line,
+ ));
+ }
+ }
+ } else if let Some(section) = l.strip_prefix('[').and_then(|l| l.strip_suffix(']'))
+ {
+ current_section =
+ Some(self.sections.entry(section.to_lowercase()).or_default());
+ } else if let Some((name, value)) = l.split_once('=') {
+ if let Some(current_section) = &mut current_section {
+ // Trim whitespace
+ let name = name.trim_ascii_end().to_uppercase();
+ let value = value.trim_ascii_start();
+ // Escape value
+ let value =
+ if value.len() > 1 && value.starts_with('"') && value.ends_with('"') {
+ &value[1..value.len() - 1]
+ } else {
+ value
+ };
+ current_section.insert(
+ name,
+ Line {
+ content: value.to_owned(),
+ loc: Some(Location { file, line }),
+ },
+ );
+ } else {
+ return Err(line_err("Expected section header or directive", src, line));
+ }
+ } else {
+ return Err(line_err(
+ "Expected section header, option assignment or directive",
+ src,
+ line,
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ /// Get a read-only shareable Config from the parser
+ pub fn finish(self) -> Config {
+ // Convert to a read-only config struct without location info
+ Config(Arc::new(Inner {
+ sections: self.sections,
+ files: self.files,
+ install_path: self.install_path,
+ }))
+ }
+ }
+
+ /** Information about how the configuration is loaded */
+ #[derive(Debug, Clone, Copy)]
+ pub struct ConfigSource {
+ /** Name of the high-level project */
+ pub project_name: &'static str,
+ /** Name of the component within the package */
+ pub component_name: &'static str,
+ /**
+ * Executable name that will be located on $PATH to
+ * find the installation path of the package
+ */
+ pub exec_name: &'static str,
+ }
+
+ impl ConfigSource {
+ /// Create a new config source
+ pub const fn new(
+ project_name: &'static str,
+ component_name: &'static str,
+ exec_name: &'static str,
+ ) -> Self {
+ Self {
+ project_name,
+ component_name,
+ exec_name,
+ }
+ }
+
+ /// Create a config source where the project, component and exec names are the same
+ pub const fn simple(name: &'static str) -> Self {
+ Self::new(name, name, name)
+ }
+
+ /**
+ * Search the default configuration file path
+ *
+ * I will be the first existing file from this list:
+ * - $XDG_CONFIG_HOME/$componentName.conf
+ * - $HOME/.config/$componentName.conf
+ * - /etc/$componentName.conf
+ * - /etc/$projectName/$componentName.conf
+ * */
+ fn default_config_path(&self) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> {
+ // TODO use a generator
+ let conf_name = format!("{}.conf", self.component_name);
+
+ if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
+ let path = PathBuf::from(xdg).join(&conf_name);
+ match path.try_exists() {
+ Ok(false) => {}
+ Ok(true) => return Ok(Some(path)),
+ Err(e) => return Err((path, e)),
+ }
+ }
+
+ if let Some(home) = std::env::var_os("HOME") {
+ let path = PathBuf::from(home).join(".config").join(&conf_name);
+ match path.try_exists() {
+ Ok(false) => {}
+ Ok(true) => return Ok(Some(path)),
+ Err(e) => return Err((path, e)),
+ }
+ }
+
+ let path = PathBuf::from("/etc").join(&conf_name);
+ match path.try_exists() {
+ Ok(false) => {}
+ Ok(true) => return Ok(Some(path)),
+ Err(e) => return Err((path, e)),
+ }
+
+ let path = PathBuf::from("/etc")
+ .join(self.project_name)
+ .join(&conf_name);
+ match path.try_exists() {
+ Ok(false) => {}
+ Ok(true) => return Ok(Some(path)),
+ Err(e) => return Err((path, e)),
+ }
+
+ Ok(None)
+ }
+
+ /** Search for the binary installation path in PATH */
+ fn install_path(&self) -> Result<PathBuf, (PathBuf, std::io::Error)> {
+ let path_env = std::env::var("PATH").unwrap_or_default();
+ for path_dir in path_env.split(':') {
+ let path_dir = PathBuf::from(path_dir);
+ let bin_path = path_dir.join(self.exec_name);
+ if bin_path.exists()
+ && let Some(parent) = path_dir.parent()
+ {
+ return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e));
+ }
+ }
+ Ok(PathBuf::from("/usr"))
+ }
+ }
+
+ impl Config {
+ /// Load a config for a Taler component, optionally also load from a file.
+ /// This is the standard way to load a Taler component config
+ pub fn load(
+ src: ConfigSource,
+ path: Option<impl Into<PathBuf>>,
+ ) -> Result<Config, ParserErr> {
+ let mut parser = Parser::empty();
+ parser.load_env(src)?;
+ match path {
+ Some(path) => parser.parse_file(path.into(), 0)?,
+ None => {
+ if let Some(default) = src
+ .default_config_path()
+ .map_err(|(p, e)| io_err("find default config path", p, e))?
+ {
+ parser.parse_file(default, 0)?;
+ }
+ }
+ }
+ Ok(parser.finish())
+ }
+
+ /// Load config from an in memory string for testing
+ pub fn from_mem(str: &str) -> Result<Config, ParserErr> {
+ let mut parser = Parser::empty();
+ parser.parse_str(str)?;
+ Ok(parser.finish())
+ }
+
+ /// Load config from an in memory string with env from a Taler component for testing
+ pub fn from_mem_with_env(src: ConfigSource, str: &str) -> Result<Config, ParserErr> {
+ let mut parser = Parser::empty();
+ parser.load_env(src)?;
+ parser.parse_str(str)?;
+ Ok(parser.finish())
+ }
+
+ /// Load a config for a Taler component, optionally also load from a file and an in memory string, for testing
+ pub fn from_file_override(
+ src: ConfigSource,
+ path: Option<impl Into<PathBuf>>,
+ str: &str,
+ ) -> Result<Config, ParserErr> {
+ let mut parser = Parser::empty();
+ parser.load_env(src)?;
+ match path {
+ Some(path) => {
+ parser.parse_file(path.into(), 0)?;
+ }
+ None => {
+ if let Some(default) = src
+ .default_config_path()
+ .map_err(|(p, e)| io_err("find default config path", p, e))?
+ {
+ parser.parse_file(default, 0)?;
+ }
+ }
+ }
+ parser.parse_str(str)?;
+ Ok(parser.finish())
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum ValueErr {
+ #[error("Missing {ty} option {option} in section [{section}]")]
+ Missing {
+ ty: String,
+ section: String,
+ option: String,
+ },
+ #[error("Invalid {ty} option {option} in section [{section}]: {err}")]
+ Invalid {
+ ty: String,
+ section: String,
+ option: String,
+ err: String,
+ },
+}
+
+#[derive(Debug, thiserror::Error)]
+
+pub enum PathsubErr {
+ #[error("recursion limit in path substitution exceeded for '{0}'")]
+ Recursion(String),
+ #[error("unbalanced variable expression '{0}'")]
+ Unbalanced(String),
+ #[error("bad substitution '{0}'")]
+ Substitution(String),
+ #[error("unbound variable '{0}'")]
+ Unbound(String),
+}
+
+#[derive(Debug, Clone)]
+struct Location {
+ file: usize,
+ line: usize,
+}
+
+#[derive(Debug, Clone)]
+struct Line {
+ content: String,
+ loc: Option<Location>,
+}
+
+#[derive(Debug)]
+struct Inner {
+ sections: IndexMap<String, IndexMap<String, Line>>,
+ files: Vec<PathBuf>,
+ install_path: PathBuf,
+}
+
+#[derive(Clone)]
+pub struct Config(Arc<Inner>);
+
+impl Debug for Config {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+fn make_lowercase<'a>(s: &'a str) -> Cow<'a, str> {
+ if s.chars().all(|c| c.is_ascii_lowercase()) {
+ Cow::Borrowed(s)
+ } else {
+ Cow::Owned(s.to_ascii_lowercase())
+ }
+}
+
+fn make_uppercase<'a>(s: &'a str) -> Cow<'a, str> {
+ if s.chars().all(|c| c.is_ascii_uppercase()) {
+ Cow::Borrowed(s)
+ } else {
+ Cow::Owned(s.to_ascii_uppercase())
+ }
+}
+
+impl Config {
+ /// Get a config section from its name
+ pub fn section<'cfg, 'arg>(&'cfg self, name: &'arg str) -> Section<'cfg, 'arg> {
+ Section {
+ name,
+ config: self,
+ values: self.0.sections.get(make_lowercase(name).as_ref()),
+ }
+ }
+
+ /// List all config sections
+ pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> {
+ self.0.sections.iter().map(|(name, values)| Section {
+ name,
+ config: self,
+ values: Some(values),
+ })
+ }
+
+ /**
+ * Substitute ${...} and $... placeholders in a string
+ * with values from the PATHS section in the
+ * configuration and environment variables
+ *
+ * This substitution is typically only done for paths.
+ */
+ pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> {
+ if depth > 128 {
+ return Err(PathsubErr::Recursion(str.to_owned()));
+ } else if !str.contains('$') {
+ return Ok(str.to_owned());
+ }
+
+ /** Lookup for variable value from PATHS section in the configuration and environment variables */
+ fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> {
+ if let Some(path_res) = cfg
+ .0
+ .sections
+ .get("paths")
+ .and_then(|section| section.get(make_uppercase(name).as_ref()))
+ {
+ return Some(cfg.pathsub(&path_res.content, depth + 1));
+ }
+
+ if let Ok(val) = std::env::var(name) {
+ return Some(Ok(val));
+ }
+ None
+ }
+
+ let mut result = String::new();
+ let mut remaining = str;
+ loop {
+ // Look for the next variable
+ let Some((normal, value)) = remaining.split_once('$') else {
+ result.push_str(remaining);
+ return Ok(result);
+ };
+
+ // Append normal character
+ result.push_str(normal);
+ remaining = value;
+
+ // Check if variable is enclosed
+ let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') {
+ // ${var
+ remaining = enclosed;
+ true
+ } else {
+ false // $var
+ };
+
+ // Extract variable name
+ let name_end = remaining
+ .find(|c: char| !c.is_alphanumeric() && c != '_')
+ .unwrap_or(remaining.len());
+ let (name, after_name) = remaining.split_at(name_end);
+
+ // Extract variable default if enclosed
+ let default = if !is_enclosed {
+ remaining = after_name;
+ None
+ } else if let Some(after_enclosed) = after_name.strip_prefix('}') {
+ // ${var}
+ remaining = after_enclosed;
+ None
+ } else if let Some(default) = after_name.strip_prefix(":-") {
+ // ${var:-default}
+ let mut depth = 1;
+ let Some((default, after_default)) = default.split_once(|c| {
+ if c == '{' {
+ depth += 1;
+ false
+ } else if c == '}' {
+ depth -= 1;
+ depth == 0
+ } else {
+ false
+ }
+ }) else {
+ return Err(PathsubErr::Unbalanced(default.to_owned()));
+ };
+ remaining = after_default;
+ Some(default)
+ } else {
+ return Err(PathsubErr::Substitution(after_name.to_owned()));
+ };
+ if let Some(resolved) = lookup(self, name, depth + 1) {
+ result.push_str(&resolved?);
+ continue;
+ } else if let Some(default) = default {
+ let resolved = self.pathsub(default, depth + 1)?;
+ result.push_str(&resolved);
+ continue;
+ }
+ return Err(PathsubErr::Unbound(name.to_owned()));
+ }
+ }
+
+ /// Print config in a human format, optionally with diagnostics information
+ pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> {
+ let Inner {
+ sections,
+ files,
+ install_path,
+ } = self.0.as_ref();
+ if diagnostics {
+ writeln!(f, "#")?;
+ writeln!(f, "# Configuration file diagnostics")?;
+ writeln!(f, "#")?;
+ writeln!(f, "# File Loaded:")?;
+ for path in files {
+ writeln!(f, "# {}", path.to_string_lossy())?;
+ }
+ writeln!(f, "#")?;
+ writeln!(f, "# Installation path: {}", install_path.to_string_lossy())?;
+ writeln!(f, "#")?;
+ writeln!(f)?;
+ }
+ for (sect, values) in sections {
+ writeln!(f, "[{sect}]")?;
+ if diagnostics {
+ writeln!(f)?;
+ }
+ for (key, Line { content, loc }) in values {
+ if diagnostics {
+ match loc {
+ Some(Location { file, line }) => {
+ let path = &files[*file];
+ writeln!(f, "# {}:{line}", path.to_string_lossy())?;
+ }
+ None => writeln!(f, "# default")?,
+ }
+ }
+ writeln!(f, "{key} = {content}")?;
+ if diagnostics {
+ writeln!(f)?;
+ }
+ }
+ writeln!(f)?;
+ }
+ Ok(())
+ }
+}
+
+/** Accessor/Converter for Taler-like configuration sections */
+pub struct Section<'cfg, 'arg> {
+ pub name: &'arg str,
+ config: &'cfg Config,
+ values: Option<&'cfg IndexMap<String, Line>>,
+}
+
+#[macro_export]
+macro_rules! map_config {
+ ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => {
+ {
+ let keys = &[$($key,)*];
+ $self.map($ty, $option, |value| {
+ match value {
+ $($key => {
+ (||Ok($parse))().map_err(|e| $crate::config::MapErr::Err(e))
+ })*,
+ _ => Err($crate::config::MapErr::Invalid(keys))
+ }
+ })
+ }
+ }
+}
+
+pub use map_config;
+
+#[doc(hidden)]
+pub enum MapErr {
+ Invalid(&'static [&'static str]),
+ Err(ValueErr),
+}
+
+impl<'cfg, 'arg> Section<'cfg, 'arg> {
+ #[doc(hidden)]
+ fn inner<T>(
+ &self,
+ ty: &'arg str,
+ option: &'arg str,
+ transform: impl FnOnce(&'cfg str) -> Result<T, ValueErr>,
+ ) -> Value<'arg, T> {
+ let value = self
+ .values
+ .and_then(|m| m.get(make_uppercase(option).as_ref()))
+ .filter(|it| !it.content.is_empty())
+ .map(|raw| transform(&raw.content))
+ .transpose();
+ Value {
+ value,
+ option,
+ ty,
+ section: self.name,
+ }
+ }
+
+ #[doc(hidden)]
+ pub fn map<T>(
+ &self,
+ ty: &'arg str,
+ option: &'arg str,
+ transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>,
+ ) -> Value<'arg, T> {
+ self.value(ty, option, |v| {
+ transform(v).map_err(|e| match e {
+ MapErr::Invalid(keys) => {
+ let mut buf = "expected '".to_owned();
+ match keys {
+ [] => unreachable!("you must provide at least one mapping"),
+ [unique] => buf.push_str(unique),
+ [first, other @ .., last] => {
+ buf.push_str(first);
+ for k in other {
+ buf.push_str("', '");
+ buf.push_str(k);
+ }
+ buf.push_str("' or '");
+ buf.push_str(last);
+ }
+ }
+ buf.push_str("' got '");
+ buf.push_str(v);
+ buf.push('\'');
+ ValueErr::Invalid {
+ ty: ty.to_owned(),
+ section: self.name.to_lowercase(),
+ option: option.to_uppercase(),
+ err: buf,
+ }
+ }
+ MapErr::Err(e) => e,
+ })
+ })
+ }
+
+ /** Setup an accessor/converted for a [type] at [option] using [transform] */
+ pub fn value<T, E: Display>(
+ &self,
+ ty: &'arg str,
+ option: &'arg str,
+ transform: impl FnOnce(&'cfg str) -> Result<T, E>,
+ ) -> Value<'arg, T> {
+ self.inner(ty, option, |v| {
+ transform(v).map_err(|e| ValueErr::Invalid {
+ ty: ty.to_owned(),
+ section: self.name.to_lowercase(),
+ option: option.to_uppercase(),
+ err: e.to_string(),
+ })
+ })
+ }
+
+ /** Access [option] as a parsable type */
+ pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>(
+ &self,
+ ty: &'arg str,
+ option: &'arg str,
+ ) -> Value<'arg, T> {
+ self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string()))
+ }
+
+ /** Access [option] as str */
+ pub fn str(&self, option: &'arg str) -> Value<'arg, String> {
+ self.value("string", option, |it| Ok::<_, &str>(it.to_owned()))
+ }
+
+ /** Access [option] as compact str */
+ pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> {
+ self.value("string", option, |it| Ok::<_, CompactString>(it.into()))
+ }
+
+ // Dropped when vendoring: hex(), b32() and b64(), which need
+ // taler_common::encoding.
+
+ /** Access [option] as path */
+ pub fn path(&self, option: &'arg str) -> Value<'arg, String> {
+ self.value("path", option, |it| self.config.pathsub(it, 0))
+ }
+
+ /** Access [option] as UNIX permissions */
+ pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> {
+ self.value("unix mode", option, |it| {
+ u32::from_str_radix(it, 8)
+ .map(Permissions::from_mode)
+ .map_err(|_| format!("'{it}' not a valid number"))
+ })
+ }
+
+ /** Access [option] as a number */
+ pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> {
+ self.value("number", option, |it| {
+ it.parse::<T>()
+ .map_err(|_| format!("'{it}' not a valid number"))
+ })
+ }
+
+ /** Access [option] as Boolean */
+ pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> {
+ self.value("boolean", option, |it| match it.to_uppercase().as_str() {
+ "YES" => Ok(true),
+ "NO" => Ok(false),
+ _ => Err(format!("expected 'YES' or 'NO' got '{it}'")),
+ })
+ }
+
+ // Dropped when vendoring: currency() and amount(), which need
+ // taler_common::types::amount.
+
+ /** Access [option] as url */
+ pub fn url(&self, option: &'arg str) -> Value<'arg, Url> {
+ self.parse("url", option)
+ }
+
+ /** Access [option] as base url */
+ pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> {
+ self.value("url", option, |s| {
+ let url = Url::from_str(s).map_err(|e| e.to_string())?;
+ validate_base_url(&url)?;
+ Ok::<_, String>(url)
+ })
+ }
+
+ // Dropped when vendoring: payto(), which needs
+ // taler_common::types::payto.
+
+ /** Access [option] as Postgres URI */
+ pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> {
+ self.parse("Postgres URI", option)
+ }
+
+ /** Access [option] as a timestamp */
+ pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> {
+ self.parse("Timestamp", option)
+ }
+
+ /** Access [option] as a time */
+ pub fn time(&self, option: &'arg str) -> Value<'arg, jiff::civil::Time> {
+ self.parse("Time", option)
+ }
+
+ /** Access [option] as a date */
+ pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> {
+ self.parse("Date", option)
+ }
+
+ /** Access [option] as a duration */
+ pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> {
+ self.value("temporal", option, |it| {
+ let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?;
+ Ok::<_, String>(Duration::from_millis(tmp.as_millis() as u64))
+ })
+ }
+
+ /** Access [option] as a duration */
+ pub fn span(&self, option: &'arg str) -> Value<'arg, Span> {
+ self.parse("temporal", option)
+ }
+
+ // Dropped when vendoring: regex(), to avoid pulling in the regex crate.
+
+ /** Access option as json object */
+ pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> {
+ self.value("json", option, |it| serde_json::from_str(it))
+ }
+}
+
+pub struct Value<'arg, T> {
+ value: Result<Option<T>, ValueErr>,
+ option: &'arg str,
+ ty: &'arg str,
+ section: &'arg str,
+}
+
+impl<T> Value<'_, T> {
+ pub fn opt(self) -> Result<Option<T>, ValueErr> {
+ self.value
+ }
+
+ /** Converted value of default if missing */
+ pub fn default(self, default: T) -> Result<T, ValueErr> {
+ Ok(self.value?.unwrap_or(default))
+ }
+
+ /** Converted value or throw if missing */
+ pub fn require(self) -> Result<T, ValueErr> {
+ self.value?.ok_or_else(|| ValueErr::Missing {
+ ty: self.ty.to_owned(),
+ section: self.section.to_lowercase(),
+ option: self.option.to_uppercase(),
+ })
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::{
+ fmt::{Debug, Display},
+ fs::{File, Permissions},
+ os::unix::fs::PermissionsExt,
+ };
+
+ use tracing::error;
+
+ use super::{Config, Section, Value};
+ use crate::config::parser::ConfigSource;
+
+ const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test");
+
+ #[track_caller]
+ fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) {
+ let failure = lambda.unwrap_err();
+ let fmt = failure.to_string();
+ assert_eq!(err.as_ref(), fmt);
+ }
+
+ /// [`check_err`] for messages with an environment-dependent middle: only
+ /// the head and the tail are compared.
+ #[track_caller]
+ fn check_err_loose<T: Debug, E: Display>(
+ head: impl AsRef<str>,
+ tail: impl AsRef<str>,
+ lambda: Result<T, E>,
+ ) {
+ let failure = lambda.unwrap_err();
+ let fmt = failure.to_string();
+ let (head, tail) = (head.as_ref(), tail.as_ref());
+ assert!(
+ fmt.starts_with(head) && fmt.ends_with(tail),
+ "expected an error starting with '{head}' and ending with '{tail}', got '{fmt}'"
+ );
+ }
+
+ #[test]
+ fn fs() {
+ let dir = tempfile::tempdir().unwrap();
+ let config_path = dir.path().join("test-conf.conf");
+ let second_path = dir.path().join("test-second-conf.conf");
+
+ let config_path_fmt = config_path.to_string_lossy();
+ let second_path_fmt = second_path.to_string_lossy();
+
+ let check_err = |err: String| check_err(err, Config::load(SOURCE, Some(&config_path)));
+ let check_ok = || Config::load(SOURCE, Some(&config_path)).unwrap();
+
+ check_err(format!(
+ "Could not read config at '{config_path_fmt}': entity not found"
+ ));
+
+ let config_file = std::fs::File::create_new(&config_path).unwrap();
+ config_file
+ .set_permissions(Permissions::from_mode(0o222))
+ .unwrap();
+ if File::open(&config_path).is_ok() {
+ error!("Cannot finish this test if root");
+ return;
+ }
+ check_err(format!(
+ "Could not read config at '{config_path_fmt}': permission denied"
+ ));
+
+ config_file
+ .set_permissions(Permissions::from_mode(0o666))
+ .unwrap();
+ check_ok();
+ std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap();
+ check_err(format!(
+ "Could not read config at '{second_path_fmt}': entity not found"
+ ));
+
+ let second_file = std::fs::File::create_new(&second_path).unwrap();
+ second_file
+ .set_permissions(Permissions::from_mode(0o222))
+ .unwrap();
+ check_err(format!(
+ "Could not read config at '{second_path_fmt}': permission denied"
+ ));
+
+ std::fs::write(&config_path, "@inline-matching@[*").unwrap();
+ // glob reports the offset of the '[' within the *expanded* pattern
+ // (<tempdir>/[*), so the position moves with the length of $TMPDIR.
+ check_err_loose(
+ format!(
+ "Malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position "
+ ),
+ ": invalid range pattern",
+ Config::load(SOURCE, Some(&config_path)),
+ );
+
+ std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap();
+ check_err(format!(
+ "Could not read config at '{second_path_fmt}': permission denied"
+ ));
+
+ std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap();
+ check_err(format!(
+ "Recursion limit in config inlining at '{config_path_fmt}:2'"
+ ));
+ std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap();
+ check_err(format!(
+ "Recursion limit in config inlining at '{config_path_fmt}:3'"
+ ));
+
+ std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap();
+ check_ok();
+ }
+
+ #[test]
+ fn parsing() {
+ let check = |err: &str, content: &str| check_err(err, Config::from_mem(content));
+
+ check(
+ "Expected section header, option assignment or directive at 'mem:1'",
+ "syntax error",
+ );
+ check(
+ "Expected section header or directive at 'mem:1'",
+ "key=value",
+ );
+ check(
+ "Expected section header, option assignment or directive at 'mem:2'",
+ "[section]\nbad-line",
+ );
+
+ let cfg = Config::from_mem(
+ r#"
+
+ [section-a]
+
+ bar = baz
+
+ [section-b]
+
+ first_value = 1
+ second_value = "test"
+
+ "#,
+ )
+ .unwrap();
+
+ // Missing section
+ check_err(
+ "Missing string option VALUE in section [unknown]",
+ cfg.section("unknown").str("value").require(),
+ );
+
+ // Missing value
+ check_err(
+ "Missing string option VALUE in section [section-a]",
+ cfg.section("section-a").str("value").require(),
+ );
+ }
+
+ const DEFAULT_CONF: &str = "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE";
+
+ #[allow(clippy::type_complexity)]
+ fn routine<T: Debug + Eq>(
+ ty: &str,
+ mut lambda: impl for<'cfg, 'arg> FnMut(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>,
+ wellformed: &[(&[&str], T)],
+ malformed: &[(&[&str], fn(&str) -> String)],
+ ) {
+ let conf = |content: &str| Config::from_mem(&format!("{DEFAULT_CONF}\n{content}")).unwrap();
+
+ // Check missing msg
+ let cfg = conf("");
+ check_err(
+ format!("Missing {ty} option VALUE in section [section]"),
+ lambda(&cfg.section("section"), "value").require(),
+ );
+
+ // Check wellformed options are properly parsed
+ for (raws, expected) in wellformed {
+ for raw in *raws {
+ let cfg = conf(&format!("[section]\nvalue={raw}"));
+ dbg!(&cfg);
+ assert_eq!(
+ *expected,
+ lambda(&cfg.section("section"), "value").require().unwrap()
+ );
+ }
+ }
+
+ // Check malformed options have proper error message
+ for (raws, error_fmt) in malformed {
+ for raw in *raws {
+ let cfg = conf(&format!("[section]\nvalue={raw}"));
+ check_err(
+ format!(
+ "Invalid {ty} option VALUE in section [section]: {}",
+ error_fmt(raw)
+ ),
+ lambda(&cfg.section("section"), "value").require(),
+ )
+ }
+ }
+ }
+
+ #[test]
+ fn string() {
+ routine(
+ "string",
+ |sect, value| sect.str(value),
+ &[
+ (&["1", "\"1\""], "1".to_owned()),
+ (&["test", "\"test\""], "test".to_owned()),
+ (&["\""], "\"".to_owned()),
+ ],
+ &[],
+ );
+ }
+
+ #[test]
+ fn path() {
+ routine(
+ "path",
+ |sect, value| sect.path(value),
+ &[
+ (&["path"], "path".to_owned()),
+ (
+ &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"],
+ "foo/mydir/bar".to_owned(),
+ ),
+ (
+ &["foo/$DATADIR$DATADIR/bar"],
+ "foo/mydirmydir/bar".to_owned(),
+ ),
+ (
+ &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"],
+ "foo/pre_mydir/bar".to_owned(),
+ ),
+ (
+ &[
+ "foo/${DATADIR}_next/bar",
+ "foo/${UNKNOWN:-$DATADIR}_next/bar",
+ ],
+ "foo/mydir_next/bar".to_owned(),
+ ),
+ (
+ &[
+ "foo/${UNKNOWN:-default}_next/bar",
+ "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar",
+ ],
+ "foo/default_next/bar".to_owned(),
+ ),
+ (
+ &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"],
+ "foo/pre_default_next_next/bar".to_owned(),
+ ),
+ ],
+ &[
+ (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()),
+ (&["foo/${A:-pre_${B}/bar"], |_| {
+ "unbalanced variable expression 'pre_${B}/bar'".to_owned()
+ }),
+ (&["foo/${A:-${B${C}/bar"], |_| {
+ "unbalanced variable expression '${B${C}/bar'".to_owned()
+ }),
+ (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| {
+ "unbound variable 'UNKNOWN'".to_owned()
+ }),
+ (&["foo/$RECURSIVE/bar"], |_| {
+ "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned()
+ }),
+ ],
+ )
+ }
+
+ #[test]
+ fn number() {
+ routine(
+ "number",
+ |sect, value| sect.number(value),
+ &[(&["1"], 1), (&["42"], 42)],
+ &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))],
+ );
+ }
+
+ #[test]
+ fn boolean() {
+ routine(
+ "boolean",
+ |sect, value| sect.boolean(value),
+ &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)],
+ &[(&["true", "1"], |it| {
+ format!("expected 'YES' or 'NO' got '{it}'")
+ })],
+ );
+ }
+
+ // Dropped when vendoring: the amount() test, along with the accessor.
+
+ #[test]
+ fn unix_mode() {
+ routine(
+ "unix mode",
+ |sect, value| sect.unix_mode(value),
+ &[
+ (&["660"], Permissions::from_mode(0o660)),
+ (&["0666"], Permissions::from_mode(0o666)),
+ ],
+ &[(&["999", "rw-"], |it| {
+ format!("'{it}' not a valid number")
+ })],
+ );
+ }
+}
diff --git a/taler-config/src/lib.rs b/taler-config/src/lib.rs
@@ -0,0 +1,79 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2024, 2025, 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+//! Configuration parsing, logging and socket binding shared with the rest of
+//! the GNU Taler Rust tree. See the README next to this file for what was
+//! vendored from where.
+
+use std::path::PathBuf;
+
+use config::{Config, parser::ConfigSource};
+use tracing::error;
+use tracing_subscriber::util::SubscriberInitExt;
+
+use crate::log::taler_logger;
+
+pub mod config;
+pub mod log;
+pub mod serve;
+
+/// Command line arguments every Taler component understands. Flatten this
+/// into the component's own `clap::Parser` struct with `#[command(flatten)]`.
+#[derive(clap::Parser, Debug, Clone)]
+pub struct CommonArgs {
+ /// Specifies the configuration file
+ #[arg(short, long, global = true)]
+ pub config: Option<PathBuf>,
+
+ /// Configure logging to use LOGLEVEL
+ #[arg(short('L'), long, global = true)]
+ pub log: Option<tracing::Level>,
+
+ /// Show logs from all sources
+ #[arg(short, long, global = true)]
+ pub verbose: bool,
+}
+
+/// Set up logging, load the configuration and run `app` on a multi-threaded
+/// tokio runtime. Exits with status 1 after logging the error if either the
+/// configuration fails to load or `app` returns one.
+pub fn taler_main(
+ src: ConfigSource,
+ args: CommonArgs,
+ app: impl AsyncFnOnce(&Config) -> Result<(), anyhow::Error>,
+) {
+ taler_logger(args.log, args.verbose).init();
+ let cfg = match Config::load(src, args.config) {
+ Ok(cfg) => cfg,
+ Err(err) => {
+ error!(target: "config", "{}", err);
+ std::process::exit(1);
+ }
+ };
+
+ // Setup async runtime
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+
+ // Run app
+ let result = runtime.block_on(app(&cfg));
+ if let Err(err) = result {
+ error!(target: "cli", "{}", err);
+ std::process::exit(1);
+ }
+}
diff --git a/taler-config/src/log.rs b/taler-config/src/log.rs
@@ -0,0 +1,139 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025, 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::io::IsTerminal;
+
+use compact_str::CompactString;
+use jiff::{Timestamp, fmt::StdFmtWrite, tz::TimeZone};
+use tracing::{Event, Level, Subscriber};
+use tracing_subscriber::{
+ fmt::{FmtContext, FormatEvent, FormatFields, format::Writer},
+ layer::SubscriberExt,
+ registry::LookupSpan,
+ util::SubscriberInitExt,
+};
+
+tokio::task_local! {
+ pub static LOG_TASK_ID: CompactString;
+}
+
+pub struct TalerFmt {
+ zone: Option<TimeZone>,
+}
+
+impl Default for TalerFmt {
+ fn default() -> Self {
+ Self {
+ zone: if std::env::var_os("JOURNAL_STREAM").is_some() {
+ None
+ } else {
+ Some(match TimeZone::try_system() {
+ Ok(z) => z,
+ Err(e) => {
+ eprintln!("could not fetch system time zone, default to UTC: {e}");
+ TimeZone::UTC
+ }
+ })
+ },
+ }
+ }
+}
+
+impl<S, N> FormatEvent<S, N> for TalerFmt
+where
+ S: Subscriber + for<'a> LookupSpan<'a>,
+ N: for<'a> FormatFields<'a> + 'static,
+{
+ fn format_event(
+ &self,
+ ctx: &FmtContext<'_, S, N>,
+ mut w: Writer<'_>,
+ event: &Event<'_>,
+ ) -> std::fmt::Result {
+ let meta = event.metadata();
+ let ansi = w.has_ansi_escapes();
+
+ if let Some(zone) = &self.zone {
+ let timestamp = Timestamp::now();
+ let offset = zone.to_offset(timestamp);
+ if ansi {
+ write!(&mut w, "\x1b[2m")?; // Dim fg
+ }
+ jiff::fmt::temporal::DateTimePrinter::new()
+ .precision(Some(6))
+ .separator(b'T')
+ .print_timestamp_with_offset(×tamp, offset, StdFmtWrite(&mut w))
+ .map_err(|_| std::fmt::Error)?;
+ // TODO remove : in offset
+ }
+ let level = meta.level();
+ if ansi {
+ let color = match *level {
+ tracing::Level::ERROR => "\x1b[31m", // red
+ tracing::Level::WARN => "\x1b[33m", // yellow
+ tracing::Level::INFO => "\x1b[32m", // green
+ tracing::Level::DEBUG => "\x1b[34m", // blue
+ tracing::Level::TRACE => "\x1b[35m", // magenta
+ };
+ write!(&mut w, "\x1b[0m{color}")?
+ };
+ let level = match *level {
+ tracing::Level::ERROR => "ERROR", // red
+ tracing::Level::WARN => " WARN", // yellow
+ tracing::Level::INFO => " INFO", // green
+ tracing::Level::DEBUG => "DEBUG", // blue
+ tracing::Level::TRACE => "TRACE", // magenta
+ };
+ write!(&mut w, " {level} ")?; // Reset dim then color
+ if ansi {
+ write!(&mut w, "\x1b[2;37m")?; // Dim fg
+ }
+ LOG_TASK_ID
+ .try_with(|id| write!(w, "({id})"))
+ .ok()
+ .transpose()?;
+ write!(w, "{}: ", meta.target())?;
+ if ansi {
+ write!(&mut w, "\x1b[0m")?; // Reset
+ }
+ ctx.format_fields(w.by_ref(), event)?;
+ writeln!(&mut w)
+ }
+}
+
+pub fn taler_logger(max_level: Option<Level>, verbose: bool) -> impl SubscriberInitExt {
+ let max_level = max_level.unwrap_or(Level::INFO);
+ tracing_subscriber::registry()
+ .with(
+ tracing_subscriber::fmt::layer()
+ .event_format(TalerFmt::default())
+ .with_writer(std::io::stderr)
+ .with_ansi(std::io::stderr().is_terminal()),
+ )
+ .with(tracing_subscriber::filter::filter_fn(move |metadata| {
+ let target = metadata.target();
+ *metadata.level() <= max_level
+ && (verbose
+ || !(target.starts_with("sqlx")
+ || target.starts_with("axum")
+ || target.contains("hyper_util")
+ || target.starts_with("h2")
+ || target.starts_with("reqwest")
+ || target.starts_with("rustls")
+ || target.starts_with("hyper_rustls")
+ || target.starts_with("mio")))
+ }))
+}
diff --git a/taler-config/src/serve.rs b/taler-config/src/serve.rs
@@ -0,0 +1,299 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2024, 2025, 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+//! Listening socket selection: TCP, a UNIX domain socket, or a socket
+//! inherited from systemd.
+
+use std::{
+ fs::Permissions,
+ io::ErrorKind,
+ net::{IpAddr, SocketAddr},
+ os::unix::fs::PermissionsExt as _,
+ time::Instant,
+};
+
+use axum::{
+ Router,
+ extract::Request,
+ middleware::{self, Next},
+ response::Response,
+};
+use compact_str::CompactString;
+use listenfd::ListenFd;
+use rand::Rng as _;
+use tokio::{
+ net::{TcpListener, UnixListener},
+ signal,
+};
+use tracing::{Level, debug, info};
+
+use crate::{
+ config::{Section, ValueErr},
+ log::LOG_TASK_ID,
+ map_config,
+};
+
+#[derive(Debug, Clone)]
+pub enum Serve {
+ Tcp(SocketAddr),
+ Unix {
+ path: String,
+ permission: Permissions,
+ },
+ Systemd,
+}
+
+impl Serve {
+ /// Read the `SERVE` option and whatever else the chosen mode needs
+ pub fn parse(s: &Section) -> Result<Self, ValueErr> {
+ map_config!(s, "serve", "SERVE",
+ "tcp" => {
+ let port = s.number("PORT").require()?;
+ let ip: IpAddr = s.parse("IP addr", "BIND_TO").require()?;
+ Serve::Tcp(SocketAddr::new(ip, port))
+ },
+ "unix" => {
+ let path = s.path("UNIXPATH").require()?;
+ let permission = s.unix_mode("UNIXPATH_MODE").require()?;
+ Serve::Unix { path, permission }
+ },
+ "systemd" => { Serve::Systemd }
+ )
+ .require()
+ }
+
+ /// Resolve listener from a config and environment
+ fn resolve(&self) -> Result<Listener, std::io::Error> {
+ match self {
+ Serve::Tcp(socket_addr) => {
+ info!(target: "api", "Server listening on {socket_addr}");
+ let listener = std::net::TcpListener::bind(socket_addr)?;
+ listener.set_nonblocking(true)?;
+ Ok(Listener::Tcp(TcpListener::from_std(listener)?))
+ }
+ Serve::Unix { path, permission } => {
+ info!(target: "api",
+ "Server listening on unix domain socket {path} {:o}",
+ permission.mode()
+ );
+ if let Err(e) = std::fs::remove_file(path) {
+ let kind = e.kind();
+ if kind != ErrorKind::NotFound {
+ return Err(e);
+ }
+ }
+ let listener = std::os::unix::net::UnixListener::bind(path)?;
+ std::fs::set_permissions(path, permission.clone())?;
+ listener.set_nonblocking(true)?;
+ Ok(Listener::Unix(UnixListener::from_std(listener)?))
+ }
+ Serve::Systemd => {
+ let mut listenfd = ListenFd::from_env();
+ if let Ok(Some(unix)) = listenfd.take_unix_listener(0) {
+ info!(target: "api",
+ "Server listening on activated unix socket {:?}",
+ unix.local_addr()?
+ );
+ unix.set_nonblocking(true)?;
+ Ok(Listener::Unix(UnixListener::from_std(unix)?))
+ } else if let Ok(Some(tcp)) = listenfd.take_tcp_listener(0) {
+ info!(target: "api",
+ "Server listening on activated TCP socket {:?}",
+ tcp.local_addr()?
+ );
+ tcp.set_nonblocking(true)?;
+ Ok(Listener::Tcp(TcpListener::from_std(tcp)?))
+ } else {
+ Err(std::io::Error::other("Missing systemd activated socket"))
+ }
+ }
+ }
+ }
+}
+
+enum Listener {
+ Tcp(TcpListener),
+ Unix(UnixListener),
+}
+
+/// Bind the socket described by `serve`, add the request logger and serve
+/// `router` until SIGINT or SIGTERM, letting in-flight requests finish.
+pub async fn serve(router: Router, serve: &Serve) -> std::io::Result<()> {
+ let listener = serve.resolve()?;
+ let router = router.layer(middleware::from_fn(logger_middleware));
+ match listener {
+ Listener::Tcp(tcp_listener) => {
+ axum::serve(tcp_listener, router)
+ .with_graceful_shutdown(shutdown_signal())
+ .await?;
+ }
+ Listener::Unix(unix_listener) => {
+ axum::serve(unix_listener, router)
+ .with_graceful_shutdown(shutdown_signal())
+ .await?;
+ }
+ }
+
+ info!(target: "api", "Server stopped");
+ Ok(())
+}
+
+/** Wait for a system signal shutdown */
+async fn shutdown_signal() {
+ let ctrl_c = async {
+ signal::ctrl_c()
+ .await
+ .expect("failed to install Ctrl+C handler");
+ };
+
+ let terminate = async {
+ signal::unix::signal(signal::unix::SignalKind::terminate())
+ .expect("failed to install signal handler")
+ .recv()
+ .await;
+ };
+
+ tokio::select! {
+ _ = ctrl_c => {},
+ _ = terminate => {},
+ }
+}
+
+#[macro_export]
+macro_rules! dyn_event {
+ ($lvl:ident, $($arg:tt)+) => {
+ match $lvl {
+ ::tracing::Level::TRACE => ::tracing::trace!($($arg)+),
+ ::tracing::Level::DEBUG => ::tracing::debug!($($arg)+),
+ ::tracing::Level::INFO => ::tracing::info!($($arg)+),
+ ::tracing::Level::WARN => ::tracing::warn!($($arg)+),
+ ::tracing::Level::ERROR => ::tracing::error!($($arg)+),
+ }
+ };
+}
+
+/** Taler API logger */
+async fn logger_middleware(request: Request, next: Next) -> Response {
+ let now = Instant::now();
+ let request_id: CompactString = {
+ let charset = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ let mut rng = rand::thread_rng();
+
+ let mut ansi = [0u8; 10];
+ for c in ansi.iter_mut() {
+ let idx = rng.gen_range(0..charset.len());
+ *c = charset[idx];
+ }
+ unsafe { CompactString::from_utf8_unchecked(ansi) }
+ };
+ let method = request.method().clone();
+ let path_and_query = request.uri().path_and_query().cloned();
+ let path_and_query = path_and_query
+ .as_ref()
+ .map(|it| it.as_str())
+ .unwrap_or_default();
+ LOG_TASK_ID
+ .scope(request_id, async {
+ debug!(target: "api", "{method} {path_and_query}");
+ let response = next.run(request).await;
+ let elapsed = now.elapsed();
+ let status = response.status();
+ let level = match status.as_u16() {
+ 400..500 => Level::WARN,
+ 500..600 => Level::ERROR,
+ _ => Level::INFO,
+ };
+ dyn_event!(level, target: "api",
+ "{} {method} {path_and_query} {}ms",
+ response.status(),
+ elapsed.as_millis()
+ );
+ response
+ })
+ .await
+}
+
+#[cfg(test)]
+mod test {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ use crate::config::Config;
+
+ use super::Serve;
+
+ fn parse(content: &str) -> Result<Serve, String> {
+ let cfg = Config::from_mem(content).unwrap();
+ Serve::parse(&cfg.section("test")).map_err(|e| e.to_string())
+ }
+
+ #[test]
+ fn tcp() {
+ let Serve::Tcp(addr) = parse("[test]\nSERVE=tcp\nPORT=8080\nBIND_TO=127.0.0.1").unwrap()
+ else {
+ panic!("expected a TCP socket")
+ };
+ assert_eq!("127.0.0.1:8080", addr.to_string());
+ }
+
+ #[test]
+ fn unix() {
+ let Serve::Unix { path, permission } =
+ parse("[test]\nSERVE=unix\nUNIXPATH=/run/kych/kych.sock\nUNIXPATH_MODE=660").unwrap()
+ else {
+ panic!("expected a UNIX domain socket")
+ };
+ assert_eq!("/run/kych/kych.sock", path);
+ assert_eq!(0o660, permission.mode());
+ }
+
+ #[test]
+ fn systemd() {
+ assert!(matches!(
+ parse("[test]\nSERVE=systemd").unwrap(),
+ Serve::Systemd
+ ));
+ }
+
+ #[test]
+ fn errors() {
+ assert_eq!(
+ "Missing serve option SERVE in section [test]",
+ parse("[test]\nPORT=8080").unwrap_err()
+ );
+ // The prefix really is repeated: Section::map builds a complete
+ // ValueErr, then hands it to Section::value, which wraps any Display
+ // error into a second ValueErr::Invalid. That is an upstream bug in
+ // taler-common; the message is asserted as it is so that re-vendoring
+ // a fixed config.rs fails here instead of going unnoticed.
+ assert_eq!(
+ "Invalid serve option SERVE in section [test]: \
+ Invalid serve option SERVE in section [test]: \
+ expected 'tcp', 'unix' or 'systemd' got 'http'",
+ parse("[test]\nSERVE=http").unwrap_err()
+ );
+ // Same double wrapping as above for an option the chosen mode needs.
+ assert_eq!(
+ "Invalid serve option SERVE in section [test]: \
+ Missing path option UNIXPATH in section [test]",
+ parse("[test]\nSERVE=unix").unwrap_err()
+ );
+ assert_eq!(
+ "Invalid serve option SERVE in section [test]: \
+ Missing IP addr option BIND_TO in section [test]",
+ parse("[test]\nSERVE=tcp\nPORT=8080").unwrap_err()
+ );
+ }
+}