diff --git a/.gitignore b/.gitignore index 0ea0f8d..a5c0bbf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,3 @@ \#* .\#* *.swp - -# Test files -/test -test.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 663b09a..fb6377b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,48 +6,69 @@ [//]: # (without any warranty.) # Changelog + All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Instead of loading a default configuration file, ACMEd now loads all the files from a default configuration directory (by default, `/etc/acmed/conf-enabled`). + +### Removed + +- tacd has been removed. +- The `include` directive has been removed from the configuration. + + ## [0.24.0] - 2024-12-21 ### Added + - The file extension can now be customized. ### Changed + - tacd does no longer supports OpenSSL 1.0. ## [0.23.0] - 2024-02-10 ### Added + - The `challenge-tls-alpn-01` hook now exposes the `raw_proof` variable, which contains the SHA-256 digest of the key authorization, encoded using Base64 URL scheme without padding. ### Changed + - The minimum supported Rust version (MSRV) is now 1.74. ## [0.22.2] - 2024-01-09 ### Fixed + - The default hooks were not properly updated during the 0.22.0 release, which causes the certificate renewal to fail. ## [0.22.1] - 2023-12-20 ### Fixed + - The `Cargo.lock` file is now updated before a new version is released (GitHub bug #103). ## [0.22.0] - 2023-12-20 ### Fixed + - ACMEd no longer crashes when the `random_early_renew` parameter is set to zero (GitHub bug #102). ### Changed + - The minimum supported Rust version (MSRV) is now 1.70. - Manual (and badly designed) threads have been replaced by async. - Randomized early delay, for spacing out renewals when dealing with a lot of certificates. @@ -58,18 +79,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.21.0] - 2022-12-19 ### Fixed + - The JWK representation of ECDSA keys now have their coordinates padded. ### Changed + - The minimal required Rust version is now 1.60. ## [0.20.0] - 2022-05-08 ### Added + - The `--no-pid-file` argument has been added to ACMEd and tacd. ### Fixed + - An invalid reference in the command line arguments has been fixed. - Some missing file path in log messages has been added. - The calculation of the certificate's expiration delay does no longer break compilation on some systems. @@ -78,14 +103,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.19.0] - 2022-04-17 ### Added + - The `acmed@user.service` systemd unit configuration has been added as an alternative to the `acmed.service` unit. ### Changed + - The minimal required Rust version is now 1.54. ## [0.18.0] - 2021-06-13 ### Added + - Add support for Ed25519 and Ed448 account keys and certificates. - In addition to `restart`, the Polkit rule also allows the `reload`, `try-restart`, `reload-or-restart` and `try-reload-or-restart` verbs. @@ -93,9 +121,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.17.0] - 2021-05-04 ### Added + - Allow the configuration of some default values at compile time using environment variables. ### Changed + - The template engine has been changed in favor of TinyTemplate, which has a different syntax than the previous one. - The default account directory now is `/var/lib/acmed/accounts`. - The default certificates and private keys directory now is `/var/lib/acmed/certs`. @@ -105,9 +135,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.16.0] - 2020-11-11 ### Added + - The `pkcs9_email_address`, `postal_address` and `postal_code` subject attributes has been added. ### Changed + - The `friendly_name` and `pseudonym` subject attributes has been removed. - The `street_address` subject attribute has been renamed `street`. @@ -115,28 +147,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.15.0] - 2020-11-03 ### Added + - The names of both the certificate file and the associated private key can now be configured. ### Fixed + - Configuration files cannot be loaded more than one time, which prevents infinite recursion. ### Changed + - Certificates are now allowed to share the same name if their respective key type is different. ## [0.14.0] - 2020-10-27 ### Added + - Add proxy support through the `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` environment variables. - Allow to specify a unique name for each certificate. ### Changed + - The minimal required Rust version is 1.42.0. ## [0.13.0] - 2020-10-10 ### Added + - In the configuration, `root_certificates` has been added to the `global` and `endpoint` sections as an array of strings representing the path to root certificate files. - At compilation, it is now possible to statically link OpenSSL using the `openssl_vendored` feature. - In the Makefile, it is now possible to specify which target triple to build for. @@ -145,25 +183,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] - 2020-09-26 ### Added + - Some subject attributes can now be specified. - Support for NIST P-521 certificates and account keys. ### Fixed + - Support for Let's Encrypt non-standard account creation object. ## [0.11.0] - 2020-09-19 ### Added + - The `contacts` account configuration field has been added. - External account binding. ### Changed + - The `email` account configuration field has been removed. In replacement, use the `contacts` field. - Accounts now have their own hooks and environment. - Accounts are now stored in a single binary file. ### Fixed + - ACMEd can now build on platforms with a `time_t` not defined as an `i64`. - The Makefile is now fully works on FreeBSD. @@ -171,6 +214,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.10.0] - 2020-08-27 ### Added + - The account key type and signature algorithm can now be specified in the configuration using the `key_type` and `signature_algorithm` parameters. - The delay to renew a certificate before its expiration date can be specified in the configuration using the `renew_delay` parameter at either the certificate, endpoint and global level. - It is now possible to specify IP identifiers (RFC 8738) using the `ip` parameter instead of the `dns` one. @@ -179,6 +223,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The CSR's digest algorithm can now be specified using the `csr_digest` parameter. ### Changed + - In the certificate configuration, the `domains` field has been renamed `identifiers`. - The `algorithm` certificate configuration field has been renamed `key_type`. - The `algorithm` hook template variable has been renamed `key_type`. @@ -186,46 +231,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default hooks have been updated. ### Fixed + - The Makefile now works on FreeBSD. It should also work on other BSD although it has not been tested. ## [0.9.0] - 2020-08-01 ### Added + - System users and groups can now be specified by name in addition to uid/gid. ### Changed + - The HTTP(S) part is now handled by `attohttpc` instead of `reqwest`. ### Fixed + - In tacd, the `--acme-ext-file` parameter is now in conflict with `acme-ext` instead of itself. ## [0.8.0] - 2020-06-12 ### Changed + - The HTTP(S) part is now handled by `reqwest` instead of `http_req`. -## Fixed +### Fixed + - `make install` now work with the busybox toolchain. ## [0.7.0] - 2020-03-12 ### Added + - Wildcard certificates are now supported. In the file name, the `*` is replaced by `_`. - Internationalized domain names are now supported. ### Changed + - The PID file is now always written whether or not ACMEd is running in the foreground. Previously, it was written only when running in the background. ### Fixed + - In the directory, the `externalAccountRequired` field is now a boolean instead of a string. ## [0.6.1] - 2019-09-13 ### Fixed + - A race condition when requesting multiple certificates on the same non-existent account has been fixed. - The `foregroung` option has been renamed `foreground`. @@ -233,23 +288,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.0] - 2019-06-05 ### Added + - Hooks now have the optional `allow_failure` field. - In hooks, the `stdin_str` has been added in replacement of the previous `stdin` behavior. - HTTPS request rate limits. ### Changed + - Certificates are renewed in parallel. - Hooks are now cleaned right after the current challenge has been validated instead of after the certificate's retrieval. - In hooks, the `stdin` field now refers to the path of the file that should be written into the hook's standard input. - The logging format has been re-written. ### Fixed + - The http-01-echo hook now correctly sets the file's access rights ## [0.5.0] - 2019-05-09 ### Added + - ACMEd now displays a warning when the server indicates an error in an order or an authorization. - A configuration file can now include several other files. - Hooks have access to environment variables. @@ -260,6 +319,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.4.0] - 2019-05-08 ### Added + - Man pages. - The project can now be built and installed using `make`. - The post-operation hooks now have access to the `is_success` template variable. @@ -267,18 +327,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - An existing certificate will be renewed if more domains have been added in the configuration. ### Changed + - Unknown configuration fields are no longer tolerated. ### Removed + - In challenge hooks, the `algorithm` template variable has been removed. ### Fixed + - In some cases, ACMEd was unable to parse a certificate's expiration date. ## [0.3.0] - 2019-04-30 ### Added + - tacd, the TLS-ALPN-01 validation daemon. - An account object has been added in the configuration. - In the configuration, hooks now have a mandatory `type` variable. @@ -288,6 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The TLS-ALPN-01 challenge is now supported. The proof is a string representation of the acmeIdentifier extension. The self-signed certificate itself has to be built by a hook. ### Changed + - In the configuration, the `email` certificate field has been replaced by the `account` field which matches an account object. - The format of the `domain` configuration variable has changed and now includes the challenge type. - The `token` challenge hook variable has been renamed `file_name`. @@ -295,25 +360,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The logs has been purged from many useless debug and trace entries. ### Removed + - The DER storage format has been removed. - The `challenge` certificate variables has been removed. ## [0.2.1] - 2019-03-30 -### Changed +### Fixed + - The bug that prevented from requesting more than two certificates has been fixed. ## [0.2.0] - 2019-03-27 ### Added + - The `kp_reuse` flag allow to reuse a key pair instead of creating a new one at each renewal. - It is now possible to define hook groups that can reference either hooks or other hook groups. - Hooks can be defined when before and after a file is created or edited (`file_pre_create_hooks`, `file_post_create_hooks`, `file_pre_edit_hooks` and `file_post_edit_hooks`). - It is now possible to send logs either to syslog or stderr using the `--to-syslog` and `--to-stderr` arguments. ### Changed + - `post_operation_hook` has been renamed `post_operation_hooks`. - By default, logs are now sent to syslog instead of stderr. - The process is now daemonized by default. It is possible to still run it in the foreground using the `--foregroung` flag. diff --git a/Cargo.lock b/Cargo.lock index 943b0bd..02e6177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,11 +8,15 @@ version = "0.25.0-dev" dependencies = [ "anyhow", "clap", + "config", "daemonize", + "serde", + "serde_derive", "syslog-tracing", "tokio", "tracing", "tracing-subscriber", + "walkdir", ] [[package]] @@ -151,6 +155,18 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "nom", + "pathdiff", + "serde", + "toml", +] + [[package]] name = "daemonize" version = "0.5.0" @@ -160,18 +176,40 @@ dependencies = [ "libc", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -196,6 +234,12 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.2" @@ -205,6 +249,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -236,6 +290,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -266,6 +326,44 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -317,6 +415,40 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.41" @@ -367,6 +499,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "winapi" version = "0.3.9" @@ -383,6 +525,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -461,3 +612,12 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index 2d8868a..24f9063 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,12 +23,16 @@ ed448 = [] [dependencies] anyhow = { version = "1.0.94", default-features = false, features = ["std"] } -clap = { version = "4.5.23", default-features = false, features = ["color", "derive", "help", "std"] } +clap = { version = "4.5.23", default-features = false, features = ["color", "derive", "help", "std", "string"] } +config = { version = "0.14.0", default-features = false, features = ["toml"] } daemonize = { version = "0.5.0", default-features = false } +serde = { version = "1.0.216", default-features = false, features = ["std"] } +serde_derive = { version = "1.0.216", default-features = false } syslog-tracing = { version = "0.3.1", default-features = false } tokio = { version = "1.42.0", default-features = false, features = ["rt", "rt-multi-thread", "time"] } tracing = { version = "0.1.41", default-features = false, features = ["std"] } tracing-subscriber = { version = "0.3.19", default-features = false, features = ["ansi", "fmt", "std"] } +walkdir = { version = "2.5.0", default-features = false } [target.'cfg(unix)'.dependencies] diff --git a/Makefile b/Makefile index 31ad99c..efaffba 100644 --- a/Makefile +++ b/Makefile @@ -37,9 +37,7 @@ install: install -m 0755 $(TARGET_DIR)/acmed $(DESTDIR)$(BINDIR)/acmed; \ install -m 0644 $(TARGET_DIR)/man/acmed.8.gz $(DESTDIR)$(MAN8DIR)/acmed.8.gz; \ install -m 0644 $(TARGET_DIR)/man/acmed.toml.5.gz $(DESTDIR)$(MAN5DIR)/acmed.toml.5.gz; \ - install -m 0644 acmed/config/acmed.toml $(DESTDIR)$(SYSCONFDIR)/acmed/acmed.toml; \ - install -m 0644 acmed/config/default_hooks.toml $(DESTDIR)$(SYSCONFDIR)/acmed/default_hooks.toml; \ - install -m 0644 acmed/config/letsencrypt.toml $(DESTDIR)$(SYSCONFDIR)/acmed/letsencrypt.toml; \ + install -m 0644 acmed/config/letsencrypt.toml $(DESTDIR)$(SYSCONFDIR)/acmed/conf-available/letsencrypt.toml; \ fi clean: diff --git a/config/acmed.toml b/config/acmed.toml deleted file mode 100644 index ea64037..0000000 --- a/config/acmed.toml +++ /dev/null @@ -1,9 +0,0 @@ -# ------------------------------------------------------------------------ -# Base configruation for ACMEd -# You should adapt this file to include/enhance with your custom toml's -# ------------------------------------------------------------------------ - -include = [ - "default_hooks.toml", - "letsencrypt.toml", -] diff --git a/config/default_hooks.toml b/config/default_hooks.toml deleted file mode 100644 index 07114a3..0000000 --- a/config/default_hooks.toml +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2019-2020 Rodolphe Bréard -# -# Copying and distribution of this file, with or without modification, -# are permitted in any medium without royalty provided the copyright -# notice and this notice are preserved. This file is offered as-is, -# without any warranty. - -# ------------------------------------------------------------------------ -# Default hooks for ACMEd -# You should not edit this file since it may be overridden by a newer one. -# ------------------------------------------------------------------------ - - -# -# http-01 challenge in "/var/www/{{ identifier }}/" -# - -[[hook]] -name = "http-01-echo-mkdir" -type = ["challenge-http-01"] -cmd = "mkdir" -args = [ - "-m", "0755", - "-p", "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge" -] -allow_failure = true - -[[hook]] -name = "http-01-echo-echo" -type = ["challenge-http-01"] -cmd = "echo" -args = ["{{ proof }}"] -stdout = "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}" - -[[hook]] -name = "http-01-echo-chmod" -type = ["challenge-http-01"] -cmd = "chmod" -args = [ - "a+r", - "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}" -] -allow_failure = true - -[[hook]] -name = "http-01-echo-clean" -type = ["challenge-http-01-clean"] -cmd = "rm" -args = [ - "-f", - "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}" -] -allow_failure = true - -[[group]] -name = "http-01-echo" -hooks = [ - "http-01-echo-mkdir", - "http-01-echo-echo", - "http-01-echo-chmod", - "http-01-echo-clean" -] - - -# -# tls-alpn-01 challenge with tacd -# - -[[hook]] -name = "tls-alpn-01-tacd-start-tcp" -type = ["challenge-tls-alpn-01"] -cmd = "tacd" -args = [ - "--pid-file", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid", - "--domain", "{{ identifier_tls_alpn }}", - "--acme-ext", "{{ proof }}", - "--listen", "{{ env.TACD_PORT | default('5001') }}" -] - -[[hook]] -name = "tls-alpn-01-tacd-start-unix" -type = ["challenge-tls-alpn-01"] -cmd = "tacd" -args = [ - "--pid-file", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid", - "--domain", "{{ identifier_tls_alpn }}", - "--acme-ext", "{{ proof }}", - "--listen", "unix:{{ env.TACD_SOCK_ROOT | default('/run') }}/tacd_{{ identifier }}.sock" -] - -[[hook]] -name = "tls-alpn-01-tacd-kill" -type = ["challenge-tls-alpn-01-clean"] -cmd = "pkill" -args = [ - "-F", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid", -] -allow_failure = true - -[[hook]] -name = "tls-alpn-01-tacd-rm" -type = ["challenge-tls-alpn-01-clean"] -cmd = "rm" -args = [ - "-f", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid", -] -allow_failure = true - -[[group]] -name = "tls-alpn-01-tacd-tcp" -hooks = ["tls-alpn-01-tacd-start-tcp", "tls-alpn-01-tacd-kill", "tls-alpn-01-tacd-rm"] - -[[group]] -name = "tls-alpn-01-tacd-unix" -hooks = ["tls-alpn-01-tacd-start-unix", "tls-alpn-01-tacd-kill", "tls-alpn-01-tacd-rm"] - - -# -# Git storage hook -# - -[[hook]] -name = "git-init" -type = ["file-pre-create", "file-pre-edit"] -cmd = "git" -args = [ - "init", - "{{ file_directory }}" -] - -[[hook]] -name = "git-add" -type = ["file-post-create", "file-post-edit"] -cmd = "git" -args = [ - "-C", "{{ file_directory }}", - "add", "{{ file_name }}" -] -allow_failure = true - -[[hook]] -name = "git-commit" -type = ["file-post-create", "file-post-edit"] -cmd = "git" -args = [ - "-C", "{{ file_directory }}", - "-c", "user.name='{{ env.GIT_USERNAME | default('ACMEd') }}'", - "-c", "user.email='{{ env.GIT_EMAIL | default('acmed@localhost') }}'", - "commit", - "-m", "{{ file_name }}", - "--only", "{{ file_name }}" -] -allow_failure = true - -[[group]] -name = "git" -hooks = ["git-init", "git-add", "git-commit"] diff --git a/man/en/acmed.8 b/man/en/acmed.8 index 96794a5..02c78b5 100644 --- a/man/en/acmed.8 +++ b/man/en/acmed.8 @@ -12,7 +12,7 @@ .Nd ACME client daemon .Sh SYNOPSIS .Nm -.Op Fl c|--config Ar FILE +.Op Fl c|--config Ar DIR .Op Fl f|--foreground .Op Fl h|--help .Op Fl -log-stderr @@ -32,32 +32,32 @@ client daemon which can automatically request and renew X.509 security certifica The options are as follows: .Bl -tag .It Fl c, -config Ar FILE -Specify an alternative configuration file. +Specify an alternative configuration directory. .It Fl f, -foreground -Runs in the foreground +Runs in the foreground. .It Fl h, -help -Prints help information +Prints help information. .It Fl -log-stderr -Prints log messages to the standard error output +Prints log messages to the standard error output. .It Fl -log-syslog -Sends log messages via syslog +Sends log messages via syslog. .It Fl -log-level Ar LEVEL Specify the log level. Possible values: error, warn, info, debug and trace. .It Fl -no-pid-file -Do not create any PID file +Do not create any PID file. .It Fl -pid-file Ar FILE -Specifies the location of the PID file +Specifies the location of the PID file. .It Fl -root-cert Ar FILE Add a root certificate to the trust store. This option can be used multiple times. .It Fl V, -version -Prints version information +Prints version information. .El .Sh FILES .Bl -tag -.It Pa /etc/acmed/acmed.toml +.It Pa /etc/acmed/conf-enabled Default .Nm -configuration file. +configuration directory. .El .Sh SEE ALSO .Xr acmed.toml 5 , diff --git a/man/en/acmed.toml.5 b/man/en/acmed.toml.5 index 6a902e7..7632f65 100644 --- a/man/en/acmed.toml.5 +++ b/man/en/acmed.toml.5 @@ -287,7 +287,8 @@ Specify the user who will own newly-created certificates files. See .Xr chown 2 for more details. .It Cm cert_file_ext Ft string -Specify the file extension of certificate files. +Specify the file extension of certificate files. Default is +.Dq pem . .It Cm certificates_directory Ar string Specify the directory where the certificates and their associated private keys are stored. .It Ic env Ar table @@ -382,19 +383,6 @@ file-pre-edit post-operation .El .El -.It Ic include -Array containing the path to configuration file to include. The path can be either relative or absolute. If relative, it is relative to the configuration file which included it. -.Pp -In case or overlapping global option definition, the one of the last included file will be used. For example, if a file -.Em A -includes files -.Em B -and -.Em C -and all three defines the same global option, the final value will be the one defined in file -.Em C . -.Pp -Unix style globing is supported. .It Ic rate-limit Array of table where each element defines a HTTPS rate limit. .Bl -tag diff --git a/src/cli.rs b/src/cli.rs index 9ffd330..544db03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct CliArgs { - /// Path to the main configuration file - #[arg(short, long, value_name = "FILE", default_value = crate::DEFAULT_CONFIG_PATH)] + /// Path to the main configuration directory + #[arg(short, long, value_name = "DIR", default_value = get_default_config_dir().into_os_string())] pub config: PathBuf, /// Specify the log level @@ -44,7 +44,7 @@ pub struct Log { #[group(multiple = false)] pub struct Pid { /// Path to the PID file - #[arg(long, value_name = "FILE", default_value = crate::DEFAULT_PID_FILE)] + #[arg(long, value_name = "FILE", default_value = get_default_pid_file().into_os_string())] pid_file: PathBuf, /// Do not create any PID file @@ -62,6 +62,25 @@ impl Pid { } } +fn get_default_config_dir() -> PathBuf { + let mut path = match option_env!("SYSCONFDIR") { + Some(s) => PathBuf::from(s), + None => PathBuf::from("/etc"), + }; + path.push("acmed"); + path.push("conf-enabled"); + path +} + +fn get_default_pid_file() -> PathBuf { + let mut path = match option_env!("RUNSTATEDIR") { + Some(s) => PathBuf::from(s), + None => PathBuf::from("/run"), + }; + path.push("acmed.pid"); + path +} + #[cfg(test)] mod tests { use super::*; @@ -76,16 +95,16 @@ mod tests { fn no_args() { let args: &[&str] = &[]; let pa = CliArgs::try_parse_from(args).unwrap(); - assert_eq!(pa.config, PathBuf::from(crate::DEFAULT_CONFIG_PATH)); + assert_eq!(pa.config, get_default_config_dir()); assert_eq!(pa.log_level, Level::Warn); assert_eq!(pa.log.log_syslog, false); assert_eq!(pa.log.log_stderr, false); assert_eq!(pa.foreground, false); - assert_eq!(pa.pid.pid_file, PathBuf::from(crate::DEFAULT_PID_FILE)); + assert_eq!(pa.pid.pid_file, get_default_pid_file()); assert_eq!(pa.pid.no_pid_file, false); assert_eq!( pa.pid.get_pid_file(), - Some(PathBuf::from(crate::DEFAULT_PID_FILE).as_path()) + Some(get_default_pid_file().as_path()) ); assert!(pa.root_cert.is_empty()); } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..39761f4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,161 @@ +mod account; +mod certificate; +mod endpoint; +mod global; +mod hook; +mod rate_limit; + +pub use account::*; +pub use certificate::*; +pub use endpoint::*; +pub use global::*; +pub use hook::*; +pub use rate_limit::*; + +use anyhow::{Context, Result}; +use config::{Config, File}; +use serde_derive::Deserialize; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +const ALLOWED_FILE_EXT: &[&str] = &["toml"]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AcmedConfig { + pub global: Option, + #[serde(default)] + pub endpoint: Vec, + #[serde(default, rename = "rate-limit")] + pub rate_limit: Vec, + #[serde(default)] + pub hook: Vec, + #[serde(default)] + pub group: Vec, + #[serde(default)] + pub account: Vec, + #[serde(default)] + pub certificate: Vec, +} + +pub fn load>(config_dir: P) -> Result { + let config_dir = config_dir.as_ref(); + tracing::debug!("loading config directory: {config_dir:?}"); + let settings = Config::builder() + .add_source( + get_files(config_dir)? + .iter() + .map(|path| File::from(path.as_path())) + .collect::>(), + ) + .build()?; + tracing::trace!("loaded config: {settings:?}"); + let config: AcmedConfig = settings.try_deserialize().context("invalid setting")?; + tracing::debug!("computed config: {config:?}"); + Ok(config) +} + +fn get_files(config_dir: &Path) -> Result> { + let mut file_lst = Vec::new(); + for entry in WalkDir::new(config_dir).follow_links(true) { + let path = entry?.path().to_path_buf(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ALLOWED_FILE_EXT.iter().any(|&e| e == ext) { + std::fs::File::open(&path).with_context(|| path.display().to_string())?; + file_lst.push(path); + } + } + } + } + file_lst.sort(); + tracing::debug!("configuration files found: {file_lst:?}"); + Ok(file_lst) +} + +#[cfg(test)] +fn load_str<'de, T: serde::de::Deserialize<'de>>(config_str: &str) -> Result { + let settings = Config::builder() + .add_source(File::from_str(config_str, config::FileFormat::Toml)) + .build()?; + let config: T = settings.try_deserialize().context("invalid setting")?; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty() { + let cfg = load("tests/config/empty").unwrap(); + assert!(cfg.global.is_none()); + assert!(cfg.rate_limit.is_empty()); + assert!(cfg.endpoint.is_empty()); + assert!(cfg.hook.is_empty()); + assert!(cfg.group.is_empty()); + assert!(cfg.account.is_empty()); + assert!(cfg.certificate.is_empty()); + } + + #[test] + fn simple() { + let cfg = load("tests/config/simple").unwrap(); + assert!(cfg.global.is_some()); + let global = cfg.global.unwrap(); + assert_eq!( + global.accounts_directory, + PathBuf::from("/tmp/example/account/dir") + ); + assert_eq!( + global.certificates_directory, + PathBuf::from("/tmp/example/cert/dir") + ); + assert!(cfg.rate_limit.is_empty()); + assert!(cfg.endpoint.is_empty()); + assert!(cfg.hook.is_empty()); + assert!(cfg.group.is_empty()); + assert_eq!(cfg.account.len(), 1); + let account = cfg.account.first().unwrap(); + assert_eq!(account.contacts.len(), 1); + assert!(account.env.is_empty()); + assert!(account.external_account.is_none()); + assert!(account.hooks.is_empty()); + assert_eq!(account.key_type, AccountKeyType::EcDsaP256); + assert_eq!(account.name, "example"); + assert_eq!( + account.signature_algorithm, + Some(AccountSignatureAlgorithm::Hs384) + ); + assert!(cfg.certificate.is_empty()); + } + + #[test] + fn setting_override() { + let cfg = load("tests/config/override").unwrap(); + assert!(cfg.global.is_some()); + let global = cfg.global.unwrap(); + assert_eq!( + global.accounts_directory, + PathBuf::from("/tmp/other/account/dir") + ); + assert_eq!( + global.certificates_directory, + PathBuf::from("/tmp/example/cert/dir") + ); + assert!(cfg.rate_limit.is_empty()); + assert!(cfg.endpoint.is_empty()); + assert!(cfg.hook.is_empty()); + assert!(cfg.group.is_empty()); + assert_eq!(cfg.account.len(), 1); + let account = cfg.account.first().unwrap(); + assert_eq!(account.contacts.len(), 1); + assert!(account.env.is_empty()); + assert!(account.external_account.is_none()); + assert!(account.hooks.is_empty()); + assert_eq!(account.key_type, AccountKeyType::EcDsaP256); + assert_eq!(account.name, "example"); + assert!(account.signature_algorithm.is_none()); + assert!(cfg.certificate.is_empty()); + } +} diff --git a/src/config/account.rs b/src/config/account.rs new file mode 100644 index 0000000..402ca0e --- /dev/null +++ b/src/config/account.rs @@ -0,0 +1,243 @@ +use serde::{de, Deserialize, Deserializer}; +use serde_derive::Deserialize; +use std::collections::HashMap; + +#[derive(Clone, Debug, Deserialize)] +#[serde(remote = "Self")] +#[serde(deny_unknown_fields)] +pub struct Account { + pub contacts: Vec, + #[serde(default)] + pub env: HashMap, + pub external_account: Option, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub key_type: AccountKeyType, + pub name: String, + #[serde(default)] + pub signature_algorithm: Option, +} + +impl<'de> Deserialize<'de> for Account { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let unchecked = Account::deserialize(deserializer)?; + if unchecked.contacts.is_empty() { + return Err(de::Error::custom("at least one contact must be specified")); + } + Ok(unchecked) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct AccountContact { + pub mailto: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ExternalAccount { + pub identifier: String, + pub key: String, + #[serde(default)] + pub signature_algorithm: ExternalAccountSignatureAlgorithm, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub enum AccountKeyType { + Ed25519, + Ed448, + #[default] + EcDsaP256, + EcDsaP384, + EcDsaP521, + Rsa2048, + Rsa4096, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub enum AccountSignatureAlgorithm { + Hs256, + Hs384, + Hs512, + Rs256, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub enum ExternalAccountSignatureAlgorithm { + #[default] + Hs256, + Hs384, + Hs512, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty_account() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn account_minimal() { + let cfg = r#" +name = "test" +contacts = [ + { mailto = "acme@example.org" } +] +"#; + + let a: Account = load_str(cfg).unwrap(); + assert_eq!( + a.contacts, + vec![AccountContact { + mailto: "acme@example.org".to_string() + }] + ); + assert!(a.env.is_empty()); + assert!(a.external_account.is_none()); + assert!(a.hooks.is_empty()); + assert_eq!(a.key_type, AccountKeyType::EcDsaP256); + assert_eq!(a.name, "test"); + assert!(a.signature_algorithm.is_none()); + } + + #[test] + fn account_full() { + let cfg = r#" +name = "test" +contacts = [ + { mailto = "acme@example.org" } +] +env.TEST = "Test" +external_account.identifier = "toto" +external_account.key = "VGhpcyBpcyBhIHRlc3Q=" +hooks = ["hook name"] +key_type = "rsa2048" +signature_algorithm = "HS512" +"#; + let mut env = HashMap::with_capacity(2); + env.insert("test".to_string(), "Test".to_string()); + let ea = ExternalAccount { + identifier: "toto".to_string(), + key: "VGhpcyBpcyBhIHRlc3Q=".to_string(), + signature_algorithm: ExternalAccountSignatureAlgorithm::Hs256, + }; + let a: Account = load_str(cfg).unwrap(); + assert_eq!( + a.contacts, + vec![AccountContact { + mailto: "acme@example.org".to_string() + }] + ); + assert_eq!(a.env, env); + assert_eq!(a.external_account, Some(ea)); + assert_eq!(a.hooks, vec!["hook name".to_string()]); + assert_eq!(a.key_type, AccountKeyType::Rsa2048); + assert_eq!(a.name, "test"); + assert_eq!( + a.signature_algorithm, + Some(AccountSignatureAlgorithm::Hs512) + ); + } + + #[test] + fn account_missing_name() { + let cfg = r#" +contacts = [ + { mailto = "acme@example.org" } +] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn account_missing_contact() { + let cfg = r#"name = "test""#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn account_empty_contact() { + let cfg = r#" +name = "test" +contacts = [] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn empty_account_contact() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn account_contact_mailto() { + let cfg = r#"mailto = "test.acme@example.org""#; + let ac: AccountContact = load_str(cfg).unwrap(); + assert_eq!(ac.mailto, "test.acme@example.org"); + } + + #[test] + fn empty_external_account() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn external_account_minimal() { + let cfg = r#" +identifier = "toto" +key = "VGhpcyBpcyBhIHRlc3Q=" +"#; + let ea: ExternalAccount = load_str(cfg).unwrap(); + assert_eq!(ea.identifier, "toto"); + assert_eq!(ea.key, "VGhpcyBpcyBhIHRlc3Q="); + assert_eq!( + ea.signature_algorithm, + ExternalAccountSignatureAlgorithm::Hs256 + ); + } + + #[test] + fn external_account_full() { + let cfg = r#" +identifier = "toto" +key = "VGhpcyBpcyBhIHRlc3Q=" +signature_algorithm = "HS384" +"#; + let ea: ExternalAccount = load_str(cfg).unwrap(); + assert_eq!(ea.identifier, "toto"); + assert_eq!(ea.key, "VGhpcyBpcyBhIHRlc3Q="); + assert_eq!( + ea.signature_algorithm, + ExternalAccountSignatureAlgorithm::Hs384 + ); + } + + #[test] + fn external_account_missing_identifier() { + let cfg = r#"key = "VGhpcyBpcyBhIHRlc3Q=""#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn external_account_missing_key() { + let cfg = r#"identifier = "toto""#; + let res = load_str::(cfg); + assert!(res.is_err()); + } +} diff --git a/src/config/certificate.rs b/src/config/certificate.rs new file mode 100644 index 0000000..bbaef4a --- /dev/null +++ b/src/config/certificate.rs @@ -0,0 +1,334 @@ +use anyhow::Result; +use serde::{de, Deserialize, Deserializer}; +use serde_derive::Deserialize; +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(remote = "Self")] +#[serde(deny_unknown_fields)] +pub struct Certificate { + pub account: String, + #[serde(default)] + pub csr_digest: CsrDigest, + pub directory: Option, + pub endpoint: String, + #[serde(default)] + pub env: HashMap, + pub file_name_format: Option, + pub hooks: Vec, + pub identifiers: Vec, + #[serde(default)] + pub key_type: KeyType, + #[serde(default)] + pub kp_reuse: bool, + pub name: Option, + pub random_early_renew: Option, + pub renew_delay: Option, + #[serde(default)] + pub subject_attributes: SubjectAttributes, +} + +impl<'de> Deserialize<'de> for Certificate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let unchecked = Certificate::deserialize(deserializer)?; + if unchecked.hooks.is_empty() { + return Err(de::Error::custom("at least one hook must be specified")); + } + if unchecked.identifiers.is_empty() { + return Err(de::Error::custom( + "at least one identifier must be specified", + )); + } + Ok(unchecked) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub enum CsrDigest { + #[default] + Sha256, + Sha384, + Sha512, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(remote = "Self")] +#[serde(deny_unknown_fields)] +pub struct Identifier { + pub challenge: AcmeChallenge, + pub dns: Option, + #[serde(default)] + pub env: HashMap, + pub ip: Option, +} + +impl<'de> Deserialize<'de> for Identifier { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let unchecked = Identifier::deserialize(deserializer)?; + let filled_nb: u8 = [unchecked.dns.is_some(), unchecked.ip.is_some()] + .iter() + .copied() + .map(u8::from) + .sum(); + if filled_nb != 1 { + return Err(de::Error::custom( + "one and only one of `dns` or `ip` must be specified", + )); + } + Ok(unchecked) + } +} + +impl fmt::Display for Identifier { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = String::new(); + let msg = self.dns.as_ref().or(self.ip.as_ref()).unwrap_or(&s); + write!(f, "{msg}") + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub enum AcmeChallenge { + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +pub enum KeyType { + #[serde(rename = "ed25519")] + Ed25519, + #[serde(rename = "ed448")] + Ed448, + #[serde(rename = "ecdsa_p256")] + EcDsaP256, + #[serde(rename = "ecdsa_p384")] + EcDsaP384, + #[serde(rename = "ecdsa_p521")] + EcDsaP521, + #[default] + #[serde(rename = "rsa2048")] + Rsa2048, + #[serde(rename = "rsa4096")] + Rsa4096, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectAttributes { + pub country_name: Option, + pub generation_qualifier: Option, + pub given_name: Option, + pub initials: Option, + pub locality_name: Option, + pub name: Option, + pub organization_name: Option, + pub organizational_unit_name: Option, + pub pkcs9_email_address: Option, + pub postal_address: Option, + pub postal_code: Option, + pub state_or_province_name: Option, + pub street: Option, + pub surname: Option, + pub title: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty_certificate() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn cert_minimal() { + let cfg = r#" +account = "test" +endpoint = "dummy" +hooks = ["hook 01"] +identifiers = [ + { dns = "example.org", challenge = "http-01"}, +] +"#; + let c = load_str::(cfg).unwrap(); + assert_eq!(c.account, "test"); + assert_eq!(c.csr_digest, CsrDigest::Sha256); + assert!(c.directory.is_none()); + assert_eq!(c.endpoint, "dummy"); + assert!(c.env.is_empty()); + assert!(c.file_name_format.is_none()); + assert_eq!(c.hooks, vec!["hook 01".to_string()]); + assert_eq!(c.identifiers.len(), 1); + assert_eq!(c.key_type, KeyType::Rsa2048); + assert_eq!(c.kp_reuse, false); + assert!(c.name.is_none()); + assert!(c.random_early_renew.is_none()); + assert!(c.renew_delay.is_none()); + } + + #[test] + fn cert_full() { + let cfg = r#" +account = "test" +csr_digest = "sha512" +directory = "/tmp/certs" +endpoint = "dummy" +env.TEST = "some env" +file_name_format = "test.pem" +hooks = ["hook 01"] +identifiers = [ + { dns = "example.org", challenge = "http-01"}, +] +key_type = "ecdsa_p256" +kp_reuse = true +name = "test" +random_early_renew = "1d" +renew_delay = "30d" +subject_attributes.country_name = "FR" +subject_attributes.organization_name = "ACME Inc." +"#; + let c = load_str::(cfg).unwrap(); + assert_eq!(c.account, "test"); + assert_eq!(c.csr_digest, CsrDigest::Sha512); + assert_eq!(c.directory, Some(PathBuf::from("/tmp/certs"))); + assert_eq!(c.endpoint, "dummy"); + assert_eq!(c.env.len(), 1); + assert_eq!(c.file_name_format, Some("test.pem".to_string())); + assert_eq!(c.hooks, vec!["hook 01".to_string()]); + assert_eq!(c.identifiers.len(), 1); + assert_eq!(c.key_type, KeyType::EcDsaP256); + assert_eq!(c.kp_reuse, true); + assert_eq!(c.name, Some("test".to_string())); + assert_eq!(c.random_early_renew, Some("1d".to_string())); + assert_eq!(c.renew_delay, Some("30d".to_string())); + assert_eq!(c.subject_attributes.country_name, Some("FR".to_string())); + assert!(c.subject_attributes.generation_qualifier.is_none()); + assert!(c.subject_attributes.given_name.is_none()); + assert!(c.subject_attributes.initials.is_none()); + assert!(c.subject_attributes.locality_name.is_none()); + assert!(c.subject_attributes.name.is_none()); + assert_eq!( + c.subject_attributes.organization_name, + Some("ACME Inc.".to_string()) + ); + assert!(c.subject_attributes.organizational_unit_name.is_none()); + assert!(c.subject_attributes.pkcs9_email_address.is_none()); + assert!(c.subject_attributes.postal_address.is_none()); + assert!(c.subject_attributes.postal_code.is_none()); + assert!(c.subject_attributes.state_or_province_name.is_none()); + assert!(c.subject_attributes.street.is_none()); + assert!(c.subject_attributes.surname.is_none()); + assert!(c.subject_attributes.title.is_none()); + } + + #[test] + fn cert_empty_hooks() { + let cfg = r#" +account = "test" +endpoint = "dummy" +hooks = [] +identifiers = [ + { dns = "example.org", challenge = "http-01"}, +] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn cert_empty_identifiers() { + let cfg = r#" +account = "test" +endpoint = "dummy" +hooks = ["hook 01"] +identifiers = [] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn empty_identifier() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn identifier_dns() { + let cfg = r#" +challenge = "dns-01" +dns = "example.org" +"#; + let i = load_str::(cfg).unwrap(); + assert_eq!(i.challenge, AcmeChallenge::Dns01); + assert_eq!(i.dns, Some("example.org".to_string())); + assert!(i.env.is_empty()); + assert!(i.ip.is_none()); + } + + #[test] + fn identifier_ipv4() { + let cfg = r#" +challenge = "http-01" +ip = "203.0.113.42" +"#; + let i = load_str::(cfg).unwrap(); + assert_eq!(i.challenge, AcmeChallenge::Http01); + assert!(i.dns.is_none()); + assert!(i.env.is_empty()); + assert_eq!(i.ip, Some("203.0.113.42".to_string())); + } + + #[test] + fn identifier_ipv6() { + let cfg = r#" +challenge = "tls-alpn-01" +ip = "2001:db8::42" +"#; + let i = load_str::(cfg).unwrap(); + assert_eq!(i.challenge, AcmeChallenge::TlsAlpn01); + assert!(i.dns.is_none()); + assert!(i.env.is_empty()); + assert_eq!(i.ip, Some("2001:db8::42".to_string())); + } + + #[test] + fn identifier_dns_and_ip() { + let cfg = r#" +challenge = "http-01" +dns = "example.org" +ip = "203.0.113.42" +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn identifier_missing_challenge() { + let cfg = r#"ip = "2001:db8::42""#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn identifier_missing_dns_and_ip() { + let cfg = r#"challenge = "http-01""#; + let res = load_str::(cfg); + assert!(res.is_err()); + } +} diff --git a/src/config/endpoint.rs b/src/config/endpoint.rs new file mode 100644 index 0000000..65812f8 --- /dev/null +++ b/src/config/endpoint.rs @@ -0,0 +1,83 @@ +use serde_derive::Deserialize; +use std::path::PathBuf; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + pub file_name_format: Option, + pub name: String, + pub random_early_renew: Option, + #[serde(default)] + pub rate_limits: Vec, + pub renew_delay: Option, + #[serde(default)] + pub root_certificates: Vec, + #[serde(default)] + pub tos_agreed: bool, + pub url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn minimal() { + let cfg = r#" +name = "test" +url = "https://acme-v02.api.example.com/directory" +"#; + + let e: Endpoint = load_str(cfg).unwrap(); + assert!(e.file_name_format.is_none()); + assert_eq!(e.name, "test"); + assert!(e.random_early_renew.is_none()); + assert!(e.rate_limits.is_empty()); + assert!(e.renew_delay.is_none()); + assert!(e.root_certificates.is_empty()); + assert_eq!(e.tos_agreed, false); + assert_eq!(e.url, "https://acme-v02.api.example.com/directory"); + } + + #[test] + fn full() { + let cfg = r#" +name = "test" +url = "https://acme-v02.api.example.com/directory" +file_name_format = "{{ key_type }} {{ file_type }} {{ name }}.{{ ext }}" +random_early_renew = "1d" +rate_limits = ["rl 1", "rl 2"] +renew_delay = "21d" +root_certificates = ["root_cert.pem"] +tos_agreed = true +"#; + + let e: Endpoint = load_str(cfg).unwrap(); + assert_eq!( + e.file_name_format, + Some("{{ key_type }} {{ file_type }} {{ name }}.{{ ext }}".to_string()) + ); + assert_eq!(e.name, "test"); + assert_eq!(e.random_early_renew, Some("1d".to_string())); + assert_eq!(e.rate_limits, vec!["rl 1", "rl 2"]); + assert_eq!(e.renew_delay, Some("21d".to_string())); + assert_eq!(e.root_certificates, vec![PathBuf::from("root_cert.pem")]); + assert_eq!(e.tos_agreed, true); + assert_eq!(e.url, "https://acme-v02.api.example.com/directory"); + } + + #[test] + fn missing_name() { + let cfg = r#""#; + + let res = load_str::(cfg); + assert!(res.is_err()); + } +} diff --git a/src/config/global.rs b/src/config/global.rs new file mode 100644 index 0000000..3160d66 --- /dev/null +++ b/src/config/global.rs @@ -0,0 +1,142 @@ +use serde_derive::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GlobalOptions { + #[serde(default = "get_default_accounts_directory")] + pub accounts_directory: PathBuf, + pub cert_file_group: Option, + pub cert_file_mode: Option, + pub cert_file_user: Option, + #[serde(default = "get_default_cert_file_ext")] + pub cert_file_ext: String, + #[serde(default = "get_default_certificates_directory")] + pub certificates_directory: PathBuf, + #[serde(default)] + pub env: HashMap, + #[serde(default = "get_default_file_name_format")] + pub file_name_format: String, + pub pk_file_group: Option, + pub pk_file_mode: Option, + pub pk_file_user: Option, + #[serde(default = "get_default_pk_file_ext")] + pub pk_file_ext: String, + pub random_early_renew: Option, + #[serde(default = "get_default_renew_delay")] + pub renew_delay: String, + #[serde(default)] + pub root_certificates: Vec, +} + +fn get_default_lib_dir() -> PathBuf { + let mut path = match option_env!("VARLIBDIR") { + Some(s) => PathBuf::from(s), + None => PathBuf::from("/var/lib"), + }; + path.push("acmed"); + path +} + +fn get_default_accounts_directory() -> PathBuf { + let mut path = get_default_lib_dir(); + path.push("accounts"); + path +} + +fn get_default_cert_file_ext() -> String { + "pem".to_string() +} + +fn get_default_certificates_directory() -> PathBuf { + let mut path = get_default_lib_dir(); + path.push("certs"); + path +} + +fn get_default_file_name_format() -> String { + "{{ name }}_{{ key_type }}.{{ file_type }}.{{ ext }}".to_string() +} + +fn get_default_pk_file_ext() -> String { + "pem".to_string() +} + +fn get_default_renew_delay() -> String { + "30d".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty() { + let go: GlobalOptions = load_str("").unwrap(); + assert_eq!(go.accounts_directory, get_default_accounts_directory()); + assert!(go.cert_file_group.is_none()); + assert!(go.cert_file_mode.is_none()); + assert!(go.cert_file_user.is_none()); + assert_eq!(go.cert_file_ext, get_default_cert_file_ext()); + assert_eq!( + go.certificates_directory, + get_default_certificates_directory() + ); + assert!(go.env.is_empty()); + assert_eq!(go.file_name_format, get_default_file_name_format()); + assert!(go.pk_file_group.is_none()); + assert!(go.pk_file_mode.is_none()); + assert!(go.pk_file_user.is_none()); + assert_eq!(go.pk_file_ext, get_default_pk_file_ext()); + assert!(go.random_early_renew.is_none()); + assert_eq!(go.renew_delay, get_default_renew_delay()); + assert!(go.root_certificates.is_empty()); + } + + #[test] + fn full() { + let cfg = r#" +accounts_directory = "/tmp/accounts" +cert_file_group = "acme_test" +cert_file_mode = 0o644 +cert_file_user = "acme_test" +cert_file_ext = "pem.txt" +certificates_directory = "/tmp/certs" +env.HTTP_ROOT = "/srv/http" +env.TEST = "Test" +file_name_format = "{{ key_type }} {{ file_type }} {{ name }}.{{ ext }}" +pk_file_group = "acme_test" +pk_file_mode = 0o644 +pk_file_user = "acme_test" +pk_file_ext = "pem.txt" +random_early_renew = "2d" +renew_delay = "21d" +root_certificates = ["root_cert.pem"] +"#; + + let mut env = HashMap::with_capacity(2); + env.insert("test".to_string(), "Test".to_string()); + env.insert("http_root".to_string(), "/srv/http".to_string()); + let go: GlobalOptions = load_str(cfg).unwrap(); + assert_eq!(go.accounts_directory, PathBuf::from("/tmp/accounts")); + assert_eq!(go.cert_file_group, Some("acme_test".to_string())); + assert_eq!(go.cert_file_mode, Some(0o644)); + assert_eq!(go.cert_file_user, Some("acme_test".to_string())); + assert_eq!(go.cert_file_ext, "pem.txt"); + assert_eq!(go.certificates_directory, PathBuf::from("/tmp/certs")); + assert_eq!(go.env, env); + assert_eq!( + go.file_name_format, + "{{ key_type }} {{ file_type }} {{ name }}.{{ ext }}" + ); + assert_eq!(go.pk_file_group, Some("acme_test".to_string())); + assert_eq!(go.pk_file_mode, Some(0o644)); + assert_eq!(go.pk_file_user, Some("acme_test".to_string())); + assert_eq!(go.pk_file_ext, "pem.txt"); + assert_eq!(go.random_early_renew, Some("2d".to_string())); + assert_eq!(go.renew_delay, "21d"); + assert_eq!(go.root_certificates, vec![PathBuf::from("root_cert.pem")]); + } +} diff --git a/src/config/hook.rs b/src/config/hook.rs new file mode 100644 index 0000000..d288841 --- /dev/null +++ b/src/config/hook.rs @@ -0,0 +1,196 @@ +use serde::{de, Deserialize, Deserializer}; +use serde_derive::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(remote = "Self")] +#[serde(deny_unknown_fields)] +pub struct Hook { + #[serde(default)] + pub allow_failure: bool, + #[serde(default)] + pub args: Vec, + pub cmd: String, + pub name: String, + pub stderr: Option, + pub stdin: Option, + pub stdin_str: Option, + pub stdout: Option, + #[serde(rename = "type")] + pub hook_type: Vec, +} + +impl<'de> Deserialize<'de> for Hook { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let unchecked = Hook::deserialize(deserializer)?; + if unchecked.hook_type.is_empty() { + return Err(de::Error::custom( + "at least one hook type must be specified", + )); + } + if unchecked.stdin.is_some() && unchecked.stdin_str.is_some() { + return Err(de::Error::custom( + "the `stdin` and `stdin_str` directives cannot be both specified within the same hook", + )); + } + Ok(unchecked) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HookType { + FilePreCreate, + FilePostCreate, + FilePreEdit, + FilePostEdit, + #[serde(rename = "challenge-http-01")] + ChallengeHttp01, + #[serde(rename = "challenge-http-01-clean")] + ChallengeHttp01Clean, + #[serde(rename = "challenge-dns-01")] + ChallengeDns01, + #[serde(rename = "challenge-dns-01-clean")] + ChallengeDns01Clean, + #[serde(rename = "challenge-tls-alpn-01")] + ChallengeTlsAlpn01, + #[serde(rename = "challenge-tls-alpn-01-clean")] + ChallengeTlsAlpn01Clean, + PostOperation, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Group { + pub hooks: Vec, + pub name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty_group() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn valid_group() { + let cfg = r#" +name = "test" +hooks = ["h1", "H 2"] +"#; + let rl: Group = load_str(cfg).unwrap(); + assert_eq!(rl.name, "test"); + assert_eq!(rl.hooks, vec!["h1".to_string(), "H 2".to_string()]); + } + + #[test] + fn empty_hook() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn hook_minimal() { + let cfg = r#" +name = "test" +cmd = "cat" +type = ["file-pre-edit"] +"#; + let h = load_str::(cfg).unwrap(); + assert_eq!(h.allow_failure, false); + assert!(h.args.is_empty()); + assert_eq!(h.cmd, "cat"); + assert_eq!(h.name, "test"); + assert!(h.stderr.is_none()); + assert!(h.stdin.is_none()); + assert!(h.stdin_str.is_none()); + assert!(h.stdout.is_none()); + assert_eq!(h.hook_type, vec![HookType::FilePreEdit]); + } + + #[test] + fn hook_full() { + let cfg = r#" +name = "test" +cmd = "cat" +args = ["-e"] +type = ["file-pre-edit"] +allow_failure = true +stdin = "/tmp/in.txt" +stdout = "/tmp/out.log" +stderr = "/tmp/err.log" +"#; + let h = load_str::(cfg).unwrap(); + assert_eq!(h.allow_failure, true); + assert_eq!(h.args, vec!["-e".to_string()]); + assert_eq!(h.cmd, "cat"); + assert_eq!(h.name, "test"); + assert_eq!(h.stderr, Some(PathBuf::from("/tmp/err.log"))); + assert_eq!(h.stdin, Some(PathBuf::from("/tmp/in.txt"))); + assert!(h.stdin_str.is_none()); + assert_eq!(h.stdout, Some(PathBuf::from("/tmp/out.log"))); + assert_eq!(h.hook_type, vec![HookType::FilePreEdit]); + } + + #[test] + fn hook_both_stdin() { + let cfg = r#" +name = "test" +cmd = "cat" +type = ["file-pre-edit"] +stdin = "/tmp/in.txt" +stdin_str = "some input" +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn hook_missing_name() { + let cfg = r#" +cmd = "cat" +type = ["file-pre-edit"] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn hook_missing_cmd() { + let cfg = r#" +name = "test" +type = ["file-pre-edit"] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn hook_missing_type() { + let cfg = r#" +name = "test" +cmd = "cat" +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn hook_empty_type() { + let cfg = r#" +name = "test" +cmd = "cat" +type = [] +"#; + let res = load_str::(cfg); + assert!(res.is_err()); + } +} diff --git a/src/config/rate_limit.rs b/src/config/rate_limit.rs new file mode 100644 index 0000000..076c7cf --- /dev/null +++ b/src/config/rate_limit.rs @@ -0,0 +1,68 @@ +use serde_derive::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RateLimit { + pub name: String, + pub number: usize, + pub period: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_str; + + #[test] + fn empty() { + let res = load_str::(""); + assert!(res.is_err()); + } + + #[test] + fn ok() { + let cfg = r#" +name = "test" +number = 20 +period = "20s" +"#; + + let rl: RateLimit = load_str(cfg).unwrap(); + assert_eq!(rl.name, "test"); + assert_eq!(rl.number, 20); + assert_eq!(rl.period, "20s"); + } + + #[test] + fn missing_name() { + let cfg = r#" +number = 20 +period = "20s" +"#; + + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn missing_number() { + let cfg = r#" +name = "test" +period = "20s" +"#; + + let res = load_str::(cfg); + assert!(res.is_err()); + } + + #[test] + fn missing_period() { + let cfg = r#" +name = "test" +number = 20 +"#; + + let res = load_str::(cfg); + assert!(res.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 5776ef6..082d33a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod cli; +mod config; mod log; +use crate::config::AcmedConfig; use anyhow::{Context, Result}; use clap::Parser; use daemonize::Daemonize; @@ -11,17 +13,24 @@ use std::process; pub const APP_IDENTITY: &[u8] = b"acmed\0"; pub const APP_THREAD_NAME: &str = "acmed-runtime"; -pub const DEFAULT_CONFIG_PATH: &str = "/etc/acmed/acmed.toml"; pub const DEFAULT_LOG_LEVEL: log::Level = log::Level::Warn; -pub const DEFAULT_PID_FILE: &str = "/run/acmed.pid"; fn main() { // CLI let args = cli::CliArgs::parse(); - println!("Debug: args: {args:?}"); // Initialize the logging system log::init(args.log_level, !args.log.log_stderr); + tracing::trace!("computed args: {args:?}"); + + // Load the configuration + let cfg = match config::load(args.config.as_path()) { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!("unable to load configuration: {e:#}"); + std::process::exit(3); + } + }; // Initialize the server (PID file and daemon) init_server(args.foreground, args.pid.get_pid_file()); @@ -32,10 +41,10 @@ fn main() { .thread_name(APP_THREAD_NAME) .build() .unwrap() - .block_on(start()); + .block_on(start(cfg)); } -async fn start() { +async fn start(cnf: AcmedConfig) { tracing::info!("starting ACMEd"); } @@ -46,12 +55,12 @@ fn init_server(foreground: bool, pid_file: Option<&Path>) { daemonize = daemonize.pid_file(f); } if let Err(e) = daemonize.start() { - tracing::error!("error: {e:#}"); + tracing::error!("{e:#}"); std::process::exit(3); } } else if let Some(f) = pid_file { if let Err(e) = write_pid_file(f) { - tracing::error!("error: {e:#}"); + tracing::error!("{e:#}"); std::process::exit(3); } } diff --git a/tests/config/empty/.dummy b/tests/config/empty/.dummy new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/override/00_initial.toml b/tests/config/override/00_initial.toml new file mode 100644 index 0000000..5743deb --- /dev/null +++ b/tests/config/override/00_initial.toml @@ -0,0 +1,9 @@ +[global] +accounts_directory = "/tmp/example/account/dir" +certificates_directory = "/tmp/example/cert/dir/" + +[[account]] +name = "example" +contacts = [ + { mailto = "acme@example.org" }, +] diff --git a/tests/config/override/01_override.toml b/tests/config/override/01_override.toml new file mode 100644 index 0000000..e5417fe --- /dev/null +++ b/tests/config/override/01_override.toml @@ -0,0 +1 @@ +global.accounts_directory = "/tmp/other/account/dir" diff --git a/tests/config/simple/simple.toml b/tests/config/simple/simple.toml new file mode 100644 index 0000000..12a26ce --- /dev/null +++ b/tests/config/simple/simple.toml @@ -0,0 +1,10 @@ +[global] +accounts_directory = "/tmp/example/account/dir" +certificates_directory = "/tmp/example/cert/dir/" + +[[account]] +name = "example" +contacts = [ + { mailto = "acme@example.org" }, +] +signature_algorithm = "HS384"