Browse Source

Load the configuration

ng
Rodolphe Bréard 4 weeks ago
parent
commit
c1e90d7ebb
Failed to extract signature
  1. 4
      .gitignore
  2. 75
      CHANGELOG.md
  3. 160
      Cargo.lock
  4. 6
      Cargo.toml
  5. 4
      Makefile
  6. 9
      config/acmed.toml
  7. 157
      config/default_hooks.toml
  8. 22
      man/en/acmed.8
  9. 16
      man/en/acmed.toml.5
  10. 31
      src/cli.rs
  11. 161
      src/config.rs
  12. 243
      src/config/account.rs
  13. 334
      src/config/certificate.rs
  14. 83
      src/config/endpoint.rs
  15. 142
      src/config/global.rs
  16. 196
      src/config/hook.rs
  17. 68
      src/config/rate_limit.rs
  18. 23
      src/main.rs
  19. 0
      tests/config/empty/.dummy
  20. 9
      tests/config/override/00_initial.toml
  21. 1
      tests/config/override/01_override.toml
  22. 10
      tests/config/simple/simple.toml

4
.gitignore

@ -8,7 +8,3 @@
\#*
.\#*
*.swp
# Test files
/test
test.*

75
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.

160
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",
]

6
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]

4
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:

9
config/acmed.toml

@ -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",
]

157
config/default_hooks.toml

@ -1,157 +0,0 @@
# Copyright (c) 2019-2020 Rodolphe Bréard <rodolphe@breard.tf>
#
# 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"]

22
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 ,

16
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

31
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());
}

161
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<GlobalOptions>,
#[serde(default)]
pub endpoint: Vec<Endpoint>,
#[serde(default, rename = "rate-limit")]
pub rate_limit: Vec<RateLimit>,
#[serde(default)]
pub hook: Vec<Hook>,
#[serde(default)]
pub group: Vec<Group>,
#[serde(default)]
pub account: Vec<Account>,
#[serde(default)]
pub certificate: Vec<Certificate>,
}
pub fn load<P: AsRef<Path>>(config_dir: P) -> Result<AcmedConfig> {
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::<Vec<_>>(),
)
.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<Vec<PathBuf>> {
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<T> {
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());
}
}

243
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<AccountContact>,
#[serde(default)]
pub env: HashMap<String, String>,
pub external_account: Option<ExternalAccount>,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub key_type: AccountKeyType,
pub name: String,
#[serde(default)]
pub signature_algorithm: Option<AccountSignatureAlgorithm>,
}
impl<'de> Deserialize<'de> for Account {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<Account>("");
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::<Account>(cfg);
assert!(res.is_err());
}
#[test]
fn account_missing_contact() {
let cfg = r#"name = "test""#;
let res = load_str::<Account>(cfg);
assert!(res.is_err());
}
#[test]
fn account_empty_contact() {
let cfg = r#"
name = "test"
contacts = []
"#;
let res = load_str::<Account>(cfg);
assert!(res.is_err());
}
#[test]
fn empty_account_contact() {
let res = load_str::<AccountContact>("");
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::<ExternalAccount>("");
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::<ExternalAccount>(cfg);
assert!(res.is_err());
}
#[test]
fn external_account_missing_key() {
let cfg = r#"identifier = "toto""#;
let res = load_str::<ExternalAccount>(cfg);
assert!(res.is_err());
}
}

334
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<PathBuf>,
pub endpoint: String,
#[serde(default)]
pub env: HashMap<String, String>,
pub file_name_format: Option<String>,
pub hooks: Vec<String>,
pub identifiers: Vec<Identifier>,
#[serde(default)]
pub key_type: KeyType,
#[serde(default)]
pub kp_reuse: bool,
pub name: Option<String>,
pub random_early_renew: Option<String>,
pub renew_delay: Option<String>,
#[serde(default)]
pub subject_attributes: SubjectAttributes,
}
impl<'de> Deserialize<'de> for Certificate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub ip: Option<String>,
}
impl<'de> Deserialize<'de> for Identifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String>,
pub generation_qualifier: Option<String>,
pub given_name: Option<String>,
pub initials: Option<String>,
pub locality_name: Option<String>,
pub name: Option<String>,
pub organization_name: Option<String>,
pub organizational_unit_name: Option<String>,
pub pkcs9_email_address: Option<String>,
pub postal_address: Option<String>,
pub postal_code: Option<String>,
pub state_or_province_name: Option<String>,
pub street: Option<String>,
pub surname: Option<String>,
pub title: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::load_str;
#[test]
fn empty_certificate() {
let res = load_str::<Certificate>("");
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::<Certificate>(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::<Certificate>(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::<Certificate>(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::<Certificate>(cfg);
assert!(res.is_err());
}
#[test]
fn empty_identifier() {
let res = load_str::<Identifier>("");
assert!(res.is_err());
}
#[test]
fn identifier_dns() {
let cfg = r#"
challenge = "dns-01"
dns = "example.org"
"#;
let i = load_str::<Identifier>(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::<Identifier>(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::<Identifier>(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::<Identifier>(cfg);
assert!(res.is_err());
}
#[test]
fn identifier_missing_challenge() {
let cfg = r#"ip = "2001:db8::42""#;
let res = load_str::<Identifier>(cfg);
assert!(res.is_err());
}
#[test]
fn identifier_missing_dns_and_ip() {
let cfg = r#"challenge = "http-01""#;
let res = load_str::<Identifier>(cfg);
assert!(res.is_err());
}
}

83
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<String>,
pub name: String,
pub random_early_renew: Option<String>,
#[serde(default)]
pub rate_limits: Vec<String>,
pub renew_delay: Option<String>,
#[serde(default)]
pub root_certificates: Vec<PathBuf>,
#[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::<Endpoint>("");
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::<Endpoint>(cfg);
assert!(res.is_err());
}
}

142
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<String>,
pub cert_file_mode: Option<u32>,
pub cert_file_user: Option<String>,
#[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<String, String>,
#[serde(default = "get_default_file_name_format")]
pub file_name_format: String,
pub pk_file_group: Option<String>,
pub pk_file_mode: Option<u32>,
pub pk_file_user: Option<String>,
#[serde(default = "get_default_pk_file_ext")]
pub pk_file_ext: String,
pub random_early_renew: Option<String>,
#[serde(default = "get_default_renew_delay")]
pub renew_delay: String,
#[serde(default)]
pub root_certificates: Vec<PathBuf>,
}
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")]);
}
}

196
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<String>,
pub cmd: String,
pub name: String,
pub stderr: Option<PathBuf>,
pub stdin: Option<PathBuf>,
pub stdin_str: Option<String>,
pub stdout: Option<PathBuf>,
#[serde(rename = "type")]
pub hook_type: Vec<HookType>,
}
impl<'de> Deserialize<'de> for Hook {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String>,
pub name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::load_str;
#[test]
fn empty_group() {
let res = load_str::<Group>("");
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::<Hook>("");
assert!(res.is_err());
}
#[test]
fn hook_minimal() {
let cfg = r#"
name = "test"
cmd = "cat"
type = ["file-pre-edit"]
"#;
let h = load_str::<Hook>(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::<Hook>(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::<Hook>(cfg);
assert!(res.is_err());
}
#[test]
fn hook_missing_name() {
let cfg = r#"
cmd = "cat"
type = ["file-pre-edit"]
"#;
let res = load_str::<Hook>(cfg);
assert!(res.is_err());
}
#[test]
fn hook_missing_cmd() {
let cfg = r#"
name = "test"
type = ["file-pre-edit"]
"#;
let res = load_str::<Hook>(cfg);
assert!(res.is_err());
}
#[test]
fn hook_missing_type() {
let cfg = r#"
name = "test"
cmd = "cat"
"#;
let res = load_str::<Hook>(cfg);
assert!(res.is_err());
}
#[test]
fn hook_empty_type() {
let cfg = r#"
name = "test"
cmd = "cat"
type = []
"#;
let res = load_str::<Hook>(cfg);
assert!(res.is_err());
}
}

68
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::<RateLimit>("");
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::<RateLimit>(cfg);
assert!(res.is_err());
}
#[test]
fn missing_number() {
let cfg = r#"
name = "test"
period = "20s"
"#;
let res = load_str::<RateLimit>(cfg);
assert!(res.is_err());
}
#[test]
fn missing_period() {
let cfg = r#"
name = "test"
number = 20
"#;
let res = load_str::<RateLimit>(cfg);
assert!(res.is_err());
}
}

23
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);
}
}

0
tests/config/empty/.dummy

9
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" },
]

1
tests/config/override/01_override.toml

@ -0,0 +1 @@
global.accounts_directory = "/tmp/other/account/dir"

10
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"
Loading…
Cancel
Save