kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README | LICENSE

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:
Mdebian/README.Debian | 31++++++++++++++++++++++++++-----
Mdebian/README.source | 23+++++++++++++----------
Mdebian/changelog | 6++++++
Mdebian/copyright | 9+++++++++
Mdebian/etc/kych/kych.conf | 72+++++++++++++++++++++++++++++++++++++++---------------------------------
Adebian/kych.dirs | 4++++
Mdebian/kych.service | 11+++++++++--
Adebian/kych.socket | 19+++++++++++++++++++
Mdebian/rules | 14++++++++------
Mdocumentation/taler-docs/manpages/kych-client-management.1.rst | 22+++++++++++++++++-----
Mdocumentation/taler-docs/manpages/kych-oauth2-gateway.1.rst | 78+++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mdocumentation/taler-docs/manpages/kych.conf.5.rst | 172+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mdocumentation/taler-docs/taler-kych-manual.rst | 56+++++++++++++++++++++++++++++++++-----------------------
Mkych_oauth2_gateway/Cargo.lock | 343+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mkych_oauth2_gateway/Cargo.toml | 9+++++----
Mkych_oauth2_gateway/README | 7+++++--
Mkych_oauth2_gateway/src/bin/client_management_cli.rs | 32+++++++++++++++++++-------------
Mkych_oauth2_gateway/src/config.rs | 530+++++++++++++++++++++++++++++++++----------------------------------------------
Mkych_oauth2_gateway/src/db/mod.rs | 8++++----
Mkych_oauth2_gateway/src/main.rs | 116+++++++++++++++++++++++--------------------------------------------------------
Mkych_oauth2_gateway/tests/handlers_integration.rs | 60+++++++++++++++++++++++-------------------------------------
Ataler-config/Cargo.lock | 2106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/Cargo.toml | 45+++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/README | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/src/config.rs | 1342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/src/lib.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/src/log.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-config/src/serve.rs | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&section) + { + 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(&timestamp, 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() + ); + } +}