├── rustfmt.toml ├── acme_common ├── src │ ├── crypto │ │ ├── openssl_hash.rs │ │ ├── key_type.rs │ │ ├── openssl_certificate.rs │ │ └── openssl_keys.rs │ ├── crypto.rs │ ├── logs.rs │ ├── error.rs │ └── lib.rs ├── Cargo.toml └── build.rs ├── .gitignore ├── Cargo.toml ├── acmed ├── config │ ├── acmed.toml │ └── default_hooks.toml ├── src │ ├── acme_proto │ │ ├── structs.rs │ │ ├── certificate.rs │ │ ├── account.rs │ │ ├── structs │ │ │ ├── order.rs │ │ │ ├── account.rs │ │ │ ├── directory.rs │ │ │ ├── error.rs │ │ │ └── authorization.rs │ │ └── http.rs │ ├── jws │ │ └── algorithms.rs │ ├── jws.rs │ ├── main_event_loop.rs │ ├── main.rs │ ├── endpoint.rs │ ├── http.rs │ ├── hooks.rs │ ├── acme_proto.rs │ ├── certificate.rs │ ├── storage.rs │ └── config.rs ├── Cargo.toml └── build.rs ├── tacd ├── Cargo.toml └── src │ ├── openssl_server.rs │ └── main.rs ├── .travis.yml ├── acmed.service.example ├── LICENSE-MIT.txt ├── contrib └── build-docker.sh ├── Makefile ├── man └── en │ ├── acmed.8 │ ├── tacd.8 │ └── acmed.toml.5 ├── CONTRIBUTING.md ├── CHANGELOG.md ├── README.md └── LICENSE-APACHE-2.0.txt /rustfmt.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acme_common/src/crypto/openssl_hash.rs: -------------------------------------------------------------------------------- 1 | pub fn sha256(data: &[u8]) -> Vec { 2 | openssl::sha::sha256(data).to_vec() 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust files 2 | /target 3 | **/*.rs.bk 4 | Cargo.lock 5 | 6 | # Backup files 7 | *~ 8 | \#* 9 | .\#* 10 | *.swp 11 | 12 | # Test files 13 | /test 14 | test.* 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "acmed", 4 | "tacd", 5 | ] 6 | 7 | [profile.release] 8 | opt-level = 'z' 9 | lto = 'thin' 10 | codegen-units = 1 11 | panic = 'abort' 12 | -------------------------------------------------------------------------------- /acme_common/src/crypto.rs: -------------------------------------------------------------------------------- 1 | mod key_type; 2 | mod openssl_certificate; 3 | mod openssl_hash; 4 | mod openssl_keys; 5 | 6 | pub const DEFAULT_ALGO: &str = "rsa2048"; 7 | pub const TLS_LIB_NAME: &str = env!("ACMED_TLS_LIB_NAME"); 8 | pub const TLS_LIB_VERSION: &str = env!("ACMED_TLS_LIB_VERSION"); 9 | 10 | pub use key_type::KeyType; 11 | pub use openssl_certificate::{Csr, X509Certificate}; 12 | pub use openssl_hash::sha256; 13 | pub use openssl_keys::{gen_keypair, KeyPair}; 14 | -------------------------------------------------------------------------------- /acmed/config/acmed.toml: -------------------------------------------------------------------------------- 1 | include = [ 2 | "default_hooks.toml" 3 | ] 4 | 5 | [[rate-limit]] 6 | name = "LE min" 7 | number = 20 8 | period = "1s" 9 | 10 | [[endpoint]] 11 | name = "letsencrypt v2 prod" 12 | url = "https://acme-v02.api.letsencrypt.org/directory" 13 | rate_limits = ["LE min"] 14 | tos_agreed = false 15 | 16 | [[endpoint]] 17 | name = "letsencrypt v2 staging" 18 | url = "https://acme-staging-v02.api.letsencrypt.org/directory" 19 | rate_limits = ["LE min"] 20 | tos_agreed = false 21 | -------------------------------------------------------------------------------- /tacd/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tacd" 3 | version = "0.8.0" 4 | authors = ["Rodolphe Breard "] 5 | edition = "2018" 6 | description = "TLS-ALPN Challenge Daemon" 7 | keywords = ["acme", "tls", "alpn", "X.509"] 8 | repository = "https://github.com/breard-r/acmed" 9 | readme = "../README.md" 10 | license = "MIT OR Apache-2.0" 11 | include = ["src/**/*", "Cargo.toml", "LICENSE-*.txt"] 12 | publish = false 13 | 14 | [dependencies] 15 | acme_common = { path = "../acme_common" } 16 | clap = "2.32" 17 | log = "0.4" 18 | openssl = "0.10" 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | dist: xenial 3 | 4 | rust: 5 | - "1.40.0" 6 | - "1.41.1" 7 | - "1.42.0" 8 | - "1.43.1" 9 | - "stable" 10 | - "beta" 11 | - "nightly" 12 | 13 | jobs: 14 | allow_failures: 15 | - rust: "nightly" 16 | include: 17 | - name: Run cargo audit 18 | rust: stable 19 | env: TASK=audit 20 | fast_finish: true 21 | 22 | install: 23 | - cargo build --verbose 24 | - if [ "$TASK" = "audit" ]; then cargo install cargo-audit; fi 25 | 26 | script: 27 | - if [ "$TASK" = "audit" ]; then 28 | cargo audit; 29 | else 30 | cargo test --verbose; 31 | fi 32 | -------------------------------------------------------------------------------- /acmed.service.example: -------------------------------------------------------------------------------- 1 | # systemd example unit file. Please adjust. 2 | 3 | [Unit] 4 | Description=ACME client daemon 5 | After=network.target 6 | 7 | [Service] 8 | User=acmed 9 | Group=acmed 10 | 11 | # Working directory 12 | WorkingDirectory=/etc/acmed 13 | 14 | # Starting, stopping, timeouts 15 | ExecStart=/usr/local/bin/acmed --foreground --pid-file /etc/acmed/acmed.pid --log-level debug --log-stderr 16 | TimeoutStartSec=3 17 | TimeoutStopSec=5 18 | Restart=on-failure 19 | KillSignal=SIGINT 20 | 21 | # Sandboxing, reduce privileges, only allow write access to working directory 22 | NoNewPrivileges=yes 23 | PrivateTmp=yes 24 | PrivateUsers=yes 25 | ProtectSystem=strict 26 | ReadWritePaths=/etc/acmed/ 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | -------------------------------------------------------------------------------- /acme_common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "acme_common" 3 | version = "0.8.0" 4 | authors = ["Rodolphe Breard "] 5 | edition = "2018" 6 | repository = "https://github.com/breard-r/libreauth" 7 | readme = "../README.md" 8 | license = "MIT OR Apache-2.0" 9 | include = ["src/**/*", "Cargo.toml", "Licence_*.txt"] 10 | publish = false 11 | 12 | [lib] 13 | name = "acme_common" 14 | 15 | [dependencies] 16 | attohttpc = { version = "0.15", default-features = false } 17 | base64 = "0.12" 18 | daemonize = "0.4" 19 | env_logger = "0.7" 20 | handlebars = "3.0" 21 | log = "0.4" 22 | native-tls = "0.2" 23 | openssl = "0.10" 24 | openssl-sys = "0.9" 25 | punycode = "0.4" 26 | serde_json = "1.0" 27 | syslog = "5.0" 28 | toml = "0.5" 29 | 30 | [target.'cfg(unix)'.dependencies] 31 | nix = "0.17" 32 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! deserialize_from_str { 3 | ($t: ty) => { 4 | impl FromStr for $t { 5 | type Err = Error; 6 | 7 | fn from_str(data: &str) -> Result { 8 | let res = serde_json::from_str(data)?; 9 | Ok(res) 10 | } 11 | } 12 | }; 13 | } 14 | 15 | mod account; 16 | mod authorization; 17 | mod directory; 18 | mod error; 19 | mod order; 20 | 21 | pub use account::{Account, AccountDeactivation, AccountResponse, AccountUpdate}; 22 | pub use authorization::{Authorization, AuthorizationStatus, Challenge}; 23 | pub use deserialize_from_str; 24 | pub use directory::Directory; 25 | pub use error::{AcmeError, ApiError, HttpApiError}; 26 | pub use order::{Identifier, IdentifierType, NewOrder, Order, OrderStatus}; 27 | -------------------------------------------------------------------------------- /acmed/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "acmed" 3 | version = "0.8.0" 4 | authors = ["Rodolphe Breard "] 5 | edition = "2018" 6 | description = "ACME (RFC 8555) client daemon" 7 | keywords = ["acme", "tls", "X.509"] 8 | repository = "https://github.com/breard-r/acmed" 9 | readme = "../README.md" 10 | license = "MIT OR Apache-2.0" 11 | include = ["src/**/*", "Cargo.toml", "LICENSE-*.txt"] 12 | build = "build.rs" 13 | publish = false 14 | 15 | [dependencies] 16 | acme_common = { path = "../acme_common" } 17 | attohttpc = { version = "0.15", features = ["charsets", "json"] } 18 | clap = "2.32" 19 | handlebars = "3.0" 20 | log = "0.4" 21 | nom = "5.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | toml = "0.5" 25 | 26 | [target.'cfg(unix)'.dependencies] 27 | nix = "0.17" 28 | 29 | [build-dependencies] 30 | serde = { version = "1.0", features = ["derive"] } 31 | toml = "0.5" 32 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/certificate.rs: -------------------------------------------------------------------------------- 1 | use crate::certificate::{Algorithm, Certificate}; 2 | use crate::storage; 3 | use acme_common::crypto::{gen_keypair, KeyPair, KeyType}; 4 | use acme_common::error::Error; 5 | 6 | fn gen_key_pair(cert: &Certificate) -> Result { 7 | let key_type = match cert.algo { 8 | Algorithm::Rsa2048 => KeyType::Rsa2048, 9 | Algorithm::Rsa4096 => KeyType::Rsa4096, 10 | Algorithm::EcdsaP256 => KeyType::EcdsaP256, 11 | Algorithm::EcdsaP384 => KeyType::EcdsaP384, 12 | }; 13 | let key_pair = gen_keypair(key_type)?; 14 | storage::set_keypair(cert, &key_pair)?; 15 | Ok(key_pair) 16 | } 17 | 18 | fn read_key_pair(cert: &Certificate) -> Result { 19 | storage::get_keypair(cert) 20 | } 21 | 22 | pub fn get_key_pair(cert: &Certificate) -> Result { 23 | if cert.kp_reuse { 24 | match read_key_pair(cert) { 25 | Ok(key_pair) => Ok(key_pair), 26 | Err(_) => gen_key_pair(cert), 27 | } 28 | } else { 29 | gen_key_pair(cert) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE-MIT.txt: -------------------------------------------------------------------------------- 1 | Copyright 2019 Rodolphe Bréard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /acme_common/src/crypto/key_type.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use std::fmt; 3 | use std::str::FromStr; 4 | 5 | #[derive(Clone, Copy, Debug)] 6 | pub enum KeyType { 7 | Curve25519, 8 | EcdsaP256, 9 | EcdsaP384, 10 | Rsa2048, 11 | Rsa4096, 12 | } 13 | 14 | impl FromStr for KeyType { 15 | type Err = Error; 16 | 17 | fn from_str(s: &str) -> Result { 18 | match s.to_lowercase().as_str() { 19 | "ed25519" => Ok(KeyType::Curve25519), 20 | "ecdsa_p256" => Ok(KeyType::EcdsaP256), 21 | "ecdsa_p384" => Ok(KeyType::EcdsaP384), 22 | "rsa2048" => Ok(KeyType::Rsa2048), 23 | "rsa4096" => Ok(KeyType::Rsa4096), 24 | _ => Err(format!("{}: unknown algorithm.", s).into()), 25 | } 26 | } 27 | } 28 | 29 | impl fmt::Display for KeyType { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | let s = match self { 32 | KeyType::Curve25519 => "ed25519", 33 | KeyType::EcdsaP256 => "ecdsa-p256", 34 | KeyType::EcdsaP384 => "ecdsa-p384", 35 | KeyType::Rsa2048 => "rsa2048", 36 | KeyType::Rsa4096 => "rsa4096", 37 | }; 38 | write!(f, "{}", s) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /contrib/build-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | 6 | # Parse command line args 7 | if [ "$#" -ne 1 ]; then 8 | echo "Usage: $0 " 9 | echo "Supported targets: stretch, buster" 10 | exit 1 11 | fi 12 | if [ "$1" == "buster" ]; then 13 | TARGET=buster 14 | elif [ "$1" == "stretch" ]; then 15 | TARGET=stretch 16 | else 17 | echo "Invalid target: $1" 18 | exit 1 19 | fi 20 | 21 | # Determine image 22 | IMAGE=rust:1-$TARGET 23 | 24 | function log { 25 | echo -e "\033[32;1m==> ${1}\033[0m" 26 | } 27 | 28 | log "Pulling image $IMAGE..." 29 | docker pull $IMAGE 30 | 31 | log "Starting container..." 32 | CID=$(docker run --rm -td $IMAGE) 33 | 34 | log "Copying project files..." 35 | docker cp "$DIR" "$CID":/code/ 36 | 37 | log "Starting build..." 38 | docker exec "$CID" /bin/bash -c "cd /code && cargo build --release" 39 | 40 | log "Copying binaries..." 41 | mkdir -p target/docker/$TARGET/ 42 | docker cp "$CID":/code/target/release/acmed target/docker/$TARGET/ 43 | docker cp "$CID":/code/target/release/tacd target/docker/$TARGET/ 44 | 45 | log "Stopping and removing container..." 46 | docker stop "$CID" 47 | 48 | log "Done! Find your binaries in the target/docker/$TARGET/ directory." 49 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX = /usr 2 | EXEC_PREFIX = $(PREFIX) 3 | BINDIR = $(EXEC_PREFIX)/bin 4 | DATAROOTDIR = $(PREFIX)/share 5 | DATADIR = $(DATAROOTDIR) 6 | SYSCONFDIR = /etc 7 | TARGET_DIR = ./target/release 8 | EXE_NAMES = acmed \ 9 | tacd 10 | EXE_FILES = $(foreach name,$(EXE_NAMES),$(TARGET_DIR)/$(name)) 11 | MAN_SRC_DIR = ./man/en 12 | MAN_DST_DIR = $(TARGET_DIR)/man 13 | MAN_SRC = acmed.8 \ 14 | acmed.toml.5 \ 15 | tacd.8 16 | MAN_FILES = $(foreach name,$(MAN_SRC),$(MAN_DST_DIR)/$(name).gz) 17 | 18 | all: update $(EXE_FILES) man 19 | 20 | man: $(MAN_DST_DIR) $(MAN_FILES) 21 | 22 | $(EXE_NAMES): %: $(TARGET_DIR)/% 23 | 24 | $(EXE_FILES): $(TARGET_DIR)/%: %/Cargo.toml 25 | cargo build --release --bin $(subst /Cargo.toml,,$<) 26 | strip $@ 27 | 28 | $(MAN_DST_DIR): 29 | @mkdir -p $(MAN_DST_DIR) 30 | 31 | $(MAN_DST_DIR)/%.gz: $(MAN_SRC_DIR)/% 32 | gzip <"$<" >"$@" 33 | 34 | update: 35 | cargo update 36 | 37 | install: 38 | install -D -m 0755 $(TARGET_DIR)/acmed $(DESTDIR)$(BINDIR)/acmed 39 | install -D -m 0755 $(TARGET_DIR)/tacd $(DESTDIR)$(BINDIR)/tacd 40 | install -D -m 0644 $(TARGET_DIR)/man/acmed.8.gz $(DESTDIR)$(DATADIR)/man/man8/acmed.8.gz 41 | install -D -m 0644 $(TARGET_DIR)/man/acmed.toml.5.gz $(DESTDIR)$(DATADIR)/man/man5/acmed.toml.5.gz 42 | install -D -m 0644 $(TARGET_DIR)/man/tacd.8.gz $(DESTDIR)$(DATADIR)/man/man8/tacd.8.gz 43 | install -D -m 0644 acmed/config/acmed.toml $(DESTDIR)$(SYSCONFDIR)/acmed/acmed.toml 44 | install -D -m 0644 acmed/config/default_hooks.toml $(DESTDIR)$(SYSCONFDIR)/acmed/default_hooks.toml 45 | install -d -m 0700 $(DESTDIR)$(SYSCONFDIR)/acmed/accounts 46 | install -d -m 0755 $(DESTDIR)$(SYSCONFDIR)/acmed/certs 47 | 48 | clean: 49 | cargo clean 50 | 51 | .PHONY: $(EXE_NAMES) all clean install man update 52 | -------------------------------------------------------------------------------- /acmed/build.rs: -------------------------------------------------------------------------------- 1 | extern crate serde; 2 | extern crate toml; 3 | 4 | use serde::Deserialize; 5 | use std::env; 6 | use std::fs::File; 7 | use std::io::prelude::*; 8 | use std::path::PathBuf; 9 | 10 | macro_rules! set_rustc_env_var { 11 | ($name: expr, $value: expr) => {{ 12 | println!("cargo:rustc-env={}={}", $name, $value); 13 | }}; 14 | } 15 | 16 | #[derive(Deserialize)] 17 | pub struct Lock { 18 | package: Vec, 19 | } 20 | 21 | #[derive(Deserialize)] 22 | struct Package { 23 | name: String, 24 | version: String, 25 | } 26 | 27 | struct Error; 28 | 29 | impl From for Error { 30 | fn from(_error: std::io::Error) -> Self { 31 | Error {} 32 | } 33 | } 34 | 35 | impl From for Error { 36 | fn from(_error: toml::de::Error) -> Self { 37 | Error {} 38 | } 39 | } 40 | 41 | fn get_lock() -> Result { 42 | let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 43 | path.pop(); 44 | path.push("Cargo.lock"); 45 | let mut file = File::open(path)?; 46 | let mut contents = String::new(); 47 | file.read_to_string(&mut contents)?; 48 | let ret: Lock = toml::from_str(&contents)?; 49 | Ok(ret) 50 | } 51 | 52 | fn set_lock() { 53 | let lock = match get_lock() { 54 | Ok(l) => l, 55 | Err(_) => { 56 | return; 57 | } 58 | }; 59 | for p in lock.package.iter() { 60 | if p.name == "attohttpc" { 61 | let agent = format!("{}/{}", p.name, p.version); 62 | set_rustc_env_var!("ACMED_HTTP_LIB_AGENT", agent); 63 | set_rustc_env_var!("ACMED_HTTP_LIB_NAME", p.name); 64 | set_rustc_env_var!("ACMED_HTTP_LIB_VERSION", p.version); 65 | return; 66 | } 67 | } 68 | } 69 | 70 | fn set_target() { 71 | if let Ok(target) = env::var("TARGET") { 72 | set_rustc_env_var!("ACMED_TARGET", target); 73 | }; 74 | } 75 | 76 | fn main() { 77 | set_target(); 78 | set_lock(); 79 | } 80 | -------------------------------------------------------------------------------- /tacd/src/openssl_server.rs: -------------------------------------------------------------------------------- 1 | use acme_common::crypto::{KeyPair, X509Certificate}; 2 | use acme_common::error::Error; 3 | use log::debug; 4 | use openssl::ssl::{self, AlpnError, SslAcceptor, SslMethod}; 5 | use std::net::TcpListener; 6 | use std::sync::Arc; 7 | use std::thread; 8 | 9 | #[cfg(target_family = "unix")] 10 | use std::os::unix::net::UnixListener; 11 | 12 | #[cfg(ossl110)] 13 | const ALPN_ERROR: AlpnError = AlpnError::ALERT_FATAL; 14 | #[cfg(not(ossl110))] 15 | const ALPN_ERROR: AlpnError = AlpnError::NOACK; 16 | 17 | macro_rules! listen_and_accept { 18 | ($lt: ident, $addr: ident, $acceptor: ident) => { 19 | let listener = $lt::bind($addr)?; 20 | for stream in listener.incoming() { 21 | if let Ok(stream) = stream { 22 | let acceptor = $acceptor.clone(); 23 | thread::spawn(move || { 24 | debug!("New client"); 25 | let _ = acceptor.accept(stream).unwrap(); 26 | }); 27 | }; 28 | } 29 | }; 30 | } 31 | 32 | pub fn start( 33 | listen_addr: &str, 34 | certificate: &X509Certificate, 35 | key_pair: &KeyPair, 36 | ) -> Result<(), Error> { 37 | let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; 38 | acceptor.set_alpn_select_callback(|_, client| { 39 | debug!("ALPN negociation"); 40 | ssl::select_next_proto(crate::ALPN_ACME_PROTO_NAME, client).ok_or(ALPN_ERROR) 41 | }); 42 | acceptor.set_private_key(&key_pair.inner_key)?; 43 | acceptor.set_certificate(&certificate.inner_cert)?; 44 | acceptor.check_private_key()?; 45 | let acceptor = Arc::new(acceptor.build()); 46 | if cfg!(unix) && listen_addr.starts_with("unix:") { 47 | let listen_addr = &listen_addr[5..]; 48 | debug!("Listening on unix socket {}", listen_addr); 49 | listen_and_accept!(UnixListener, listen_addr, acceptor); 50 | } else { 51 | debug!("Listening on {}", listen_addr); 52 | listen_and_accept!(TcpListener, listen_addr, acceptor); 53 | } 54 | Err("Main thread loop unexpectedly exited".into()) 55 | } 56 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/account.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::http; 2 | use crate::acme_proto::structs::Account; 3 | use crate::certificate::Certificate; 4 | use crate::endpoint::Endpoint; 5 | use crate::jws::algorithms::SignatureAlgorithm; 6 | use crate::jws::encode_jwk; 7 | use crate::storage; 8 | use acme_common::crypto::KeyPair; 9 | use acme_common::error::Error; 10 | use std::str::FromStr; 11 | 12 | pub struct AccountManager { 13 | pub key_pair: KeyPair, 14 | pub account_url: String, 15 | pub orders_url: String, 16 | } 17 | 18 | impl AccountManager { 19 | pub fn new( 20 | endpoint: &mut Endpoint, 21 | root_certs: &[String], 22 | cert: &Certificate, 23 | ) -> Result { 24 | // TODO: store the key id (account url) 25 | let key_pair = storage::get_account_keypair(cert)?; 26 | let kp_ref = &key_pair; 27 | let account = Account::new(cert, endpoint); 28 | let account = serde_json::to_string(&account)?; 29 | let acc_ref = &account; 30 | let data_builder = |n: &str, url: &str| encode_jwk(kp_ref, acc_ref.as_bytes(), url, n); 31 | let (acc_rep, account_url) = http::new_account(endpoint, root_certs, &data_builder)?; 32 | let ac = AccountManager { 33 | key_pair, 34 | account_url, 35 | orders_url: acc_rep.orders.unwrap_or_default(), 36 | }; 37 | // TODO: check account data and, if different from config, update them 38 | Ok(ac) 39 | } 40 | } 41 | 42 | pub fn init_account(cert: &Certificate) -> Result<(), Error> { 43 | if !storage::account_files_exists(cert) { 44 | // TODO: allow to change the signature algo 45 | let sign_alg = SignatureAlgorithm::from_str(crate::DEFAULT_JWS_SIGN_ALGO)?; 46 | let key_pair = sign_alg.gen_key_pair()?; 47 | storage::set_account_keypair(cert, &key_pair)?; 48 | cert.info(&format!("Account {} created", &cert.account.name)); 49 | } else { 50 | // TODO: check if the keys are suitable for the specified signature algorithm 51 | // and, if not, initiate a key rollover. 52 | cert.debug(&format!("Account {} already exists", &cert.account.name)); 53 | } 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /man/en/acmed.8: -------------------------------------------------------------------------------- 1 | .\" Copyright (c) 2019-2020 Rodolphe Bréard 2 | .\" 3 | .\" Copying and distribution of this file, with or without modification, 4 | .\" are permitted in any medium without royalty provided the copyright 5 | .\" notice and this notice are preserved. This file is offered as-is, 6 | .\" without any warranty. 7 | .Dd June 12, 2020 8 | .Dt ACMED 8 9 | .Os 10 | .Sh NAME 11 | .Nm acmed 12 | .Nd ACME client daemon 13 | .Sh SYNOPSIS 14 | .Nm 15 | .Op Fl c|--config Ar FILE 16 | .Op Fl f|--foreground 17 | .Op Fl h|--help 18 | .Op Fl -log-stderr 19 | .Op Fl -log-syslog 20 | .Op Fl -log-level Ar LEVEL 21 | .Op Fl -pid-file Ar FILE 22 | .Op Fl -root-cert Ar FILE 23 | .Op Fl V|--version 24 | .Sh DESCRIPTION 25 | .Nm 26 | is an Automatic Certificate Management Environment 27 | .Pq ACME 28 | client daemon which can automatically request and renew X.509 security certificates from various Certification Authorities 29 | .Pq CA . 30 | .Pp 31 | The options are as follows: 32 | .Bl -tag 33 | .It Fl c, -config Ar FILE 34 | Specify an alternative configuration file. 35 | .It Fl f, -foreground 36 | Runs in the foreground 37 | .It Fl h, -help 38 | Prints help information 39 | .It Fl -log-stderr 40 | Prints log messages to the standard error output 41 | .It Fl -log-syslog 42 | Sends log messages via syslog 43 | .It Fl -log-level Ar LEVEL 44 | Specify the log level. Possible values: error, warn, info, debug and trace. 45 | .It Fl -pid-file Ar FILE 46 | Specifies the location of the PID file 47 | .It Fl -root-cert Ar FILE 48 | Add a root certificate to the trust store. This option can be used multiple times. 49 | .It Fl V, -version 50 | Prints version information 51 | .Sh FILES 52 | .Bl -tag 53 | .It Pa /etc/acmed/acmed.toml 54 | Default 55 | .Nm 56 | configuration file. 57 | .Sh SEE ALSO 58 | .Xr acmed.toml 5 , 59 | .Xr tacd 8 60 | .Sh STANDARDS 61 | .Bl 62 | .It 63 | .Rs 64 | .%A R. Barnes 65 | .%A J. Hoffman-Andrews 66 | .%A D. McCarney 67 | .%A J. Kasten 68 | .%D March 2019 69 | .%R RFC 8555 70 | .%T Automatic Certificate Management Environment (ACME) 71 | .Re 72 | .It 73 | .Rs 74 | .%A R.B. Shoemaker 75 | .%D February 2020 76 | .%R RFC 8737 77 | .%T Automated Certificate Management Environment (ACME) TLS Application-Layer Protocol Negotiation (ALPN) Challenge Extension 78 | .Re 79 | .Sh AUTHORS 80 | .An Rodolphe Bréard 81 | .Aq rodolphe@breard.tf 82 | -------------------------------------------------------------------------------- /acme_common/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::File; 3 | use std::io::prelude::*; 4 | use std::io::BufReader; 5 | use std::path::PathBuf; 6 | 7 | macro_rules! set_rustc_env_var { 8 | ($name: expr, $value: expr) => {{ 9 | println!("cargo:rustc-env={}={}", $name, $value); 10 | }}; 11 | } 12 | 13 | fn get_openssl_version_unit(n: u64, pos: u32) -> u64 { 14 | let p = 0x000f_f000_0000 >> (8 * pos); 15 | let n = n & p; 16 | n >> (8 * (3 - pos) + 4) 17 | } 18 | 19 | fn get_openssl_version(v: &str) -> String { 20 | let v = u64::from_str_radix(&v, 16).unwrap(); 21 | let mut version = vec![]; 22 | for i in 0..3 { 23 | let n = get_openssl_version_unit(v, i); 24 | version.push(format!("{}", n)); 25 | } 26 | let version = version.join("."); 27 | let p = get_openssl_version_unit(v, 3); 28 | if p != 0 { 29 | let p = p + 0x60; 30 | let p = std::char::from_u32(p as u32).unwrap(); 31 | format!("{}{}", version, p) 32 | } else { 33 | version 34 | } 35 | } 36 | 37 | fn get_lib_version(lib: &str) -> Option { 38 | let pat = format!("\"checksum {} ", lib); 39 | let mut lock_file = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); 40 | lock_file.push("../Cargo.lock"); 41 | let file = File::open(lock_file).unwrap(); 42 | for line in BufReader::new(file).lines() { 43 | let line = line.unwrap(); 44 | if line.starts_with(&pat) { 45 | let v: Vec<&str> = line.split(' ').collect(); 46 | return Some(String::from(v[2])); 47 | } 48 | } 49 | None 50 | } 51 | 52 | fn set_tls() { 53 | if let Ok(v) = env::var("DEP_OPENSSL_VERSION_NUMBER") { 54 | let version = get_openssl_version(&v); 55 | set_rustc_env_var!("ACMED_TLS_LIB_VERSION", version); 56 | set_rustc_env_var!("ACMED_TLS_LIB_NAME", "OpenSSL"); 57 | } 58 | if let Ok(v) = env::var("DEP_OPENSSL_LIBRESSL_VERSION_NUMBER") { 59 | let version = get_openssl_version(&v); 60 | set_rustc_env_var!("ACMED_TLS_LIB_VERSION", version); 61 | set_rustc_env_var!("ACMED_TLS_LIB_NAME", "LibreSSL"); 62 | } 63 | if env::var("CARGO_FEATURE_STANDALONE").is_ok() { 64 | let version = get_lib_version("ring").unwrap(); 65 | set_rustc_env_var!("ACMED_TLS_LIB_VERSION", version); 66 | set_rustc_env_var!("ACMED_TLS_LIB_NAME", "ring"); 67 | } 68 | } 69 | 70 | fn main() { 71 | set_tls(); 72 | } 73 | -------------------------------------------------------------------------------- /acme_common/src/logs.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use env_logger::Builder; 3 | use log::LevelFilter; 4 | use syslog::Facility; 5 | 6 | const DEFAULT_LOG_SYSTEM: LogSystem = LogSystem::SysLog; 7 | const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn; 8 | 9 | #[derive(Debug, PartialEq, Eq)] 10 | pub enum LogSystem { 11 | SysLog, 12 | StdErr, 13 | } 14 | 15 | fn get_loglevel(log_level: Option<&str>) -> Result { 16 | let level = match log_level { 17 | Some(v) => match v { 18 | "error" => LevelFilter::Error, 19 | "warn" => LevelFilter::Warn, 20 | "info" => LevelFilter::Info, 21 | "debug" => LevelFilter::Debug, 22 | "trace" => LevelFilter::Trace, 23 | _ => { 24 | return Err(format!("{}: invalid log level", v).into()); 25 | } 26 | }, 27 | None => DEFAULT_LOG_LEVEL, 28 | }; 29 | Ok(level) 30 | } 31 | 32 | fn set_log_syslog(log_level: LevelFilter) -> Result<(), Error> { 33 | syslog::init( 34 | Facility::LOG_DAEMON, 35 | log_level, 36 | Some(env!("CARGO_PKG_NAME")), 37 | )?; 38 | Ok(()) 39 | } 40 | 41 | fn set_log_stderr(log_level: LevelFilter) -> Result<(), Error> { 42 | let mut builder = Builder::from_env("ACMED_LOG_LEVEL"); 43 | builder.filter_level(log_level); 44 | builder.init(); 45 | Ok(()) 46 | } 47 | 48 | pub fn set_log_system( 49 | log_level: Option<&str>, 50 | has_syslog: bool, 51 | has_stderr: bool, 52 | ) -> Result<(LogSystem, LevelFilter), Error> { 53 | let log_level = get_loglevel(log_level)?; 54 | let logtype = if has_syslog { 55 | LogSystem::SysLog 56 | } else if has_stderr { 57 | LogSystem::StdErr 58 | } else { 59 | DEFAULT_LOG_SYSTEM 60 | }; 61 | match logtype { 62 | LogSystem::SysLog => set_log_syslog(log_level)?, 63 | LogSystem::StdErr => set_log_stderr(log_level)?, 64 | }; 65 | Ok((logtype, log_level)) 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::{set_log_system, DEFAULT_LOG_LEVEL, DEFAULT_LOG_SYSTEM}; 71 | 72 | #[test] 73 | fn test_invalid_level() { 74 | let ret = set_log_system(Some("invalid"), false, false); 75 | assert!(ret.is_err()); 76 | } 77 | 78 | #[test] 79 | fn test_default_values() { 80 | let ret = set_log_system(None, false, false); 81 | assert!(ret.is_ok()); 82 | let (logtype, log_level) = ret.unwrap(); 83 | assert_eq!(logtype, DEFAULT_LOG_SYSTEM); 84 | assert_eq!(log_level, DEFAULT_LOG_LEVEL); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /acmed/src/jws/algorithms.rs: -------------------------------------------------------------------------------- 1 | use acme_common::crypto::{gen_keypair, KeyPair, KeyType}; 2 | use acme_common::error::Error; 3 | use std::fmt; 4 | use std::str::FromStr; 5 | 6 | #[derive(Debug, PartialEq, Eq)] 7 | pub enum SignatureAlgorithm { 8 | Es256, 9 | } 10 | 11 | impl fmt::Display for SignatureAlgorithm { 12 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 13 | let s = match self { 14 | SignatureAlgorithm::Es256 => "ES256", 15 | }; 16 | write!(f, "{}", s) 17 | } 18 | } 19 | 20 | impl FromStr for SignatureAlgorithm { 21 | type Err = Error; 22 | 23 | fn from_str(data: &str) -> Result { 24 | match data.to_lowercase().as_str() { 25 | "es256" => Ok(SignatureAlgorithm::Es256), 26 | _ => Err(format!("{}: unknown signature algorithm", data).into()), 27 | } 28 | } 29 | } 30 | 31 | impl SignatureAlgorithm { 32 | pub fn from_pkey(key_pair: &KeyPair) -> Result { 33 | match key_pair.key_type { 34 | KeyType::EcdsaP256 => Ok(SignatureAlgorithm::Es256), 35 | t => Err(format!("{}: unsupported key type", t).into()), 36 | } 37 | } 38 | 39 | pub fn gen_key_pair(&self) -> Result { 40 | match self { 41 | SignatureAlgorithm::Es256 => gen_keypair(KeyType::EcdsaP256), 42 | } 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use super::SignatureAlgorithm; 49 | use acme_common::crypto::KeyPair; 50 | use std::str::FromStr; 51 | 52 | #[test] 53 | fn test_es256_from_str() { 54 | let variants = ["ES256", "Es256", "es256"]; 55 | for v in variants.iter() { 56 | let a = SignatureAlgorithm::from_str(v); 57 | assert!(a.is_ok()); 58 | let a = a.unwrap(); 59 | assert_eq!(a, SignatureAlgorithm::Es256); 60 | } 61 | } 62 | 63 | #[test] 64 | fn test_es256_to_str() { 65 | let a = SignatureAlgorithm::Es256; 66 | assert_eq!(a.to_string().as_str(), "ES256"); 67 | } 68 | 69 | #[test] 70 | fn test_eddsa_ed25519_from_str() { 71 | let variants = ["ES256", "Es256", "es256"]; 72 | for v in variants.iter() { 73 | let a = SignatureAlgorithm::from_str(v); 74 | assert!(a.is_ok()); 75 | let a = a.unwrap(); 76 | assert_eq!(a, SignatureAlgorithm::Es256); 77 | } 78 | } 79 | 80 | #[test] 81 | fn test_from_p256() { 82 | let pem = b"-----BEGIN PRIVATE KEY----- 83 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6To1BW8qTehGhPca 84 | 0eMcW8iQU4yA02dvtKkuqfny4HChRANCAAQwxx+j3wYGzD5LSFNBTLlT7J+7rWrq 85 | 4BGdR8705iwpBeOQgMpLj+9vuFutlVtmoYpJSYa9+49Hxz8aCe1AQeWt 86 | -----END PRIVATE KEY-----"; 87 | let k = KeyPair::from_pem(pem).unwrap(); 88 | let s = SignatureAlgorithm::from_pkey(&k); 89 | assert!(s.is_ok()); 90 | let s = s.unwrap(); 91 | assert_eq!(s, SignatureAlgorithm::Es256) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /man/en/tacd.8: -------------------------------------------------------------------------------- 1 | .\" Copyright (c) 2019-2020 Rodolphe Bréard 2 | .\" 3 | .\" Copying and distribution of this file, with or without modification, 4 | .\" are permitted in any medium without royalty provided the copyright 5 | .\" notice and this notice are preserved. This file is offered as-is, 6 | .\" without any warranty. 7 | .Dd June 12, 2020 8 | .Dt TACD 8 9 | .Os 10 | .Sh NAME 11 | .Nm tacd 12 | .Nd TLS-ALPN Challenge Daemon 13 | .Sh SYNOPSIS 14 | .Nm 15 | .Op Fl e|--acme-ext Ar STRING 16 | .Op Fl -acme-ext-file Ar FILE 17 | .Op Fl d|--domain Ar STRING 18 | .Op Fl -domain-file Ar STRING 19 | .Op Fl f|--foreground 20 | .Op Fl h|--help 21 | .Op Fl l|--listen Ar host:port 22 | .Op Fl -log-stderr 23 | .Op Fl -log-syslog 24 | .Op Fl -log-level Ar LEVEL 25 | .Op Fl -pid-file Ar FILE 26 | .Op Fl V|--version 27 | .Sh DESCRIPTION 28 | .Nm 29 | is a server that will listen to incoming Transport Layer Security 30 | .Pq TLS 31 | connections and, if the 32 | .Em acme-tls/1 33 | protocol has been declared during the Application-Layer Protocol Negotiation 34 | .Pq ALPN , 35 | present a self-signed certificate in order to attempt to solve the TLS-ALPN-01 challenge. It then drops the connection. 36 | .Pp 37 | In order to generate the self-signed certificate, it is required to specify both the 38 | .Em domain name 39 | to validate and the 40 | .Em acmeIdentifier extension . 41 | If one of those values is not specified using the available options, it is read from the standard input. When reading from the standard input, a new line character is expected at the end. In the case both values needs to be read from the standard input, the 42 | .Em domain name 43 | is read first, then the 44 | .Em acmeIdentifier extension . 45 | .Pp 46 | The options are as follows: 47 | .Bl -tag 48 | .It Fl e, -acme-ext Ar STRING 49 | The acmeIdentifier extension to set in the self-signed certificate. 50 | .It Fl -acme-ext-file Ar FILE 51 | File from which is read the acmeIdentifier extension to set in the self-signed certificate. 52 | .It Fl d, -domain Ar STRING 53 | The domain that is being validated. 54 | .It Fl -domain-file Ar STRING 55 | File from which is read the domain that is being validated. 56 | .It Fl f, -foreground 57 | Runs in the foreground. 58 | .It Fl h, -help 59 | Prints help information. 60 | .It Fl i, -listen Ar host:port | unix:path 61 | Specifies the host and port combination or the unix socket to listen on. 62 | .It Fl -log-stderr 63 | Prints log messages to the standard error output. 64 | .It Fl -log-syslog 65 | Sends log messages via syslog. 66 | .It Fl -log-level Ar LEVEL 67 | Specify the log level. Possible values: error, warn, info, debug and trace. 68 | .It Fl -pid-file Ar FILE 69 | Specifies the location of the PID file. 70 | .It Fl V, -version 71 | Prints version information. 72 | .Sh SEE ALSO 73 | .Xr acmed.toml 5 74 | .Sh STANDARDS 75 | .Rs 76 | .%A R.B. Shoemaker 77 | .%D February 2020 78 | .%R RFC 8737 79 | .%T Automated Certificate Management Environment (ACME) TLS Application-Layer Protocol Negotiation (ALPN) Challenge Extension 80 | .Re 81 | .Sh AUTHORS 82 | .An Rodolphe Bréard 83 | .Aq rodolphe@breard.tf 84 | -------------------------------------------------------------------------------- /acme_common/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Error { 5 | pub message: String, 6 | } 7 | 8 | impl Error { 9 | pub fn prefix(&self, prefix: &str) -> Self { 10 | Error { 11 | message: format!("{}: {}", prefix, &self.message), 12 | } 13 | } 14 | } 15 | 16 | impl fmt::Display for Error { 17 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 18 | write!(f, "{}", self.message) 19 | } 20 | } 21 | 22 | impl From<&str> for Error { 23 | fn from(error: &str) -> Self { 24 | Error { 25 | message: error.to_string(), 26 | } 27 | } 28 | } 29 | 30 | impl From for Error { 31 | fn from(error: String) -> Self { 32 | error.as_str().into() 33 | } 34 | } 35 | 36 | impl From<&String> for Error { 37 | fn from(error: &String) -> Self { 38 | error.as_str().into() 39 | } 40 | } 41 | 42 | impl From for Error { 43 | fn from(error: std::io::Error) -> Self { 44 | format!("IO error: {}", error).into() 45 | } 46 | } 47 | 48 | impl From for Error { 49 | fn from(error: std::string::FromUtf8Error) -> Self { 50 | format!("UTF-8 error: {}", error).into() 51 | } 52 | } 53 | 54 | impl From for Error { 55 | fn from(error: std::sync::mpsc::RecvError) -> Self { 56 | format!("MSPC receiver error: {}", error).into() 57 | } 58 | } 59 | 60 | impl From for Error { 61 | fn from(error: std::time::SystemTimeError) -> Self { 62 | format!("SystemTimeError difference: {:?}", error.duration()).into() 63 | } 64 | } 65 | 66 | impl From for Error { 67 | fn from(error: syslog::Error) -> Self { 68 | format!("syslog error: {}", error).into() 69 | } 70 | } 71 | 72 | impl From for Error { 73 | fn from(error: toml::de::Error) -> Self { 74 | format!("IO error: {}", error).into() 75 | } 76 | } 77 | 78 | impl From for Error { 79 | fn from(error: serde_json::error::Error) -> Self { 80 | format!("IO error: {}", error).into() 81 | } 82 | } 83 | 84 | impl From for Error { 85 | fn from(error: attohttpc::Error) -> Self { 86 | format!("HTTP error: {}", error).into() 87 | } 88 | } 89 | 90 | impl From for Error { 91 | fn from(error: handlebars::TemplateRenderError) -> Self { 92 | format!("Template error: {}", error).into() 93 | } 94 | } 95 | 96 | impl From for Error { 97 | fn from(error: native_tls::Error) -> Self { 98 | format!("{}", error).into() 99 | } 100 | } 101 | 102 | impl From for Error { 103 | fn from(error: openssl::error::ErrorStack) -> Self { 104 | format!("{}", error).into() 105 | } 106 | } 107 | 108 | #[cfg(unix)] 109 | impl From for Error { 110 | fn from(error: nix::Error) -> Self { 111 | format!("{}", error).into() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ACMEd 2 | 3 | 4 | ## Testing and bug report 5 | 6 | The first way to help is to actually use the software and [report any bug you encounter](https://github.com/breard-r/acmed/issues). Do not hesitate to test the limits. 7 | 8 | 9 | ## Improving the language 10 | 11 | Since the author is not a native English speaker, some of the texts used in this project should be fixed. 12 | 13 | 14 | ## Work on dependencies 15 | 16 | ### attohttpc 17 | 18 | Although `attohttpc` is not currently a dependency, it may replace `reqwest` which is far too big and drags a lot of dependencies. But before this could be done, it needs to allow [new root certificates to be added](https://github.com/sbstp/attohttpc/issues/71). 19 | 20 | ### rust-openssl 21 | 22 | An improvement that would be appreciable is to add Curve 25519 support to the [openssl](https://crates.io/crates/openssl) crate. 23 | 24 | - https://github.com/sfackler/rust-openssl/issues/947 25 | - https://github.com/sfackler/rust-openssl/pull/1275 26 | 27 | ### Find or create a good template engine 28 | 29 | As reported in [issue #8](https://github.com/breard-r/acmed/issues/8), there is currently no perfect template engine. A good way to help improve ACMEd would be to find or create one that supports all the listed requirements. 30 | 31 | 32 | ## Improving the code 33 | 34 | As a one-man project, it has several goals already set but not explicitly written in an issue or any other follow-up file. It will not be the case before version 1.0 is released since everything may change at any moment. Therefore, it is recommended to request change instead of implementing them, this way we can discuss how things should be made. 35 | 36 | If you really want to submit a pull request, please : 37 | 38 | - document your changes in the man pages and the `CHANGELOG.md` file 39 | - write as much tests as you can 40 | - run `cargo test` and be sure every test pass 41 | - format your code using [rustfmt](https://github.com/rust-lang/rustfmt) 42 | - be sure not to have any warning when compiling 43 | - run [clippy](https://github.com/rust-lang/rust-clippy) and fix any issue 44 | - refrain from including a new dependency (crates having `ring` in their dependency tree are a no-go, see #2) 45 | - beware of potential repercussions on the default hooks: those should remain usable 46 | 47 | Not following the rules above will delay the merge since they will have to be fixed first. 48 | 49 | 50 | ## Author vs. contributor 51 | 52 | Some people have troubles seeing the difference between an author and a contributor. Here is how it is seen withing this project. 53 | 54 | A contributor is a person who helps the project in various ways whenever she or he wants. As such, a contributor does not have any obligation other than being respectful and open-minded. People who wrote code that have been accepted are automatically listed in the [contributors page](https://github.com/breard-r/acmed/graphs/contributors). The creation of a file with the names of people contributing outside of the code base will be studied upon request from such people. 55 | 56 | An author is a person who has some responsibilities on the project. Authors are expected to contribute on a regular basis, decide architectural choices, enforce copyright issues and so on. One does not grant himself the author status, it is given by the others authors after having discussed the request. 57 | -------------------------------------------------------------------------------- /acme_common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use daemonize::Daemonize; 2 | use std::fs::File; 3 | use std::io::prelude::*; 4 | use std::{fs, process}; 5 | 6 | pub mod crypto; 7 | pub mod error; 8 | pub mod logs; 9 | 10 | macro_rules! exit_match { 11 | ($e: expr) => { 12 | match $e { 13 | Ok(_) => {} 14 | Err(e) => { 15 | eprintln!("Error: {}", e); 16 | std::process::exit(3); 17 | } 18 | } 19 | }; 20 | } 21 | 22 | pub fn to_idna(domain_name: &str) -> Result { 23 | let mut idna_parts = vec![]; 24 | let parts: Vec<&str> = domain_name.split('.').collect(); 25 | for name in parts.iter() { 26 | let raw_name = name.to_lowercase(); 27 | let idna_name = if name.is_ascii() { 28 | raw_name 29 | } else { 30 | let idna_name = punycode::encode(&raw_name) 31 | .map_err(|_| error::Error::from("IDNA encoding failed."))?; 32 | format!("xn--{}", idna_name) 33 | }; 34 | idna_parts.push(idna_name); 35 | } 36 | Ok(idna_parts.join(".")) 37 | } 38 | 39 | pub fn b64_encode>(input: &T) -> String { 40 | base64::encode_config(input, base64::URL_SAFE_NO_PAD) 41 | } 42 | 43 | pub fn init_server(foreground: bool, pid_file: Option<&str>, default_pid_file: &str) { 44 | if !foreground { 45 | let daemonize = Daemonize::new().pid_file(pid_file.unwrap_or(default_pid_file)); 46 | exit_match!(daemonize.start()); 47 | } else if let Some(f) = pid_file { 48 | exit_match!(write_pid_file(f)); 49 | } 50 | } 51 | 52 | fn write_pid_file(pid_file: &str) -> Result<(), error::Error> { 53 | let data = format!("{}\n", process::id()).into_bytes(); 54 | let mut file = File::create(pid_file)?; 55 | file.write_all(&data)?; 56 | file.sync_all()?; 57 | Ok(()) 58 | } 59 | 60 | pub fn clean_pid_file(pid_file: Option<&str>) -> Result<(), error::Error> { 61 | if let Some(f) = pid_file { 62 | fs::remove_file(f)?; 63 | } 64 | Ok(()) 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use super::to_idna; 70 | 71 | #[test] 72 | fn test_no_idna() { 73 | let idna_res = to_idna("HeLo.example.com"); 74 | assert!(idna_res.is_ok()); 75 | assert_eq!(idna_res.unwrap(), "helo.example.com"); 76 | } 77 | 78 | #[test] 79 | fn test_simple_idna() { 80 | let idna_res = to_idna("Hélo.Example.com"); 81 | assert!(idna_res.is_ok()); 82 | assert_eq!(idna_res.unwrap(), "xn--hlo-bma.example.com"); 83 | } 84 | 85 | #[test] 86 | fn test_multiple_idna() { 87 | let idna_res = to_idna("ns1.hÉlo.aç-éièè.example.com"); 88 | assert!(idna_res.is_ok()); 89 | assert_eq!( 90 | idna_res.unwrap(), 91 | "ns1.xn--hlo-bma.xn--a-i-2lahae.example.com" 92 | ); 93 | } 94 | 95 | #[test] 96 | fn test_already_idna() { 97 | let idna_res = to_idna("xn--hlo-bma.example.com"); 98 | assert!(idna_res.is_ok()); 99 | assert_eq!(idna_res.unwrap(), "xn--hlo-bma.example.com"); 100 | } 101 | 102 | #[test] 103 | fn test_mixed_idna_parts() { 104 | let idna_res = to_idna("ns1.xn--hlo-bma.aç-éièè.example.com"); 105 | assert!(idna_res.is_ok()); 106 | assert_eq!( 107 | idna_res.unwrap(), 108 | "ns1.xn--hlo-bma.xn--a-i-2lahae.example.com" 109 | ); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs/order.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::structs::{ApiError, HttpApiError}; 2 | use acme_common::error::Error; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::str::FromStr; 6 | 7 | #[derive(Serialize)] 8 | pub struct NewOrder { 9 | pub identifiers: Vec, 10 | pub not_before: Option, 11 | pub not_after: Option, 12 | } 13 | 14 | impl NewOrder { 15 | pub fn new(domains: &[String]) -> Self { 16 | NewOrder { 17 | identifiers: domains.iter().map(|n| Identifier::new_dns(n)).collect(), 18 | not_before: None, 19 | not_after: None, 20 | } 21 | } 22 | } 23 | 24 | #[derive(Deserialize)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct Order { 27 | pub status: OrderStatus, 28 | pub expires: Option, 29 | pub identifiers: Vec, 30 | pub not_before: Option, 31 | pub not_after: Option, 32 | pub error: Option, 33 | pub authorizations: Vec, 34 | pub finalize: String, 35 | pub certificate: Option, 36 | } 37 | 38 | impl ApiError for Order { 39 | fn get_error(&self) -> Option { 40 | self.error.to_owned().map(Error::from) 41 | } 42 | } 43 | 44 | deserialize_from_str!(Order); 45 | 46 | #[derive(Debug, PartialEq, Eq, Deserialize)] 47 | #[serde(rename_all = "lowercase")] 48 | pub enum OrderStatus { 49 | Pending, 50 | Ready, 51 | Processing, 52 | Valid, 53 | Invalid, 54 | } 55 | 56 | impl fmt::Display for OrderStatus { 57 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 58 | let s = match self { 59 | OrderStatus::Pending => "pending", 60 | OrderStatus::Ready => "ready", 61 | OrderStatus::Processing => "processing", 62 | OrderStatus::Valid => "valid", 63 | OrderStatus::Invalid => "invalid", 64 | }; 65 | write!(f, "{}", s) 66 | } 67 | } 68 | 69 | #[derive(Deserialize, Serialize)] 70 | pub struct Identifier { 71 | #[serde(rename = "type")] 72 | pub id_type: IdentifierType, 73 | pub value: String, 74 | } 75 | 76 | impl Identifier { 77 | pub fn new_dns(value: &str) -> Self { 78 | Identifier { 79 | id_type: IdentifierType::Dns, 80 | value: value.to_string(), 81 | } 82 | } 83 | } 84 | 85 | deserialize_from_str!(Identifier); 86 | 87 | impl fmt::Display for Identifier { 88 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 89 | write!(f, "{}:{}", self.id_type, self.value) 90 | } 91 | } 92 | 93 | #[derive(Debug, Deserialize, Serialize, Eq, PartialEq)] 94 | pub enum IdentifierType { 95 | #[serde(rename = "dns")] 96 | Dns, 97 | } 98 | 99 | impl fmt::Display for IdentifierType { 100 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 101 | let s = match self { 102 | IdentifierType::Dns => "dns", 103 | }; 104 | write!(f, "{}", s) 105 | } 106 | } 107 | 108 | #[cfg(test)] 109 | mod tests { 110 | use super::{Identifier, IdentifierType}; 111 | use std::str::FromStr; 112 | 113 | #[test] 114 | fn id_serialize() { 115 | let reference = "{\"type\":\"dns\",\"value\":\"test.example.org\"}"; 116 | let id = Identifier { 117 | id_type: IdentifierType::Dns, 118 | value: "test.example.org".to_string(), 119 | }; 120 | let id_json = serde_json::to_string(&id); 121 | assert!(id_json.is_ok()); 122 | let id_json = id_json.unwrap(); 123 | assert_eq!(id_json, reference.to_string()); 124 | } 125 | 126 | #[test] 127 | fn id_deserialize_valid() { 128 | let id_str = "{\"type\":\"dns\",\"value\":\"test.example.org\"}"; 129 | let id = Identifier::from_str(id_str); 130 | assert!(id.is_ok()); 131 | let id = id.unwrap(); 132 | assert_eq!(id.id_type, IdentifierType::Dns); 133 | assert_eq!(id.value, "test.example.org".to_string()); 134 | } 135 | 136 | #[test] 137 | fn id_deserialize_invalid_type() { 138 | let id_str = "{\"type\":\"trololo\",\"value\":\"test.example.org\"}"; 139 | let id = Identifier::from_str(id_str); 140 | assert!(id.is_err()); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /acmed/src/jws.rs: -------------------------------------------------------------------------------- 1 | use crate::jws::algorithms::SignatureAlgorithm; 2 | use acme_common::b64_encode; 3 | use acme_common::crypto::{sha256, KeyPair}; 4 | use acme_common::error::Error; 5 | use serde::Serialize; 6 | use serde_json::value::Value; 7 | 8 | pub mod algorithms; 9 | 10 | #[derive(Serialize)] 11 | struct JwsData { 12 | protected: String, 13 | payload: String, 14 | signature: String, 15 | } 16 | 17 | #[derive(Serialize)] 18 | struct JwsProtectedHeaderJwk { 19 | alg: String, 20 | jwk: Value, 21 | nonce: String, 22 | url: String, 23 | } 24 | 25 | #[derive(Serialize)] 26 | struct JwsProtectedHeaderKid { 27 | alg: String, 28 | kid: String, 29 | nonce: String, 30 | url: String, 31 | } 32 | 33 | fn get_data(key_pair: &KeyPair, protected: &str, payload: &[u8]) -> Result { 34 | let protected = b64_encode(protected); 35 | let payload = b64_encode(payload); 36 | let signing_input = format!("{}.{}", protected, payload); 37 | let fingerprint = sha256(signing_input.as_bytes()); 38 | let signature = key_pair.sign(&fingerprint)?; 39 | let signature = b64_encode(&signature); 40 | let data = JwsData { 41 | protected, 42 | payload, 43 | signature, 44 | }; 45 | let str_data = serde_json::to_string(&data)?; 46 | Ok(str_data) 47 | } 48 | 49 | pub fn encode_jwk( 50 | key_pair: &KeyPair, 51 | payload: &[u8], 52 | url: &str, 53 | nonce: &str, 54 | ) -> Result { 55 | let sign_alg = SignatureAlgorithm::from_pkey(key_pair)?; 56 | let protected = JwsProtectedHeaderJwk { 57 | alg: sign_alg.to_string(), 58 | jwk: key_pair.jwk_public_key()?, 59 | nonce: nonce.into(), 60 | url: url.into(), 61 | }; 62 | let protected = serde_json::to_string(&protected)?; 63 | get_data(key_pair, &protected, payload) 64 | } 65 | 66 | pub fn encode_kid( 67 | key_pair: &KeyPair, 68 | key_id: &str, 69 | payload: &[u8], 70 | url: &str, 71 | nonce: &str, 72 | ) -> Result { 73 | let sign_alg = SignatureAlgorithm::from_pkey(key_pair)?; 74 | let protected = JwsProtectedHeaderKid { 75 | alg: sign_alg.to_string(), 76 | kid: key_id.to_string(), 77 | nonce: nonce.into(), 78 | url: url.into(), 79 | }; 80 | let protected = serde_json::to_string(&protected)?; 81 | get_data(key_pair, &protected, payload) 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use super::{encode_jwk, encode_kid}; 87 | use acme_common::crypto::{gen_keypair, KeyType}; 88 | 89 | #[test] 90 | fn test_default_jwk() { 91 | let key_pair = gen_keypair(KeyType::EcdsaP256).unwrap(); 92 | let payload = "Dummy payload 1"; 93 | let payload_b64 = "RHVtbXkgcGF5bG9hZCAx"; 94 | let s = encode_jwk(&key_pair, payload.as_bytes(), "", ""); 95 | assert!(s.is_ok()); 96 | let s = s.unwrap(); 97 | assert!(s.contains("\"protected\"")); 98 | assert!(s.contains("\"payload\"")); 99 | assert!(s.contains("\"signature\"")); 100 | assert!(s.contains(payload_b64)); 101 | } 102 | 103 | #[test] 104 | fn test_default_nopad_jwk() { 105 | let key_pair = gen_keypair(KeyType::EcdsaP256).unwrap(); 106 | let payload = "Dummy payload"; 107 | let payload_b64 = "RHVtbXkgcGF5bG9hZA"; 108 | let payload_b64_pad = "RHVtbXkgcGF5bG9hZA=="; 109 | let s = encode_jwk(&key_pair, payload.as_bytes(), "", ""); 110 | assert!(s.is_ok()); 111 | let s = s.unwrap(); 112 | assert!(s.contains("\"protected\"")); 113 | assert!(s.contains("\"payload\"")); 114 | assert!(s.contains("\"signature\"")); 115 | assert!(s.contains(payload_b64)); 116 | assert!(!s.contains(payload_b64_pad)); 117 | } 118 | 119 | #[test] 120 | fn test_default_kid() { 121 | let key_pair = gen_keypair(KeyType::EcdsaP256).unwrap(); 122 | let payload = "Dummy payload 1"; 123 | let payload_b64 = "RHVtbXkgcGF5bG9hZCAx"; 124 | let key_id = "0x2a"; 125 | let s = encode_kid(&key_pair, key_id, payload.as_bytes(), "", ""); 126 | assert!(s.is_ok()); 127 | let s = s.unwrap(); 128 | assert!(s.contains("\"protected\"")); 129 | assert!(s.contains("\"payload\"")); 130 | assert!(s.contains("\"signature\"")); 131 | assert!(s.contains(payload_b64)); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /acmed/config/default_hooks.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019-2020 Rodolphe Bréard 2 | # 3 | # Copying and distribution of this file, with or without modification, 4 | # are permitted in any medium without royalty provided the copyright 5 | # notice and this notice are preserved. This file is offered as-is, 6 | # without any warranty. 7 | 8 | # ------------------------------------------------------------------------ 9 | # Default hooks for ACMEd 10 | # You should not edit this file since it may be overridden by a newer one. 11 | # ------------------------------------------------------------------------ 12 | 13 | 14 | # 15 | # http-01 challenge in "/var/www/{{domain}}/" 16 | # 17 | 18 | [[hook]] 19 | name = "http-01-echo-mkdir" 20 | type = ["challenge-http-01"] 21 | cmd = "mkdir" 22 | args = [ 23 | "-m", "0755", 24 | "-p", "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge" 25 | ] 26 | allow_failure = true 27 | 28 | [[hook]] 29 | name = "http-01-echo-echo" 30 | type = ["challenge-http-01"] 31 | cmd = "echo" 32 | args = ["{{proof}}"] 33 | stdout = "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 34 | 35 | [[hook]] 36 | name = "http-01-echo-chmod" 37 | type = ["challenge-http-01"] 38 | cmd = "chmod" 39 | args = [ 40 | "a+r", 41 | "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 42 | ] 43 | allow_failure = true 44 | 45 | [[hook]] 46 | name = "http-01-echo-clean" 47 | type = ["challenge-http-01-clean"] 48 | cmd = "rm" 49 | args = [ 50 | "-f", 51 | "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 52 | ] 53 | allow_failure = true 54 | 55 | [[group]] 56 | name = "http-01-echo" 57 | hooks = [ 58 | "http-01-echo-mkdir", 59 | "http-01-echo-echo", 60 | "http-01-echo-chmod", 61 | "http-01-echo-clean" 62 | ] 63 | 64 | 65 | # 66 | # tls-alpn-01 challenge with tacd 67 | # 68 | 69 | [[hook]] 70 | name = "tls-alpn-01-tacd-start-tcp" 71 | type = ["challenge-tls-alpn-01"] 72 | cmd = "tacd" 73 | args = [ 74 | "--pid-file", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{domain}}.pid", 75 | "--domain", "{{domain}}", 76 | "--acme-ext", "{{proof}}", 77 | "--listen", "{{#if env.TACD_HOST}}{{env.TACD_HOST}}{{else}}{{domain}}{{/if}}:{{#if env.TACD_PORT}}{{env.TACD_PORT}}{{else}}5001{{/if}}" 78 | ] 79 | 80 | [[hook]] 81 | name = "tls-alpn-01-tacd-start-unix" 82 | type = ["challenge-tls-alpn-01"] 83 | cmd = "tacd" 84 | args = [ 85 | "--pid-file", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{domain}}.pid", 86 | "--domain", "{{domain}}", 87 | "--acme-ext", "{{proof}}", 88 | "--listen", "unix:{{#if env.TACD_SOCK_ROOT}}{{env.TACD_SOCK_ROOT}}{{else}}/run{{/if}}/tacd_{{domain}}.sock" 89 | ] 90 | 91 | [[hook]] 92 | name = "tls-alpn-01-tacd-kill" 93 | type = ["challenge-tls-alpn-01-clean"] 94 | cmd = "pkill" 95 | args = [ 96 | "-F", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{domain}}.pid", 97 | ] 98 | allow_failure = true 99 | 100 | [[hook]] 101 | name = "tls-alpn-01-tacd-rm" 102 | type = ["challenge-tls-alpn-01-clean"] 103 | cmd = "rm" 104 | args = [ 105 | "-f", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{domain}}.pid", 106 | ] 107 | allow_failure = true 108 | 109 | [[group]] 110 | name = "tls-alpn-01-tacd-tcp" 111 | hooks = ["tls-alpn-01-tacd-start-tcp", "tls-alpn-01-tacd-kill", "tls-alpn-01-tacd-rm"] 112 | 113 | [[group]] 114 | name = "tls-alpn-01-tacd-unix" 115 | hooks = ["tls-alpn-01-tacd-start-unix", "tls-alpn-01-tacd-kill", "tls-alpn-01-tacd-rm"] 116 | 117 | 118 | # 119 | # Git storage hook 120 | # 121 | 122 | [[hook]] 123 | name = "git-init" 124 | type = ["file-pre-create", "file-pre-edit"] 125 | cmd = "git" 126 | args = [ 127 | "init", 128 | "{{file_directory}}" 129 | ] 130 | 131 | [[hook]] 132 | name = "git-add" 133 | type = ["file-post-create", "file-post-edit"] 134 | cmd = "git" 135 | args = [ 136 | "-C", "{{file_directory}}", 137 | "add", "{{file_name}}" 138 | ] 139 | 140 | [[hook]] 141 | name = "git-commit" 142 | type = ["file-post-create", "file-post-edit"] 143 | cmd = "git" 144 | args = [ 145 | "-C", "{{file_directory}}", 146 | "-c", "user.name='{{#if env.GIT_USERNAME}}{{env.GIT_USERNAME}}{{else}}ACMEd{{/if}}'", 147 | "-c", "user.email='{{#if env.GIT_EMAIL}}{{env.GIT_EMAIL}}{{else}}acmed@localhost{{/if}}'", 148 | "commit", 149 | "-m", "{{file_name}}", 150 | "--only", "{{file_name}}" 151 | ] 152 | 153 | [[group]] 154 | name = "git" 155 | hooks = ["git-init", "git-add", "git-commit"] 156 | -------------------------------------------------------------------------------- /acmed/src/main_event_loop.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::account::init_account; 2 | use crate::acme_proto::request_certificate; 3 | use crate::certificate::Certificate; 4 | use crate::config; 5 | use crate::endpoint::Endpoint; 6 | use acme_common::error::Error; 7 | use std::collections::HashMap; 8 | use std::sync::{Arc, RwLock}; 9 | use std::thread; 10 | use std::time::Duration; 11 | 12 | type EndpointSync = Arc>; 13 | 14 | fn renew_certificate(crt: &Certificate, root_certs: &[String], endpoint: &mut Endpoint) { 15 | let (status, is_success) = match request_certificate(crt, root_certs, endpoint) { 16 | Ok(_) => ("Success.".to_string(), true), 17 | Err(e) => { 18 | let e = e.prefix("Unable to renew the certificate"); 19 | crt.warn(&e.message); 20 | (e.message, false) 21 | } 22 | }; 23 | match crt.call_post_operation_hooks(&status, is_success) { 24 | Ok(_) => {} 25 | Err(e) => { 26 | let e = e.prefix("Post-operation hook error"); 27 | crt.warn(&e.message); 28 | } 29 | }; 30 | } 31 | 32 | pub struct MainEventLoop { 33 | certs: Vec, 34 | root_certs: Vec, 35 | endpoints: HashMap, 36 | } 37 | 38 | impl MainEventLoop { 39 | pub fn new(config_file: &str, root_certs: &[&str]) -> Result { 40 | let cnf = config::from_file(config_file)?; 41 | 42 | let mut certs = Vec::new(); 43 | let mut endpoints = HashMap::new(); 44 | for (i, crt) in cnf.certificate.iter().enumerate() { 45 | let endpoint = crt.get_endpoint(&cnf)?; 46 | let endpoint_name = endpoint.name.clone(); 47 | let cert = Certificate { 48 | account: crt.get_account(&cnf)?, 49 | domains: crt.get_domains()?, 50 | algo: crt.get_algorithm()?, 51 | kp_reuse: crt.get_kp_reuse(), 52 | endpoint_name: endpoint_name.clone(), 53 | hooks: crt.get_hooks(&cnf)?, 54 | account_directory: cnf.get_account_dir(), 55 | crt_directory: crt.get_crt_dir(&cnf), 56 | crt_name: crt.get_crt_name(), 57 | crt_name_format: crt.get_crt_name_format(), 58 | cert_file_mode: cnf.get_cert_file_mode(), 59 | cert_file_owner: cnf.get_cert_file_user(), 60 | cert_file_group: cnf.get_cert_file_group(), 61 | pk_file_mode: cnf.get_pk_file_mode(), 62 | pk_file_owner: cnf.get_pk_file_user(), 63 | pk_file_group: cnf.get_pk_file_group(), 64 | env: crt.env.to_owned(), 65 | id: i + 1, 66 | }; 67 | endpoints 68 | .entry(endpoint_name) 69 | .or_insert_with(|| Arc::new(RwLock::new(endpoint))); 70 | init_account(&cert)?; 71 | certs.push(cert); 72 | } 73 | 74 | Ok(MainEventLoop { 75 | certs, 76 | root_certs: root_certs.iter().map(|v| (*v).to_string()).collect(), 77 | endpoints, 78 | }) 79 | } 80 | 81 | pub fn run(&mut self) { 82 | loop { 83 | self.renew_certificates(); 84 | thread::sleep(Duration::from_secs(crate::DEFAULT_SLEEP_TIME)); 85 | } 86 | } 87 | 88 | fn renew_certificates(&mut self) { 89 | let mut handles = vec![]; 90 | for (ep_name, endpoint_lock) in self.endpoints.iter_mut() { 91 | let mut certs_to_renew = vec![]; 92 | for crt in self.certs.iter() { 93 | if crt.endpoint_name == *ep_name { 94 | match crt.should_renew() { 95 | Ok(true) => { 96 | let crt_arc = Arc::new(crt.clone()); 97 | certs_to_renew.push(crt_arc); 98 | } 99 | Ok(false) => {} 100 | Err(e) => { 101 | crt.warn(&e.message); 102 | } 103 | } 104 | } 105 | } 106 | let lock = endpoint_lock.clone(); 107 | let rc = self.root_certs.clone(); 108 | let handle = thread::spawn(move || { 109 | let mut endpoint = lock.write().unwrap(); 110 | for crt in certs_to_renew { 111 | //let root_certs = rc.clone(); 112 | renew_certificate(&crt, &rc, &mut endpoint); 113 | } 114 | }); 115 | handles.push(handle); 116 | } 117 | for handle in handles { 118 | let _ = handle.join(); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /acmed/src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::main_event_loop::MainEventLoop; 2 | use acme_common::{clean_pid_file, crypto, init_server}; 3 | use clap::{App, Arg}; 4 | use log::error; 5 | 6 | mod acme_proto; 7 | mod certificate; 8 | mod config; 9 | mod endpoint; 10 | mod hooks; 11 | mod http; 12 | mod jws; 13 | mod main_event_loop; 14 | mod storage; 15 | 16 | pub const APP_NAME: &str = "ACMEd"; 17 | pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 18 | pub const DEFAULT_PID_FILE: &str = "/var/run/acmed.pid"; 19 | pub const DEFAULT_CONFIG_FILE: &str = "/etc/acmed/acmed.toml"; 20 | pub const DEFAULT_ACCOUNTS_DIR: &str = "/etc/acmed/accounts"; 21 | pub const DEFAULT_CERT_DIR: &str = "/etc/acmed/certs"; 22 | pub const DEFAULT_CERT_FORMAT: &str = "{{name}}_{{algo}}.{{file_type}}.{{ext}}"; 23 | pub const DEFAULT_SLEEP_TIME: u64 = 3600; 24 | pub const DEFAULT_POOL_TIME: u64 = 5000; 25 | pub const DEFAULT_CERT_FILE_MODE: u32 = 0o644; 26 | pub const DEFAULT_PK_FILE_MODE: u32 = 0o600; 27 | pub const DEFAULT_ACCOUNT_FILE_MODE: u32 = 0o600; 28 | pub const DEFAULT_KP_REUSE: bool = false; 29 | pub const DEFAULT_JWS_SIGN_ALGO: &str = "ES256"; 30 | pub const DEFAULT_POOL_NB_TRIES: usize = 20; 31 | pub const DEFAULT_POOL_WAIT_SEC: u64 = 5; 32 | pub const DEFAULT_HTTP_FAIL_NB_RETRY: usize = 10; 33 | pub const DEFAULT_HTTP_FAIL_WAIT_SEC: u64 = 1; 34 | pub const DEFAULT_HOOK_ALLOW_FAILURE: bool = false; 35 | pub const MAX_RATE_LIMIT_SLEEP_MILISEC: u64 = 3_600_000; 36 | pub const MIN_RATE_LIMIT_SLEEP_MILISEC: u64 = 100; 37 | 38 | fn main() { 39 | let full_version = format!( 40 | "{} {}\n\nCompiled with:\n {} {}\n {} {}", 41 | APP_VERSION, 42 | env!("ACMED_TARGET"), 43 | crypto::TLS_LIB_NAME, 44 | crypto::TLS_LIB_VERSION, 45 | env!("ACMED_HTTP_LIB_NAME"), 46 | env!("ACMED_HTTP_LIB_VERSION") 47 | ); 48 | let matches = App::new(APP_NAME) 49 | .version(APP_VERSION) 50 | .long_version(full_version.as_str()) 51 | .arg( 52 | Arg::with_name("config") 53 | .short("c") 54 | .long("config") 55 | .help("Specify an alternative configuration file") 56 | .takes_value(true) 57 | .value_name("FILE"), 58 | ) 59 | .arg( 60 | Arg::with_name("log-level") 61 | .long("log-level") 62 | .help("Specify the log level") 63 | .takes_value(true) 64 | .value_name("LEVEL") 65 | .possible_values(&["error", "warn", "info", "debug", "trace"]), 66 | ) 67 | .arg( 68 | Arg::with_name("to-syslog") 69 | .long("log-syslog") 70 | .help("Sends log messages via syslog") 71 | .conflicts_with("to-stderr"), 72 | ) 73 | .arg( 74 | Arg::with_name("to-stderr") 75 | .long("log-stderr") 76 | .help("Prints log messages to the standard error output") 77 | .conflicts_with("log-syslog"), 78 | ) 79 | .arg( 80 | Arg::with_name("foreground") 81 | .short("f") 82 | .long("foreground") 83 | .help("Runs in the foreground"), 84 | ) 85 | .arg( 86 | Arg::with_name("pid-file") 87 | .long("pid-file") 88 | .help("Specifies the location of the PID file") 89 | .takes_value(true) 90 | .value_name("FILE"), 91 | ) 92 | .arg( 93 | Arg::with_name("root-cert") 94 | .long("root-cert") 95 | .help("Add a root certificate to the trust store") 96 | .takes_value(true) 97 | .multiple(true) 98 | .value_name("FILE"), 99 | ) 100 | .get_matches(); 101 | 102 | match acme_common::logs::set_log_system( 103 | matches.value_of("log-level"), 104 | matches.is_present("log-syslog"), 105 | matches.is_present("to-stderr"), 106 | ) { 107 | Ok(_) => {} 108 | Err(e) => { 109 | eprintln!("Error: {}", e); 110 | std::process::exit(2); 111 | } 112 | }; 113 | 114 | let root_certs = match matches.values_of("root-cert") { 115 | Some(v) => v.collect(), 116 | None => vec![], 117 | }; 118 | 119 | init_server( 120 | matches.is_present("foreground"), 121 | matches.value_of("pid-file"), 122 | DEFAULT_PID_FILE, 123 | ); 124 | 125 | let config_file = matches.value_of("config").unwrap_or(DEFAULT_CONFIG_FILE); 126 | let mut srv = match MainEventLoop::new(&config_file, &root_certs) { 127 | Ok(s) => s, 128 | Err(e) => { 129 | error!("{}", e); 130 | let _ = clean_pid_file(matches.value_of("pid-file")); 131 | std::process::exit(1); 132 | } 133 | }; 134 | srv.run(); 135 | } 136 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/http.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::structs::{AccountResponse, Authorization, Directory, Order}; 2 | use crate::endpoint::Endpoint; 3 | use crate::http; 4 | use acme_common::error::Error; 5 | use std::{thread, time}; 6 | 7 | macro_rules! pool_object { 8 | ($obj_type: ty, $obj_name: expr, $endpoint: expr, $root_certs: expr, $url: expr, $data_builder: expr, $break: expr) => {{ 9 | for _ in 0..crate::DEFAULT_POOL_NB_TRIES { 10 | thread::sleep(time::Duration::from_secs(crate::DEFAULT_POOL_WAIT_SEC)); 11 | let response = http::post_jose($endpoint, $root_certs, $url, $data_builder)?; 12 | let obj = response.json::<$obj_type>()?; 13 | if $break(&obj) { 14 | return Ok(obj); 15 | } 16 | } 17 | let msg = format!("{} pooling failed on {}", $obj_name, $url); 18 | Err(msg.into()) 19 | }}; 20 | } 21 | 22 | pub fn refresh_directory(endpoint: &mut Endpoint, root_certs: &[String]) -> Result<(), Error> { 23 | let url = endpoint.url.clone(); 24 | let response = http::get(endpoint, root_certs, &url)?; 25 | endpoint.dir = response.json::()?; 26 | Ok(()) 27 | } 28 | 29 | pub fn new_account( 30 | endpoint: &mut Endpoint, 31 | root_certs: &[String], 32 | data_builder: &F, 33 | ) -> Result<(AccountResponse, String), Error> 34 | where 35 | F: Fn(&str, &str) -> Result, 36 | { 37 | let url = endpoint.dir.new_account.clone(); 38 | let response = http::post_jose(endpoint, root_certs, &url, data_builder)?; 39 | let acc_uri = response 40 | .headers() 41 | .get(http::HEADER_LOCATION) 42 | .ok_or_else(|| Error::from("No account location found."))?; 43 | let acc_uri = http::header_to_string(&acc_uri)?; 44 | let acc_resp = response.json::()?; 45 | Ok((acc_resp, acc_uri)) 46 | } 47 | 48 | pub fn new_order( 49 | endpoint: &mut Endpoint, 50 | root_certs: &[String], 51 | data_builder: &F, 52 | ) -> Result<(Order, String), Error> 53 | where 54 | F: Fn(&str, &str) -> Result, 55 | { 56 | let url = endpoint.dir.new_order.clone(); 57 | let response = http::post_jose(endpoint, root_certs, &url, data_builder)?; 58 | let order_uri = response 59 | .headers() 60 | .get(http::HEADER_LOCATION) 61 | .ok_or_else(|| Error::from("No order location found."))?; 62 | let order_uri = http::header_to_string(&order_uri)?; 63 | let order_resp = response.json::()?; 64 | Ok((order_resp, order_uri)) 65 | } 66 | 67 | pub fn get_authorization( 68 | endpoint: &mut Endpoint, 69 | root_certs: &[String], 70 | data_builder: &F, 71 | url: &str, 72 | ) -> Result 73 | where 74 | F: Fn(&str, &str) -> Result, 75 | { 76 | let response = http::post_jose(endpoint, root_certs, &url, data_builder)?; 77 | let auth = response.json::()?; 78 | Ok(auth) 79 | } 80 | 81 | pub fn post_challenge_response( 82 | endpoint: &mut Endpoint, 83 | root_certs: &[String], 84 | data_builder: &F, 85 | url: &str, 86 | ) -> Result<(), Error> 87 | where 88 | F: Fn(&str, &str) -> Result, 89 | { 90 | let _ = http::post_jose(endpoint, root_certs, &url, data_builder)?; 91 | Ok(()) 92 | } 93 | 94 | pub fn pool_authorization( 95 | endpoint: &mut Endpoint, 96 | root_certs: &[String], 97 | data_builder: &F, 98 | break_fn: &S, 99 | url: &str, 100 | ) -> Result 101 | where 102 | F: Fn(&str, &str) -> Result, 103 | S: Fn(&Authorization) -> bool, 104 | { 105 | pool_object!( 106 | Authorization, 107 | "Authorization", 108 | endpoint, 109 | root_certs, 110 | url, 111 | data_builder, 112 | break_fn 113 | ) 114 | } 115 | 116 | pub fn pool_order( 117 | endpoint: &mut Endpoint, 118 | root_certs: &[String], 119 | data_builder: &F, 120 | break_fn: &S, 121 | url: &str, 122 | ) -> Result 123 | where 124 | F: Fn(&str, &str) -> Result, 125 | S: Fn(&Order) -> bool, 126 | { 127 | pool_object!( 128 | Order, 129 | "Order", 130 | endpoint, 131 | root_certs, 132 | url, 133 | data_builder, 134 | break_fn 135 | ) 136 | } 137 | 138 | pub fn finalize_order( 139 | endpoint: &mut Endpoint, 140 | root_certs: &[String], 141 | data_builder: &F, 142 | url: &str, 143 | ) -> Result 144 | where 145 | F: Fn(&str, &str) -> Result, 146 | { 147 | let response = http::post_jose(endpoint, root_certs, &url, data_builder)?; 148 | let order = response.json::()?; 149 | Ok(order) 150 | } 151 | 152 | pub fn get_certificate( 153 | endpoint: &mut Endpoint, 154 | root_certs: &[String], 155 | data_builder: &F, 156 | url: &str, 157 | ) -> Result 158 | where 159 | F: Fn(&str, &str) -> Result, 160 | { 161 | let response = http::post( 162 | endpoint, 163 | root_certs, 164 | &url, 165 | data_builder, 166 | http::CONTENT_TYPE_JOSE, 167 | http::CONTENT_TYPE_PEM, 168 | )?; 169 | let crt_pem = response.text()?; 170 | Ok(crt_pem) 171 | } 172 | -------------------------------------------------------------------------------- /acmed/src/endpoint.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::structs::Directory; 2 | use acme_common::error::Error; 3 | use nom::bytes::complete::take_while_m_n; 4 | use nom::character::complete::digit1; 5 | use nom::combinator::map_res; 6 | use nom::multi::fold_many1; 7 | use nom::IResult; 8 | use std::cmp; 9 | use std::thread; 10 | use std::time::{Duration, Instant}; 11 | 12 | #[derive(Debug)] 13 | pub struct Endpoint { 14 | pub name: String, 15 | pub url: String, 16 | pub tos_agreed: bool, 17 | pub nonce: Option, 18 | pub rl: RateLimit, 19 | pub dir: Directory, 20 | } 21 | 22 | impl Endpoint { 23 | pub fn new( 24 | name: &str, 25 | url: &str, 26 | tos_agreed: bool, 27 | limits: &[(usize, String)], 28 | ) -> Result { 29 | let rl = RateLimit::new(limits)?; 30 | Ok(Self { 31 | name: name.to_string(), 32 | url: url.to_string(), 33 | tos_agreed, 34 | nonce: None, 35 | rl, 36 | dir: Directory { 37 | meta: None, 38 | new_nonce: String::new(), 39 | new_account: String::new(), 40 | new_order: String::new(), 41 | new_authz: None, 42 | revoke_cert: String::new(), 43 | key_change: String::new(), 44 | }, 45 | }) 46 | } 47 | } 48 | 49 | #[derive(Clone, Debug)] 50 | pub struct RateLimit { 51 | limits: Vec<(usize, Duration)>, 52 | query_log: Vec, 53 | } 54 | 55 | impl RateLimit { 56 | pub fn new(raw_limits: &[(usize, String)]) -> Result { 57 | let mut limits = vec![]; 58 | for (nb, raw_duration) in raw_limits.iter() { 59 | let parsed_duration = parse_duration(raw_duration)?; 60 | limits.push((*nb, parsed_duration)); 61 | } 62 | limits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); 63 | limits.reverse(); 64 | Ok(Self { 65 | limits, 66 | query_log: vec![], 67 | }) 68 | } 69 | 70 | pub fn block_until_allowed(&mut self) { 71 | if self.limits.is_empty() { 72 | return; 73 | } 74 | let sleep_duration = self.get_sleep_duration(); 75 | loop { 76 | self.prune_log(); 77 | if self.request_allowed() { 78 | self.query_log.push(Instant::now()); 79 | return; 80 | } 81 | // TODO: find a better sleep duration 82 | thread::sleep(sleep_duration); 83 | } 84 | } 85 | 86 | fn get_sleep_duration(&self) -> Duration { 87 | let (nb_req, min_duration) = match self.limits.last() { 88 | Some((n, d)) => (*n as u64, *d), 89 | None => { 90 | return Duration::from_millis(0); 91 | } 92 | }; 93 | let nb_mili = match min_duration.as_secs() { 94 | 0 | 1 => crate::MIN_RATE_LIMIT_SLEEP_MILISEC, 95 | n => { 96 | let a = n * 200 / nb_req; 97 | let a = cmp::min(a, crate::MAX_RATE_LIMIT_SLEEP_MILISEC); 98 | cmp::max(a, crate::MIN_RATE_LIMIT_SLEEP_MILISEC) 99 | } 100 | }; 101 | Duration::from_millis(nb_mili) 102 | } 103 | 104 | fn request_allowed(&self) -> bool { 105 | for (max_allowed, duration) in self.limits.iter() { 106 | let max_date = Instant::now() - *duration; 107 | let nb_req = self 108 | .query_log 109 | .iter() 110 | .filter(move |x| **x > max_date) 111 | .count(); 112 | if nb_req >= *max_allowed { 113 | return false; 114 | } 115 | } 116 | true 117 | } 118 | 119 | fn prune_log(&mut self) { 120 | if let Some((_, max_limit)) = self.limits.first() { 121 | let prune_date = Instant::now() - *max_limit; 122 | self.query_log.retain(move |&d| d > prune_date); 123 | } 124 | } 125 | } 126 | 127 | fn is_duration_chr(c: char) -> bool { 128 | c == 's' || c == 'm' || c == 'h' || c == 'd' || c == 'w' 129 | } 130 | 131 | fn get_multiplicator(input: &str) -> IResult<&str, u64> { 132 | let (input, nb) = take_while_m_n(1, 1, is_duration_chr)(input)?; 133 | let mult = match nb.chars().next() { 134 | Some('s') => 1, 135 | Some('m') => 60, 136 | Some('h') => 3_600, 137 | Some('d') => 86_400, 138 | Some('w') => 604_800, 139 | _ => 0, 140 | }; 141 | Ok((input, mult)) 142 | } 143 | 144 | fn get_duration_part(input: &str) -> IResult<&str, Duration> { 145 | let (input, nb) = map_res(digit1, |s: &str| s.parse::())(input)?; 146 | let (input, mult) = get_multiplicator(input)?; 147 | Ok((input, Duration::from_secs(nb * mult))) 148 | } 149 | 150 | fn get_duration(input: &str) -> IResult<&str, Duration> { 151 | fold_many1( 152 | get_duration_part, 153 | Duration::new(0, 0), 154 | |mut acc: Duration, item| { 155 | acc += item; 156 | acc 157 | }, 158 | )(input) 159 | } 160 | 161 | fn parse_duration(input: &str) -> Result { 162 | match get_duration(input) { 163 | Ok((r, d)) => match r.len() { 164 | 0 => Ok(d), 165 | _ => Err(format!("{}: invalid duration", input).into()), 166 | }, 167 | Err(_) => Err(format!("{}: invalid duration", input).into()), 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /acme_common/src/crypto/openssl_certificate.rs: -------------------------------------------------------------------------------- 1 | use super::{gen_keypair, KeyPair, KeyType}; 2 | use crate::b64_encode; 3 | use crate::error::Error; 4 | use openssl::asn1::Asn1Time; 5 | use openssl::bn::{BigNum, MsbOption}; 6 | use openssl::hash::MessageDigest; 7 | use openssl::stack::Stack; 8 | use openssl::x509::extension::{BasicConstraints, SubjectAlternativeName}; 9 | use openssl::x509::{X509Builder, X509Extension, X509NameBuilder, X509Req, X509ReqBuilder, X509}; 10 | use std::collections::HashSet; 11 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 12 | 13 | const APP_ORG: &str = "ACMEd"; 14 | const APP_NAME: &str = "ACMEd"; 15 | const X509_VERSION: i32 = 0x02; 16 | const CRT_SERIAL_NB_BITS: i32 = 32; 17 | const CRT_NB_DAYS_VALIDITY: u32 = 7; 18 | const INVALID_EXT_MSG: &str = "Invalid acmeIdentifier extension."; 19 | 20 | pub struct Csr { 21 | inner_csr: X509Req, 22 | } 23 | 24 | impl Csr { 25 | pub fn new(key_pair: &KeyPair, domains: &[String]) -> Result { 26 | let mut builder = X509ReqBuilder::new()?; 27 | builder.set_pubkey(&key_pair.inner_key)?; 28 | let ctx = builder.x509v3_context(None); 29 | let mut san = SubjectAlternativeName::new(); 30 | for dns in domains.iter() { 31 | san.dns(&dns); 32 | } 33 | let san = san.build(&ctx)?; 34 | let mut ext_stack = Stack::new()?; 35 | ext_stack.push(san)?; 36 | builder.add_extensions(&ext_stack)?; 37 | builder.sign(&key_pair.inner_key, MessageDigest::sha256())?; 38 | Ok(Csr { 39 | inner_csr: builder.build(), 40 | }) 41 | } 42 | 43 | pub fn to_der_base64(&self) -> Result { 44 | let csr = self.inner_csr.to_der()?; 45 | let csr = b64_encode(&csr); 46 | Ok(csr) 47 | } 48 | } 49 | 50 | pub struct X509Certificate { 51 | pub inner_cert: X509, 52 | } 53 | 54 | impl X509Certificate { 55 | pub fn from_pem(pem_data: &[u8]) -> Result { 56 | Ok(X509Certificate { 57 | inner_cert: X509::from_pem(pem_data)?, 58 | }) 59 | } 60 | 61 | pub fn from_pem_native(pem_data: &[u8]) -> Result { 62 | Ok(native_tls::Certificate::from_pem(pem_data)?) 63 | } 64 | 65 | pub fn from_acme_ext(domain: &str, acme_ext: &str) -> Result<(KeyPair, Self), Error> { 66 | let key_pair = gen_keypair(KeyType::EcdsaP256)?; 67 | let inner_cert = gen_certificate(domain, &key_pair, acme_ext)?; 68 | let cert = X509Certificate { inner_cert }; 69 | Ok((key_pair, cert)) 70 | } 71 | 72 | pub fn expires_in(&self) -> Result { 73 | let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; 74 | let now = Asn1Time::from_unix(timestamp)?; 75 | let not_after = self.inner_cert.not_after(); 76 | let diff = now.diff(not_after)?; 77 | let nb_secs = diff.days * 24 * 60 * 60 + diff.secs; 78 | let nb_secs = if nb_secs > 0 { nb_secs as u64 } else { 0 }; 79 | Ok(Duration::from_secs(nb_secs)) 80 | } 81 | 82 | pub fn subject_alt_names(&self) -> HashSet { 83 | match self.inner_cert.subject_alt_names() { 84 | Some(s) => s 85 | .iter() 86 | .filter(|v| v.dnsname().is_some()) 87 | .map(|v| v.dnsname().unwrap().to_string()) 88 | .collect(), 89 | None => HashSet::new(), 90 | } 91 | } 92 | } 93 | 94 | fn gen_certificate(domain: &str, key_pair: &KeyPair, acme_ext: &str) -> Result { 95 | let mut x509_name = X509NameBuilder::new()?; 96 | x509_name.append_entry_by_text("O", APP_ORG)?; 97 | let ca_name = format!("{} TLS-ALPN-01 Authority", APP_NAME); 98 | x509_name.append_entry_by_text("CN", &ca_name)?; 99 | let x509_name = x509_name.build(); 100 | 101 | let mut builder = X509Builder::new()?; 102 | builder.set_version(X509_VERSION)?; 103 | let serial_number = { 104 | let mut serial = BigNum::new()?; 105 | serial.rand(CRT_SERIAL_NB_BITS - 1, MsbOption::MAYBE_ZERO, false)?; 106 | serial.to_asn1_integer()? 107 | }; 108 | builder.set_serial_number(&serial_number)?; 109 | builder.set_subject_name(&x509_name)?; 110 | builder.set_issuer_name(&x509_name)?; 111 | builder.set_pubkey(&key_pair.inner_key)?; 112 | let not_before = Asn1Time::days_from_now(0)?; 113 | builder.set_not_before(¬_before)?; 114 | let not_after = Asn1Time::days_from_now(CRT_NB_DAYS_VALIDITY)?; 115 | builder.set_not_after(¬_after)?; 116 | 117 | builder.append_extension(BasicConstraints::new().build()?)?; 118 | let ctx = builder.x509v3_context(None, None); 119 | let san_ext = SubjectAlternativeName::new().dns(domain).build(&ctx)?; 120 | builder.append_extension(san_ext)?; 121 | 122 | let ctx = builder.x509v3_context(None, None); 123 | let mut v: Vec<&str> = acme_ext.split('=').collect(); 124 | let value = v.pop().ok_or_else(|| Error::from(INVALID_EXT_MSG))?; 125 | let acme_ext_name = v.pop().ok_or_else(|| Error::from(INVALID_EXT_MSG))?; 126 | if !v.is_empty() { 127 | return Err(Error::from(INVALID_EXT_MSG)); 128 | } 129 | let acme_ext = X509Extension::new(None, Some(&ctx), &acme_ext_name, &value) 130 | .map_err(|_| Error::from(INVALID_EXT_MSG))?; 131 | builder 132 | .append_extension(acme_ext) 133 | .map_err(|_| Error::from(INVALID_EXT_MSG))?; 134 | builder.sign(&key_pair.inner_key, MessageDigest::sha256())?; 135 | let cert = builder.build(); 136 | Ok(cert) 137 | } 138 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs/account.rs: -------------------------------------------------------------------------------- 1 | use crate::certificate::Certificate; 2 | use crate::endpoint::Endpoint; 3 | use acme_common::error::Error; 4 | use serde::{Deserialize, Serialize}; 5 | use std::str::FromStr; 6 | 7 | #[derive(Serialize)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Account { 10 | pub contact: Vec, 11 | pub terms_of_service_agreed: bool, 12 | pub only_return_existing: bool, 13 | } 14 | 15 | impl Account { 16 | pub fn new(cert: &Certificate, endpoint: &Endpoint) -> Self { 17 | Account { 18 | contact: vec![format!("mailto:{}", cert.account.email)], 19 | terms_of_service_agreed: endpoint.tos_agreed, 20 | only_return_existing: false, 21 | } 22 | } 23 | } 24 | 25 | #[derive(Deserialize)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct AccountResponse { 28 | pub status: String, 29 | pub contact: Option>, 30 | pub terms_of_service_agreed: Option, 31 | pub external_account_binding: Option, 32 | pub orders: Option, 33 | } 34 | 35 | deserialize_from_str!(AccountResponse); 36 | 37 | // TODO: implement account update 38 | #[allow(dead_code)] 39 | #[derive(Serialize)] 40 | pub struct AccountUpdate { 41 | pub contact: Vec, 42 | } 43 | 44 | impl AccountUpdate { 45 | #[allow(dead_code)] 46 | pub fn new(contact: &[String]) -> Self { 47 | AccountUpdate { 48 | contact: contact.into(), 49 | } 50 | } 51 | } 52 | 53 | // TODO: implement account deactivation 54 | #[allow(dead_code)] 55 | #[derive(Serialize)] 56 | pub struct AccountDeactivation { 57 | pub status: String, 58 | } 59 | 60 | impl AccountDeactivation { 61 | #[allow(dead_code)] 62 | pub fn new() -> Self { 63 | AccountDeactivation { 64 | status: "deactivated".into(), 65 | } 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | fn test_account_new() { 75 | let emails = vec![ 76 | "mailto:derp@example.com".to_string(), 77 | "mailto:derp.derpson@example.com".to_string(), 78 | ]; 79 | let a = Account { 80 | contact: emails, 81 | terms_of_service_agreed: true, 82 | only_return_existing: false, 83 | }; 84 | assert_eq!(a.contact.len(), 2); 85 | assert_eq!(a.terms_of_service_agreed, true); 86 | assert_eq!(a.only_return_existing, false); 87 | let a_str = serde_json::to_string(&a); 88 | assert!(a_str.is_ok()); 89 | let a_str = a_str.unwrap(); 90 | assert!(a_str.starts_with("{")); 91 | assert!(a_str.ends_with("}")); 92 | assert!(a_str.contains("\"contact\"")); 93 | assert!(a_str.contains("\"mailto:derp@example.com\"")); 94 | assert!(a_str.contains("\"mailto:derp.derpson@example.com\"")); 95 | assert!(a_str.contains("\"termsOfServiceAgreed\"")); 96 | assert!(a_str.contains("\"onlyReturnExisting\"")); 97 | assert!(a_str.contains("true")); 98 | assert!(a_str.contains("false")); 99 | } 100 | 101 | #[test] 102 | fn test_account_response() { 103 | let data = "{ 104 | \"status\": \"valid\", 105 | \"contact\": [ 106 | \"mailto:cert-admin@example.org\", 107 | \"mailto:admin@example.org\" 108 | ], 109 | \"termsOfServiceAgreed\": true, 110 | \"orders\": \"https://example.com/acme/orders/rzGoeA\" 111 | }"; 112 | let account_resp = AccountResponse::from_str(data); 113 | assert!(account_resp.is_ok()); 114 | let account_resp = account_resp.unwrap(); 115 | assert_eq!(account_resp.status, "valid"); 116 | assert!(account_resp.contact.is_some()); 117 | let contacts = account_resp.contact.unwrap(); 118 | assert_eq!(contacts.len(), 2); 119 | assert_eq!(contacts[0], "mailto:cert-admin@example.org"); 120 | assert_eq!(contacts[1], "mailto:admin@example.org"); 121 | assert!(account_resp.external_account_binding.is_none()); 122 | assert!(account_resp.terms_of_service_agreed.is_some()); 123 | assert!(account_resp.terms_of_service_agreed.unwrap()); 124 | assert_eq!( 125 | account_resp.orders, 126 | Some("https://example.com/acme/orders/rzGoeA".into()) 127 | ); 128 | } 129 | 130 | #[test] 131 | fn test_account_update() { 132 | let emails = vec![ 133 | "mailto:derp@example.com".to_string(), 134 | "mailto:derp.derpson@example.com".to_string(), 135 | ]; 136 | let au = AccountUpdate::new(&emails); 137 | assert_eq!(au.contact.len(), 2); 138 | let au_str = serde_json::to_string(&au); 139 | assert!(au_str.is_ok()); 140 | let au_str = au_str.unwrap(); 141 | assert!(au_str.starts_with("{")); 142 | assert!(au_str.ends_with("}")); 143 | assert!(au_str.contains("\"contact\"")); 144 | assert!(au_str.contains("\"mailto:derp@example.com\"")); 145 | assert!(au_str.contains("\"mailto:derp.derpson@example.com\"")); 146 | } 147 | 148 | #[test] 149 | fn test_account_deactivation() { 150 | let ad = AccountDeactivation::new(); 151 | assert_eq!(ad.status, "deactivated"); 152 | let ad_str = serde_json::to_string(&ad); 153 | assert!(ad_str.is_ok()); 154 | let ad_str = ad_str.unwrap(); 155 | assert!(ad_str.starts_with("{")); 156 | assert!(ad_str.ends_with("}")); 157 | assert!(ad_str.contains("\"status\"")); 158 | assert!(ad_str.contains("\"deactivated\"")); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tacd/src/main.rs: -------------------------------------------------------------------------------- 1 | mod openssl_server; 2 | 3 | use crate::openssl_server::start as server_start; 4 | use acme_common::crypto::X509Certificate; 5 | use acme_common::error::Error; 6 | use acme_common::{clean_pid_file, to_idna}; 7 | use clap::{App, Arg, ArgMatches}; 8 | use log::{debug, error, info}; 9 | use std::fs::File; 10 | use std::io::{self, Read}; 11 | 12 | const APP_NAME: &str = env!("CARGO_PKG_NAME"); 13 | const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 14 | const DEFAULT_PID_FILE: &str = "/var/run/tacd.pid"; 15 | const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:5001"; 16 | const ALPN_ACME_PROTO_NAME: &[u8] = b"\x0aacme-tls/1"; 17 | 18 | fn read_line(path: Option<&str>) -> Result { 19 | let mut input = String::new(); 20 | match path { 21 | Some(p) => File::open(p)?.read_to_string(&mut input)?, 22 | None => io::stdin().read_line(&mut input)?, 23 | }; 24 | let line = input.trim().to_string(); 25 | Ok(line) 26 | } 27 | 28 | fn get_acme_value(cnf: &ArgMatches, opt: &str, opt_file: &str) -> Result { 29 | match cnf.value_of(opt) { 30 | Some(v) => Ok(v.to_string()), 31 | None => { 32 | debug!( 33 | "Reading {} from {}", 34 | opt, 35 | cnf.value_of(opt_file).unwrap_or("stdin") 36 | ); 37 | read_line(cnf.value_of(opt_file)) 38 | } 39 | } 40 | } 41 | 42 | fn init(cnf: &ArgMatches) -> Result<(), Error> { 43 | acme_common::init_server( 44 | cnf.is_present("foreground"), 45 | cnf.value_of("pid-file"), 46 | DEFAULT_PID_FILE, 47 | ); 48 | let domain = get_acme_value(cnf, "domain", "domain-file")?; 49 | let domain = to_idna(&domain)?; 50 | let ext = get_acme_value(cnf, "acme-ext", "acme-ext-file")?; 51 | let listen_addr = cnf.value_of("listen").unwrap_or(DEFAULT_LISTEN_ADDR); 52 | let (pk, cert) = X509Certificate::from_acme_ext(&domain, &ext)?; 53 | info!("Starting {} on {} for {}", APP_NAME, listen_addr, domain); 54 | server_start(listen_addr, &cert, &pk)?; 55 | Ok(()) 56 | } 57 | 58 | fn main() { 59 | let matches = App::new(APP_NAME) 60 | .version(APP_VERSION) 61 | .arg( 62 | Arg::with_name("listen") 63 | .long("listen") 64 | .short("l") 65 | .help("Specifies the host and port to listen on") 66 | .takes_value(true) 67 | .value_name("host:port|unix:path"), 68 | ) 69 | .arg( 70 | Arg::with_name("domain") 71 | .long("domain") 72 | .short("d") 73 | .help("The domain that is being validated") 74 | .takes_value(true) 75 | .value_name("STRING") 76 | .conflicts_with("domain-file") 77 | ) 78 | .arg( 79 | Arg::with_name("domain-file") 80 | .long("domain-file") 81 | .help("File from which is read the domain that is being validated") 82 | .takes_value(true) 83 | .value_name("FILE") 84 | .conflicts_with("domain") 85 | ) 86 | .arg( 87 | Arg::with_name("acme-ext") 88 | .long("acme-ext") 89 | .short("e") 90 | .help("The acmeIdentifier extension to set in the self-signed certificate") 91 | .takes_value(true) 92 | .value_name("STRING") 93 | .conflicts_with("acme-ext-file") 94 | ) 95 | .arg( 96 | Arg::with_name("acme-ext-file") 97 | .long("acme-ext-file") 98 | .help("File from which is read the acmeIdentifier extension to set in the self-signed certificate") 99 | .takes_value(true) 100 | .value_name("FILE") 101 | .conflicts_with("acme-ext") 102 | ) 103 | .arg( 104 | Arg::with_name("log-level") 105 | .long("log-level") 106 | .help("Specify the log level") 107 | .takes_value(true) 108 | .value_name("LEVEL") 109 | .possible_values(&["error", "warn", "info", "debug", "trace"]), 110 | ) 111 | .arg( 112 | Arg::with_name("to-syslog") 113 | .long("log-syslog") 114 | .help("Sends log messages via syslog") 115 | .conflicts_with("to-stderr"), 116 | ) 117 | .arg( 118 | Arg::with_name("to-stderr") 119 | .long("log-stderr") 120 | .help("Prints log messages to the standard error output") 121 | .conflicts_with("log-syslog"), 122 | ) 123 | .arg( 124 | Arg::with_name("foreground") 125 | .long("foreground") 126 | .short("f") 127 | .help("Runs in the foreground"), 128 | ) 129 | .arg( 130 | Arg::with_name("pid-file") 131 | .long("pid-file") 132 | .help("Specifies the location of the PID file") 133 | .takes_value(true) 134 | .value_name("FILE"), 135 | ) 136 | .get_matches(); 137 | 138 | match acme_common::logs::set_log_system( 139 | matches.value_of("log-level"), 140 | matches.is_present("log-syslog"), 141 | matches.is_present("to-stderr"), 142 | ) { 143 | Ok(_) => {} 144 | Err(e) => { 145 | eprintln!("Error: {}", e); 146 | std::process::exit(2); 147 | } 148 | }; 149 | 150 | match init(&matches) { 151 | Ok(_) => {} 152 | Err(e) => { 153 | error!("{}", e); 154 | let _ = clean_pid_file(matches.value_of("pid-file")); 155 | std::process::exit(1); 156 | } 157 | }; 158 | } 159 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | [//]: # (Copyright 2019-2020 Rodolphe Bréard ) 3 | 4 | [//]: # (Copying and distribution of this file, with or without modification,) 5 | [//]: # (are permitted in any medium without royalty provided the copyright) 6 | [//]: # (notice and this notice are preserved. This file is offered as-is,) 7 | [//]: # (without any warranty.) 8 | 9 | # Changelog 10 | All notable changes to this project will be documented in this file. 11 | 12 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 13 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 14 | 15 | 16 | ## [Unreleased] 17 | 18 | ### Added 19 | - System users and groups can now be specified by name in addition to uid/gid. 20 | 21 | ### Changed 22 | - The HTTP(S) part is now handled by `attohttpc` instead of `reqwest`. 23 | 24 | 25 | ## [0.8.0] - 2020-06-12 26 | 27 | ### Changed 28 | - The HTTP(S) part is now handled by `reqwest` instead of `http_req`. 29 | 30 | ## Fixed 31 | - `make install` now work with the busybox toolchain. 32 | 33 | 34 | ## [0.7.0] - 2020-03-12 35 | 36 | ### Added 37 | - Wildcard certificates are now supported. In the file name, the `*` is replaced by `_`. 38 | - Internationalized domain names are now supported. 39 | 40 | ### Changed 41 | - 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. 42 | 43 | ### Fixed 44 | - In the directory, the `externalAccountRequired` field is now a boolean instead of a string. 45 | 46 | 47 | ## [0.6.1] - 2019-09-13 48 | 49 | ### Fixed 50 | - A race condition when requesting multiple certificates on the same non-existent account has been fixed. 51 | - The `foregroung` option has been renamed `foreground`. 52 | 53 | 54 | ## [0.6.0] - 2019-06-05 55 | 56 | ### Added 57 | - Hooks now have the optional `allow_failure` field. 58 | - In hooks, the `stdin_str` has been added in replacement of the previous `stdin` behavior. 59 | - HTTPS request rate limits. 60 | 61 | ### Changed 62 | - Certificates are renewed in parallel. 63 | - Hooks are now cleaned right after the current challenge has been validated instead of after the certificate's retrieval. 64 | - In hooks, the `stdin` field now refers to the path of the file that should be written into the hook's standard input. 65 | - The logging format has been re-written. 66 | 67 | ### Fixed 68 | - The http-01-echo hook now correctly sets the file's access rights 69 | 70 | 71 | ## [0.5.0] - 2019-05-09 72 | 73 | ### Added 74 | - ACMEd now displays a warning when the server indicates an error in an order or an authorization. 75 | - A configuration file can now include several other files. 76 | - Hooks have access to environment variables. 77 | - In the configuration, the global section, certificates and domains can define environment variables for the hooks. 78 | - tacd is now able to listen on a unix socket. 79 | 80 | 81 | ## [0.4.0] - 2019-05-08 82 | 83 | ### Added 84 | - Man pages. 85 | - The project can now be built and installed using `make`. 86 | - The post-operation hooks now have access to the `is_success` template variable. 87 | - Challenge hooks now have the `is_clean_hook` template variable. 88 | - An existing certificate will be renewed if more domains have been added in the configuration. 89 | 90 | ### Changed 91 | - Unknown configuration fields are no longer tolerated. 92 | 93 | ### Removed 94 | - In challenge hooks, the `algorithm` template variable has been removed. 95 | 96 | ### Fixed 97 | - In some cases, ACMEd was unable to parse a certificate's expiration date. 98 | 99 | 100 | ## [0.3.0] - 2019-04-30 101 | 102 | ### Added 103 | - tacd, the TLS-ALPN-01 validation daemon. 104 | - An account object has been added in the configuration. 105 | - In the configuration, hooks now have a mandatory `type` variable. 106 | - It is now possible to declare hooks to clean after the challenge validation hooks. 107 | - The CLI `--root-cert` option has been added. 108 | - Failure recovery: HTTPS requests rejected by the server that are recoverable, like the badNonce error, are now retried several times before being considered a hard failure. 109 | - 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. 110 | 111 | ### Changed 112 | - In the configuration, the `email` certificate field has been replaced by the `account` field which matches an account object. 113 | - The format of the `domain` configuration variable has changed and now includes the challenge type. 114 | - The `token` challenge hook variable has been renamed `file_name`. 115 | - The `challenge_hooks`, `post_operation_hooks`, `file_pre_create_hooks`, `file_post_create_hooks`, `file_pre_edit_hooks` and `file_post_edit_hooks` certificate variables has been replaced by `hooks`. 116 | - The logs has been purged from many useless debug and trace entries. 117 | 118 | ### Removed 119 | - The DER storage format has been removed. 120 | - The `challenge` certificate variables has been removed. 121 | 122 | 123 | ## [0.2.1] - 2019-03-30 124 | 125 | ### Changed 126 | - The bug that prevented from requesting more than two certificates has been fixed. 127 | 128 | 129 | ## [0.2.0] - 2019-03-27 130 | 131 | ### Added 132 | - The `kp_reuse` flag allow to reuse a key pair instead of creating a new one at each renewal. 133 | - It is now possible to define hook groups that can reference either hooks or other hook groups. 134 | - 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`). 135 | - It is now possible to send logs either to syslog or stderr using the `--to-syslog` and `--to-stderr` arguments. 136 | 137 | ### Changed 138 | - `post_operation_hook` has been renamed `post_operation_hooks`. 139 | - By default, logs are now sent to syslog instead of stderr. 140 | - The process is now daemonized by default. It is possible to still run it in the foreground using the `--foregroung` flag. 141 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs/directory.rs: -------------------------------------------------------------------------------- 1 | use acme_common::error::Error; 2 | use serde::Deserialize; 3 | use std::str::FromStr; 4 | 5 | #[derive(Debug, Deserialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct DirectoryMeta { 8 | pub terms_of_service: Option, 9 | pub website: Option, 10 | pub caa_identities: Option>, 11 | pub external_account_required: Option, 12 | } 13 | 14 | #[derive(Debug, Deserialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct Directory { 17 | pub meta: Option, 18 | pub new_nonce: String, 19 | pub new_account: String, 20 | pub new_order: String, 21 | pub new_authz: Option, 22 | pub revoke_cert: String, 23 | pub key_change: String, 24 | } 25 | 26 | deserialize_from_str!(Directory); 27 | 28 | #[cfg(test)] 29 | mod tests { 30 | use super::Directory; 31 | use std::str::FromStr; 32 | 33 | #[test] 34 | fn test_directory() { 35 | let data = "{ 36 | \"newAccount\": \"https://example.org/acme/new-acct\", 37 | \"newNonce\": \"https://example.org/acme/new-nonce\", 38 | \"newOrder\": \"https://example.org/acme/new-order\", 39 | \"revokeCert\": \"https://example.org/acme/revoke-cert\", 40 | \"newAuthz\": \"https://example.org/acme/new-authz\", 41 | \"keyChange\": \"https://example.org/acme/key-change\" 42 | }"; 43 | let parsed_dir = Directory::from_str(data); 44 | assert!(parsed_dir.is_ok()); 45 | let parsed_dir = parsed_dir.unwrap(); 46 | assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); 47 | assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); 48 | assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); 49 | assert_eq!( 50 | parsed_dir.new_authz, 51 | Some("https://example.org/acme/new-authz".to_string()) 52 | ); 53 | assert_eq!( 54 | parsed_dir.revoke_cert, 55 | "https://example.org/acme/revoke-cert" 56 | ); 57 | assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); 58 | assert!(parsed_dir.meta.is_none()); 59 | } 60 | 61 | #[test] 62 | fn test_directory_no_authz() { 63 | let data = "{ 64 | \"newAccount\": \"https://example.org/acme/new-acct\", 65 | \"newNonce\": \"https://example.org/acme/new-nonce\", 66 | \"newOrder\": \"https://example.org/acme/new-order\", 67 | \"revokeCert\": \"https://example.org/acme/revoke-cert\", 68 | \"keyChange\": \"https://example.org/acme/key-change\" 69 | }"; 70 | let parsed_dir = Directory::from_str(data); 71 | assert!(parsed_dir.is_ok()); 72 | let parsed_dir = parsed_dir.unwrap(); 73 | assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); 74 | assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); 75 | assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); 76 | assert!(parsed_dir.new_authz.is_none()); 77 | assert_eq!( 78 | parsed_dir.revoke_cert, 79 | "https://example.org/acme/revoke-cert" 80 | ); 81 | assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); 82 | assert!(parsed_dir.meta.is_none()); 83 | } 84 | 85 | #[test] 86 | fn test_directory_meta() { 87 | let data = "{ 88 | \"keyChange\": \"https://example.org/acme/key-change\", 89 | \"meta\": { 90 | \"caaIdentities\": [ 91 | \"example.org\" 92 | ], 93 | \"termsOfService\": \"https://example.org/documents/tos.pdf\", 94 | \"website\": \"https://example.org/\" 95 | }, 96 | \"newAccount\": \"https://example.org/acme/new-acct\", 97 | \"newNonce\": \"https://example.org/acme/new-nonce\", 98 | \"newOrder\": \"https://example.org/acme/new-order\", 99 | \"revokeCert\": \"https://example.org/acme/revoke-cert\" 100 | }"; 101 | let parsed_dir = Directory::from_str(&data); 102 | assert!(parsed_dir.is_ok()); 103 | let parsed_dir = parsed_dir.unwrap(); 104 | assert!(parsed_dir.meta.is_some()); 105 | let meta = parsed_dir.meta.unwrap(); 106 | assert_eq!( 107 | meta.terms_of_service, 108 | Some("https://example.org/documents/tos.pdf".to_string()) 109 | ); 110 | assert_eq!(meta.website, Some("https://example.org/".to_string())); 111 | assert!(meta.caa_identities.is_some()); 112 | let caa_identities = meta.caa_identities.unwrap(); 113 | assert_eq!(caa_identities.len(), 1); 114 | assert_eq!(caa_identities.first(), Some(&"example.org".to_string())); 115 | assert!(meta.external_account_required.is_none()); 116 | } 117 | 118 | #[test] 119 | fn test_directory_extra_fields() { 120 | let data = "{ 121 | \"foo\": \"bar\", 122 | \"keyChange\": \"https://example.org/acme/key-change\", 123 | \"newAccount\": \"https://example.org/acme/new-acct\", 124 | \"baz\": \"quz\", 125 | \"newNonce\": \"https://example.org/acme/new-nonce\", 126 | \"newAuthz\": \"https://example.org/acme/new-authz\", 127 | \"newOrder\": \"https://example.org/acme/new-order\", 128 | \"revokeCert\": \"https://example.org/acme/revoke-cert\" 129 | }"; 130 | let parsed_dir = Directory::from_str(&data); 131 | assert!(parsed_dir.is_ok()); 132 | let parsed_dir = parsed_dir.unwrap(); 133 | assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); 134 | assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); 135 | assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); 136 | assert_eq!( 137 | parsed_dir.new_authz, 138 | Some("https://example.org/acme/new-authz".to_string()) 139 | ); 140 | assert_eq!( 141 | parsed_dir.revoke_cert, 142 | "https://example.org/acme/revoke-cert" 143 | ); 144 | assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); 145 | assert!(parsed_dir.meta.is_none()); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /acmed/src/http.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::structs::HttpApiError; 2 | use crate::endpoint::Endpoint; 3 | use acme_common::crypto::X509Certificate; 4 | use acme_common::error::Error; 5 | use attohttpc::{charsets, header, Response, Session}; 6 | use std::fs::File; 7 | use std::io::prelude::*; 8 | use std::{thread, time}; 9 | 10 | pub const CONTENT_TYPE_JOSE: &str = "application/jose+json"; 11 | pub const CONTENT_TYPE_JSON: &str = "application/json"; 12 | pub const CONTENT_TYPE_PEM: &str = "application/pem-certificate-chain"; 13 | pub const HEADER_NONCE: &str = "Replay-Nonce"; 14 | pub const HEADER_LOCATION: &str = "Location"; 15 | 16 | fn is_nonce(data: &str) -> bool { 17 | !data.is_empty() 18 | && data 19 | .bytes() 20 | .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') 21 | } 22 | 23 | fn new_nonce(endpoint: &mut Endpoint, root_certs: &[String]) -> Result<(), Error> { 24 | rate_limit(endpoint); 25 | let url = endpoint.dir.new_nonce.clone(); 26 | let _ = get(endpoint, root_certs, &url)?; 27 | Ok(()) 28 | } 29 | 30 | fn update_nonce(endpoint: &mut Endpoint, response: &Response) -> Result<(), Error> { 31 | if let Some(nonce) = response.headers().get(HEADER_NONCE) { 32 | let nonce = header_to_string(&nonce)?; 33 | if !is_nonce(&nonce) { 34 | let msg = format!("{}: invalid nonce.", &nonce); 35 | return Err(msg.into()); 36 | } 37 | endpoint.nonce = Some(nonce); 38 | } 39 | Ok(()) 40 | } 41 | 42 | fn check_status(response: &Response) -> Result<(), Error> { 43 | if !response.is_success() { 44 | let status = response.status(); 45 | let msg = format!("HTTP error: {}: {}", status.as_u16(), status.as_str()); 46 | return Err(msg.into()); 47 | } 48 | Ok(()) 49 | } 50 | 51 | fn rate_limit(endpoint: &mut Endpoint) { 52 | endpoint.rl.block_until_allowed(); 53 | } 54 | 55 | pub fn header_to_string(header_value: &header::HeaderValue) -> Result { 56 | let s = header_value 57 | .to_str() 58 | .map_err(|_| Error::from("Invalid nonce format."))?; 59 | Ok(s.to_string()) 60 | } 61 | 62 | fn get_session(root_certs: &[String]) -> Result { 63 | let useragent = format!( 64 | "{}/{} ({}) {}", 65 | crate::APP_NAME, 66 | crate::APP_VERSION, 67 | env!("ACMED_TARGET"), 68 | env!("ACMED_HTTP_LIB_AGENT") 69 | ); 70 | // TODO: allow to change the language 71 | let mut session = Session::new(); 72 | session.default_charset(Some(charsets::UTF_8)); 73 | session.try_header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.5")?; 74 | session.try_header(header::USER_AGENT, &useragent)?; 75 | for crt_file in root_certs.iter() { 76 | let mut buff = Vec::new(); 77 | File::open(crt_file)?.read_to_end(&mut buff)?; 78 | let crt = X509Certificate::from_pem_native(&buff)?; 79 | session.add_root_certificate(crt); 80 | } 81 | Ok(session) 82 | } 83 | 84 | pub fn get(endpoint: &mut Endpoint, root_certs: &[String], url: &str) -> Result { 85 | let mut session = get_session(root_certs)?; 86 | session.try_header(header::ACCEPT, CONTENT_TYPE_JSON)?; 87 | rate_limit(endpoint); 88 | let response = session.get(url).send()?; 89 | update_nonce(endpoint, &response)?; 90 | check_status(&response)?; 91 | Ok(response) 92 | } 93 | 94 | pub fn post( 95 | endpoint: &mut Endpoint, 96 | root_certs: &[String], 97 | url: &str, 98 | data_builder: &F, 99 | content_type: &str, 100 | accept: &str, 101 | ) -> Result 102 | where 103 | F: Fn(&str, &str) -> Result, 104 | { 105 | let mut session = get_session(root_certs)?; 106 | session.try_header(header::ACCEPT, accept)?; 107 | session.try_header(header::CONTENT_TYPE, content_type)?; 108 | if endpoint.nonce.is_none() { 109 | let _ = new_nonce(endpoint, root_certs); 110 | } 111 | for _ in 0..crate::DEFAULT_HTTP_FAIL_NB_RETRY { 112 | let nonce = &endpoint.nonce.clone().unwrap(); 113 | let body = data_builder(&nonce, url)?; 114 | rate_limit(endpoint); 115 | let response = session.post(url).text(&body).send()?; 116 | update_nonce(endpoint, &response)?; 117 | match check_status(&response) { 118 | Ok(_) => { 119 | return Ok(response); 120 | } 121 | Err(e) => { 122 | let api_err = response.json::()?; 123 | let acme_err = api_err.get_acme_type(); 124 | if !acme_err.is_recoverable() { 125 | return Err(e); 126 | } 127 | } 128 | } 129 | thread::sleep(time::Duration::from_secs(crate::DEFAULT_HTTP_FAIL_WAIT_SEC)); 130 | } 131 | Err("Too much errors, will not retry".into()) 132 | } 133 | 134 | pub fn post_jose( 135 | endpoint: &mut Endpoint, 136 | root_certs: &[String], 137 | url: &str, 138 | data_builder: &F, 139 | ) -> Result 140 | where 141 | F: Fn(&str, &str) -> Result, 142 | { 143 | post( 144 | endpoint, 145 | root_certs, 146 | url, 147 | data_builder, 148 | CONTENT_TYPE_JOSE, 149 | CONTENT_TYPE_JSON, 150 | ) 151 | } 152 | 153 | #[cfg(test)] 154 | mod tests { 155 | use super::is_nonce; 156 | 157 | #[test] 158 | fn test_nonce_valid() { 159 | let lst = [ 160 | "XFHw3qcgFNZAdw", 161 | "XFHw3qcg-NZAdw", 162 | "XFHw3qcg_NZAdw", 163 | "XFHw3qcg-_ZAdw", 164 | "a", 165 | "1", 166 | "-", 167 | "_", 168 | ]; 169 | for n in lst.iter() { 170 | assert!(is_nonce(n)); 171 | } 172 | } 173 | 174 | #[test] 175 | fn test_nonce_invalid() { 176 | let lst = [ 177 | "", 178 | "rdo9x8gS4K/mZg==", 179 | "rdo9x8gS4K/mZg", 180 | "rdo9x8gS4K+mZg", 181 | "৬", 182 | "京", 183 | ]; 184 | for n in lst.iter() { 185 | assert!(!is_nonce(n)); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /acme_common/src/crypto/openssl_keys.rs: -------------------------------------------------------------------------------- 1 | use crate::b64_encode; 2 | use crate::crypto::KeyType; 3 | use crate::error::Error; 4 | use openssl::bn::{BigNum, BigNumContext}; 5 | use openssl::ec::{Asn1Flag, EcGroup, EcKey}; 6 | use openssl::ecdsa::EcdsaSig; 7 | use openssl::nid::Nid; 8 | use openssl::pkey::{Id, PKey, Private}; 9 | use openssl::rsa::Rsa; 10 | use serde_json::json; 11 | use serde_json::value::Value; 12 | 13 | macro_rules! get_key_type { 14 | ($key: expr) => { 15 | match $key.id() { 16 | Id::RSA => match $key.rsa()?.size() { 17 | 2048 => KeyType::Rsa2048, 18 | 4096 => KeyType::Rsa4096, 19 | s => { 20 | return Err(format!("{}: unsupported RSA key size", s).into()); 21 | } 22 | }, 23 | Id::EC => match $key.ec_key()?.group().curve_name() { 24 | Some(Nid::X9_62_PRIME256V1) => KeyType::EcdsaP256, 25 | Some(Nid::SECP384R1) => KeyType::EcdsaP384, 26 | Some(nid) => { 27 | return Err(format!("{:?}: Unsupported EC key.", nid).into()); 28 | } 29 | None => { 30 | return Err("None: Unsupported EC key".into()); 31 | } 32 | }, 33 | _ => { 34 | return Err("Unsupported key type".into()); 35 | } 36 | } 37 | }; 38 | } 39 | 40 | pub struct KeyPair { 41 | pub key_type: KeyType, 42 | pub inner_key: PKey, 43 | } 44 | 45 | impl KeyPair { 46 | pub fn from_pem(pem_data: &[u8]) -> Result { 47 | let inner_key = PKey::private_key_from_pem(pem_data)?; 48 | let key_type = get_key_type!(inner_key); 49 | Ok(KeyPair { 50 | key_type, 51 | inner_key, 52 | }) 53 | } 54 | 55 | pub fn private_key_to_pem(&self) -> Result, Error> { 56 | self.inner_key 57 | .private_key_to_pem_pkcs8() 58 | .map_err(Error::from) 59 | } 60 | 61 | pub fn public_key_to_pem(&self) -> Result, Error> { 62 | self.inner_key.public_key_to_pem().map_err(Error::from) 63 | } 64 | 65 | pub fn sign(&self, data: &[u8]) -> Result, Error> { 66 | match self.key_type { 67 | KeyType::Curve25519 => Err("Curve25519 signatures are not implemented yet".into()), 68 | KeyType::EcdsaP256 | KeyType::EcdsaP384 => { 69 | let signature = EcdsaSig::sign(data, self.inner_key.ec_key()?.as_ref())?; 70 | let r = signature.r().to_vec(); 71 | let mut s = signature.s().to_vec(); 72 | let mut signature = r; 73 | signature.append(&mut s); 74 | Ok(signature) 75 | } 76 | KeyType::Rsa2048 | KeyType::Rsa4096 => { 77 | // TODO: implement RSA signatures 78 | Err("RSA signatures are not implemented yet".into()) 79 | } 80 | } 81 | } 82 | 83 | pub fn jwk_public_key(&self) -> Result { 84 | self.get_jwk_public_key(false) 85 | } 86 | 87 | pub fn jwk_public_key_thumbprint(&self) -> Result { 88 | self.get_jwk_public_key(true) 89 | } 90 | 91 | fn get_jwk_public_key(&self, thumbprint: bool) -> Result { 92 | match self.key_type { 93 | KeyType::Curve25519 => Err("Curve25519 thumbprint are not implemented yet".into()), 94 | KeyType::EcdsaP256 | KeyType::EcdsaP384 => self.get_nist_ec_jwk(thumbprint), 95 | KeyType::Rsa2048 | KeyType::Rsa4096 => { 96 | Err("RSA jwk thumbprint are not implemented yet".into()) 97 | } 98 | } 99 | } 100 | 101 | fn get_nist_ec_jwk(&self, thumbprint: bool) -> Result { 102 | let (crv, curve) = match self.key_type { 103 | KeyType::EcdsaP256 => ("P-256", Nid::X9_62_PRIME256V1), 104 | KeyType::EcdsaP384 => ("P-384", Nid::SECP384R1), 105 | _ => { 106 | return Err("Not a NIST elliptic curve.".into()); 107 | } 108 | }; 109 | let group = EcGroup::from_curve_name(curve).unwrap(); 110 | let mut ctx = BigNumContext::new().unwrap(); 111 | let mut x = BigNum::new().unwrap(); 112 | let mut y = BigNum::new().unwrap(); 113 | self.inner_key 114 | .ec_key() 115 | .unwrap() 116 | .public_key() 117 | .affine_coordinates_gfp(&group, &mut x, &mut y, &mut ctx)?; 118 | let x = b64_encode(&x.to_vec()); 119 | let y = b64_encode(&y.to_vec()); 120 | let jwk = if thumbprint { 121 | json!({ 122 | "crv": crv, 123 | "kty": "EC", 124 | "x": x, 125 | "y": y, 126 | }) 127 | } else { 128 | json!({ 129 | "alg": "ES256", 130 | "crv": crv, 131 | "kty": "EC", 132 | "use": "sig", 133 | "x": x, 134 | "y": y, 135 | }) 136 | }; 137 | Ok(jwk) 138 | } 139 | } 140 | 141 | fn gen_rsa_pair(nb_bits: u32) -> Result, Error> { 142 | // TODO: check if map_err is required 143 | let priv_key = Rsa::generate(nb_bits).map_err(|_| Error::from(""))?; 144 | let pk = PKey::from_rsa(priv_key).map_err(|_| Error::from(""))?; 145 | Ok(pk) 146 | } 147 | 148 | fn gen_ec_pair(nid: Nid) -> Result, Error> { 149 | // TODO: check if map_err is required 150 | let mut group = EcGroup::from_curve_name(nid).map_err(|_| Error::from(""))?; 151 | 152 | // Use NAMED_CURVE format; OpenSSL 1.0.1 and 1.0.2 default to EXPLICIT_CURVE which won't work (see #9) 153 | group.set_asn1_flag(Asn1Flag::NAMED_CURVE); 154 | 155 | let ec_priv_key = EcKey::generate(&group).map_err(|_| Error::from(""))?; 156 | let pk = PKey::from_ec_key(ec_priv_key).map_err(|_| Error::from(""))?; 157 | Ok(pk) 158 | } 159 | 160 | pub fn gen_keypair(key_type: KeyType) -> Result { 161 | let priv_key = match key_type { 162 | KeyType::Curve25519 => Err(Error::from("")), 163 | KeyType::EcdsaP256 => gen_ec_pair(Nid::X9_62_PRIME256V1), 164 | KeyType::EcdsaP384 => gen_ec_pair(Nid::SECP384R1), 165 | KeyType::Rsa2048 => gen_rsa_pair(2048), 166 | KeyType::Rsa4096 => gen_rsa_pair(4096), 167 | } 168 | .map_err(|_| Error::from(format!("Unable to generate a {} key pair.", key_type)))?; 169 | let key_pair = KeyPair { 170 | key_type, 171 | inner_key: priv_key, 172 | }; 173 | Ok(key_pair) 174 | } 175 | -------------------------------------------------------------------------------- /acmed/src/hooks.rs: -------------------------------------------------------------------------------- 1 | use crate::certificate::Certificate; 2 | pub use crate::config::HookType; 3 | use acme_common::error::Error; 4 | use handlebars::Handlebars; 5 | use serde::Serialize; 6 | use std::collections::hash_map::Iter; 7 | use std::collections::HashMap; 8 | use std::fs::File; 9 | use std::io::prelude::*; 10 | use std::io::BufReader; 11 | use std::path::PathBuf; 12 | use std::process::{Command, Stdio}; 13 | use std::{env, fmt}; 14 | 15 | pub trait HookEnvData { 16 | fn set_env(&mut self, env: &HashMap); 17 | fn get_env(&self) -> Iter; 18 | } 19 | 20 | fn deref(t: (&F, &G)) -> (F, G) 21 | where 22 | F: Clone, 23 | G: Clone, 24 | { 25 | ((*(t.0)).to_owned(), (*(t.1)).to_owned()) 26 | } 27 | 28 | macro_rules! imple_hook_data_env { 29 | ($t: ty) => { 30 | impl HookEnvData for $t { 31 | fn set_env(&mut self, env: &HashMap) { 32 | for (key, value) in env::vars().chain(env.iter().map(deref)) { 33 | self.env.insert(key, value); 34 | } 35 | } 36 | 37 | fn get_env(&self) -> Iter { 38 | self.env.iter() 39 | } 40 | } 41 | }; 42 | } 43 | 44 | #[derive(Clone, Serialize)] 45 | pub struct PostOperationHookData { 46 | pub domains: Vec, 47 | pub algorithm: String, 48 | pub status: String, 49 | pub is_success: bool, 50 | pub env: HashMap, 51 | } 52 | 53 | imple_hook_data_env!(PostOperationHookData); 54 | 55 | #[derive(Clone, Serialize)] 56 | pub struct ChallengeHookData { 57 | pub domain: String, 58 | pub challenge: String, 59 | pub file_name: String, 60 | pub proof: String, 61 | pub is_clean_hook: bool, 62 | pub env: HashMap, 63 | } 64 | 65 | imple_hook_data_env!(ChallengeHookData); 66 | 67 | #[derive(Clone, Serialize)] 68 | pub struct FileStorageHookData { 69 | // TODO: add the current operation (create/edit) 70 | pub file_name: String, 71 | pub file_directory: String, 72 | pub file_path: PathBuf, 73 | pub env: HashMap, 74 | } 75 | 76 | imple_hook_data_env!(FileStorageHookData); 77 | 78 | #[derive(Clone, Debug)] 79 | pub enum HookStdin { 80 | File(String), 81 | Str(String), 82 | None, 83 | } 84 | 85 | #[derive(Clone, Debug)] 86 | pub struct Hook { 87 | pub name: String, 88 | pub hook_type: Vec, 89 | pub cmd: String, 90 | pub args: Option>, 91 | pub stdin: HookStdin, 92 | pub stdout: Option, 93 | pub stderr: Option, 94 | pub allow_failure: bool, 95 | } 96 | 97 | impl fmt::Display for Hook { 98 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 99 | write!(f, "{}", self.name) 100 | } 101 | } 102 | 103 | macro_rules! get_hook_output { 104 | ($cert: expr, $out: expr, $reg: ident, $data: expr, $hook_name: expr, $out_name: expr) => {{ 105 | match $out { 106 | Some(path) => { 107 | let path = $reg.render_template(path, $data)?; 108 | $cert.trace(&format!("Hook {}: {}: {}", $hook_name, $out_name, &path)); 109 | let file = File::create(&path)?; 110 | Stdio::from(file) 111 | } 112 | None => Stdio::null(), 113 | } 114 | }}; 115 | } 116 | 117 | fn call_single(cert: &Certificate, data: &T, hook: &Hook) -> Result<(), Error> 118 | where 119 | T: Clone + HookEnvData + Serialize, 120 | { 121 | cert.debug(&format!("Calling hook: {}", hook.name)); 122 | let reg = Handlebars::new(); 123 | let mut v = vec![]; 124 | let args = match &hook.args { 125 | Some(lst) => { 126 | for fmt in lst.iter() { 127 | let s = reg.render_template(fmt, &data)?; 128 | v.push(s); 129 | } 130 | v.as_slice() 131 | } 132 | None => &[], 133 | }; 134 | cert.trace(&format!("Hook {}: cmd: {}", hook.name, hook.cmd)); 135 | cert.trace(&format!("Hook {}: args: {:?}", hook.name, args)); 136 | let mut cmd = Command::new(&hook.cmd) 137 | .envs(data.get_env()) 138 | .args(args) 139 | .stdout(get_hook_output!( 140 | cert, 141 | &hook.stdout, 142 | reg, 143 | &data, 144 | &hook.name, 145 | "stdout" 146 | )) 147 | .stderr(get_hook_output!( 148 | cert, 149 | &hook.stderr, 150 | reg, 151 | &data, 152 | &hook.name, 153 | "stderr" 154 | )) 155 | .stdin(match &hook.stdin { 156 | HookStdin::Str(_) | HookStdin::File(_) => Stdio::piped(), 157 | HookStdin::None => Stdio::null(), 158 | }) 159 | .spawn()?; 160 | match &hook.stdin { 161 | HookStdin::Str(s) => { 162 | let data_in = reg.render_template(&s, &data)?; 163 | cert.trace(&format!("Hook {}: string stdin: {}", hook.name, &data_in)); 164 | let stdin = cmd.stdin.as_mut().ok_or("stdin not found")?; 165 | stdin.write_all(data_in.as_bytes())?; 166 | } 167 | HookStdin::File(f) => { 168 | let file_name = reg.render_template(&f, &data)?; 169 | cert.trace(&format!("Hook {}: file stdin: {}", hook.name, &file_name)); 170 | let stdin = cmd.stdin.as_mut().ok_or("stdin not found")?; 171 | let file = File::open(&file_name)?; 172 | let buf_reader = BufReader::new(file); 173 | for line in buf_reader.lines() { 174 | let line = format!("{}\n", line?); 175 | stdin.write_all(line.as_bytes())?; 176 | } 177 | } 178 | HookStdin::None => {} 179 | } 180 | // TODO: add a timeout 181 | let status = cmd.wait()?; 182 | if !status.success() && !hook.allow_failure { 183 | let msg = match status.code() { 184 | Some(code) => format!("Unrecoverable failure: code {}", code).into(), 185 | None => "Unrecoverable failure".into(), 186 | }; 187 | return Err(msg); 188 | } 189 | match status.code() { 190 | Some(code) => cert.debug(&format!("Hook {}: exited: code {}", hook.name, code)), 191 | None => cert.debug(&format!("Hook {}: exited", hook.name)), 192 | }; 193 | Ok(()) 194 | } 195 | 196 | pub fn call(cert: &Certificate, data: &T, hook_type: HookType) -> Result<(), Error> 197 | where 198 | T: Clone + HookEnvData + Serialize, 199 | { 200 | for hook in cert 201 | .hooks 202 | .iter() 203 | .filter(|h| h.hook_type.contains(&hook_type)) 204 | { 205 | call_single(cert, data, &hook).map_err(|e| e.prefix(&hook.name))?; 206 | } 207 | Ok(()) 208 | } 209 | -------------------------------------------------------------------------------- /acmed/src/acme_proto.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::account::AccountManager; 2 | use crate::acme_proto::structs::{ 3 | ApiError, Authorization, AuthorizationStatus, NewOrder, Order, OrderStatus, 4 | }; 5 | use crate::certificate::Certificate; 6 | use crate::endpoint::Endpoint; 7 | use crate::jws::encode_kid; 8 | use crate::storage; 9 | use acme_common::crypto::Csr; 10 | use acme_common::error::Error; 11 | use serde_json::json; 12 | use std::fmt; 13 | 14 | pub mod account; 15 | mod certificate; 16 | mod http; 17 | pub mod structs; 18 | 19 | #[derive(Clone, Debug, PartialEq)] 20 | pub enum Challenge { 21 | Http01, 22 | Dns01, 23 | TlsAlpn01, 24 | } 25 | 26 | impl Challenge { 27 | pub fn from_str(s: &str) -> Result { 28 | match s.to_lowercase().as_str() { 29 | "http-01" => Ok(Challenge::Http01), 30 | "dns-01" => Ok(Challenge::Dns01), 31 | "tls-alpn-01" => Ok(Challenge::TlsAlpn01), 32 | _ => Err(format!("{}: unknown challenge.", s).into()), 33 | } 34 | } 35 | } 36 | 37 | impl fmt::Display for Challenge { 38 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 39 | let s = match self { 40 | Challenge::Http01 => "http-01", 41 | Challenge::Dns01 => "dns-01", 42 | Challenge::TlsAlpn01 => "tls-alpn-01", 43 | }; 44 | write!(f, "{}", s) 45 | } 46 | } 47 | 48 | impl PartialEq for Challenge { 49 | fn eq(&self, other: &structs::Challenge) -> bool { 50 | match (self, other) { 51 | (Challenge::Http01, structs::Challenge::Http01(_)) => true, 52 | (Challenge::Dns01, structs::Challenge::Dns01(_)) => true, 53 | (Challenge::TlsAlpn01, structs::Challenge::TlsAlpn01(_)) => true, 54 | _ => false, 55 | } 56 | } 57 | } 58 | 59 | macro_rules! set_data_builder { 60 | ($account: ident, $data: expr) => { 61 | |n: &str, url: &str| encode_kid(&$account.key_pair, &$account.account_url, $data, url, n) 62 | }; 63 | } 64 | macro_rules! set_empty_data_builder { 65 | ($account: ident) => { 66 | set_data_builder!($account, b"") 67 | }; 68 | } 69 | 70 | pub fn request_certificate( 71 | cert: &Certificate, 72 | root_certs: &[String], 73 | endpoint: &mut Endpoint, 74 | ) -> Result<(), Error> { 75 | let domains = cert 76 | .domains 77 | .iter() 78 | .map(|d| d.dns.to_owned()) 79 | .collect::>(); 80 | let mut hook_datas = vec![]; 81 | 82 | // Refresh the directory 83 | http::refresh_directory(endpoint, root_certs)?; 84 | 85 | // Get or create the account 86 | let account = AccountManager::new(endpoint, root_certs, cert)?; 87 | 88 | // Create a new order 89 | let new_order = NewOrder::new(&domains); 90 | let new_order = serde_json::to_string(&new_order)?; 91 | let data_builder = set_data_builder!(account, new_order.as_bytes()); 92 | let (order, order_url) = http::new_order(endpoint, root_certs, &data_builder)?; 93 | if let Some(e) = order.get_error() { 94 | cert.warn(&e.prefix("Error").message); 95 | } 96 | 97 | // Begin iter over authorizations 98 | for auth_url in order.authorizations.iter() { 99 | // Fetch the authorization 100 | let data_builder = set_empty_data_builder!(account); 101 | let auth = http::get_authorization(endpoint, root_certs, &data_builder, &auth_url)?; 102 | if let Some(e) = auth.get_error() { 103 | cert.warn(&e.prefix("Error").message); 104 | } 105 | if auth.status == AuthorizationStatus::Valid { 106 | continue; 107 | } 108 | if auth.status != AuthorizationStatus::Pending { 109 | let msg = format!( 110 | "{}: authorization status is {}", 111 | auth.identifier, auth.status 112 | ); 113 | return Err(msg.into()); 114 | } 115 | 116 | // Fetch the associated challenges 117 | let current_challenge = cert.get_domain_challenge(&auth.identifier.value)?; 118 | for challenge in auth.challenges.iter() { 119 | if current_challenge == *challenge { 120 | let proof = challenge.get_proof(&account.key_pair)?; 121 | let file_name = challenge.get_file_name(); 122 | let domain = auth.identifier.value.to_owned(); 123 | 124 | // Call the challenge hook in order to complete it 125 | let mut data = cert.call_challenge_hooks(&file_name, &proof, &domain)?; 126 | data.0.is_clean_hook = true; 127 | hook_datas.push(data); 128 | 129 | // Tell the server the challenge has been completed 130 | let chall_url = challenge.get_url(); 131 | let data_builder = set_data_builder!(account, b"{}"); 132 | let _ = 133 | http::post_challenge_response(endpoint, root_certs, &data_builder, &chall_url)?; 134 | } 135 | } 136 | 137 | // Pool the authorization in order to see whether or not it is valid 138 | let data_builder = set_empty_data_builder!(account); 139 | let break_fn = |a: &Authorization| a.status == AuthorizationStatus::Valid; 140 | let _ = 141 | http::pool_authorization(endpoint, root_certs, &data_builder, &break_fn, &auth_url)?; 142 | for (data, hook_type) in hook_datas.iter() { 143 | cert.call_challenge_hooks_clean(&data, (*hook_type).to_owned())?; 144 | } 145 | hook_datas.clear(); 146 | } 147 | // End iter over authorizations 148 | 149 | // Pool the order in order to see whether or not it is ready 150 | let data_builder = set_empty_data_builder!(account); 151 | let break_fn = |o: &Order| o.status == OrderStatus::Ready; 152 | let order = http::pool_order(endpoint, root_certs, &data_builder, &break_fn, &order_url)?; 153 | 154 | // Finalize the order by sending the CSR 155 | let key_pair = certificate::get_key_pair(cert)?; 156 | let domains: Vec = cert.domains.iter().map(|e| e.dns.to_owned()).collect(); 157 | let csr = json!({ 158 | "csr": Csr::new(&key_pair, domains.as_slice())?.to_der_base64()?, 159 | }); 160 | let csr = csr.to_string(); 161 | let data_builder = set_data_builder!(account, csr.as_bytes()); 162 | let order = http::finalize_order(endpoint, root_certs, &data_builder, &order.finalize)?; 163 | if let Some(e) = order.get_error() { 164 | cert.warn(&e.prefix("Error").message); 165 | } 166 | 167 | // Pool the order in order to see whether or not it is valid 168 | let data_builder = set_empty_data_builder!(account); 169 | let break_fn = |o: &Order| o.status == OrderStatus::Valid; 170 | let order = http::pool_order(endpoint, root_certs, &data_builder, &break_fn, &order_url)?; 171 | 172 | // Download the certificate 173 | let crt_url = order 174 | .certificate 175 | .ok_or_else(|| Error::from("No certificate available for download."))?; 176 | let data_builder = set_empty_data_builder!(account); 177 | let crt = http::get_certificate(endpoint, root_certs, &data_builder, &crt_url)?; 178 | storage::write_certificate(cert, &crt.as_bytes())?; 179 | 180 | cert.info(&format!( 181 | "Certificate renewed (domains: {})", 182 | cert.domain_list() 183 | )); 184 | Ok(()) 185 | } 186 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs/error.rs: -------------------------------------------------------------------------------- 1 | use acme_common::error::Error; 2 | use serde::Deserialize; 3 | use std::fmt; 4 | use std::str::FromStr; 5 | 6 | pub trait ApiError { 7 | fn get_error(&self) -> Option; 8 | } 9 | 10 | #[derive(PartialEq)] 11 | pub enum AcmeError { 12 | AccountDoesNotExist, 13 | AlreadyRevoked, 14 | BadCSR, 15 | BadNonce, 16 | BadPublicKey, 17 | BadRevocationReason, 18 | BadSignatureAlgorithm, 19 | Caa, 20 | Compound, 21 | Connection, 22 | Dns, 23 | ExternalAccountRequired, 24 | IncorrectResponse, 25 | InvalidContact, 26 | Malformed, 27 | OrderNotReady, 28 | RateLimited, 29 | RejectedIdentifier, 30 | ServerInternal, 31 | Tls, 32 | Unauthorized, 33 | UnsupportedContact, 34 | UnsupportedIdentifier, 35 | UserActionRequired, 36 | Unknown, 37 | } 38 | 39 | impl From for AcmeError { 40 | fn from(error: String) -> Self { 41 | match error.as_str() { 42 | "urn:ietf:params:acme:error:accountDoesNotExist" => AcmeError::AccountDoesNotExist, 43 | "urn:ietf:params:acme:error:alreadyRevoked" => AcmeError::AlreadyRevoked, 44 | "urn:ietf:params:acme:error:badCSR" => AcmeError::BadCSR, 45 | "urn:ietf:params:acme:error:badNonce" => AcmeError::BadNonce, 46 | "urn:ietf:params:acme:error:badPublicKey" => AcmeError::BadPublicKey, 47 | "urn:ietf:params:acme:error:badRevocationReason" => AcmeError::BadRevocationReason, 48 | "urn:ietf:params:acme:error:badSignatureAlgorithm" => AcmeError::BadSignatureAlgorithm, 49 | "urn:ietf:params:acme:error:caa" => AcmeError::Caa, 50 | "urn:ietf:params:acme:error:compound" => AcmeError::Compound, 51 | "urn:ietf:params:acme:error:connection" => AcmeError::Connection, 52 | "urn:ietf:params:acme:error:dns" => AcmeError::Dns, 53 | "urn:ietf:params:acme:error:externalAccountRequired" => { 54 | AcmeError::ExternalAccountRequired 55 | } 56 | "urn:ietf:params:acme:error:incorrectResponse" => AcmeError::IncorrectResponse, 57 | "urn:ietf:params:acme:error:invalidContact" => AcmeError::InvalidContact, 58 | "urn:ietf:params:acme:error:malformed" => AcmeError::Malformed, 59 | "urn:ietf:params:acme:error:orderNotReady" => AcmeError::OrderNotReady, 60 | "urn:ietf:params:acme:error:rateLimited" => AcmeError::RateLimited, 61 | "urn:ietf:params:acme:error:rejectedIdentifier" => AcmeError::RejectedIdentifier, 62 | "urn:ietf:params:acme:error:serverInternal" => AcmeError::ServerInternal, 63 | "urn:ietf:params:acme:error:tls" => AcmeError::Tls, 64 | "urn:ietf:params:acme:error:unauthorized" => AcmeError::Unauthorized, 65 | "urn:ietf:params:acme:error:unsupportedContact" => AcmeError::UnsupportedContact, 66 | "urn:ietf:params:acme:error:unsupportedIdentifier" => AcmeError::UnsupportedIdentifier, 67 | "urn:ietf:params:acme:error:userActionRequired" => AcmeError::UserActionRequired, 68 | _ => AcmeError::Unknown, 69 | } 70 | } 71 | } 72 | 73 | impl fmt::Display for AcmeError { 74 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 75 | let msg = match self { 76 | AcmeError::AccountDoesNotExist => "The request specified an account that does not exist", 77 | AcmeError::AlreadyRevoked => "The request specified a certificate to be revoked that has already been revoked", 78 | AcmeError::BadCSR => "The CSR is unacceptable (e.g., due to a short key)", 79 | AcmeError::BadNonce => "The client sent an unacceptable anti-replay nonce", 80 | AcmeError::BadPublicKey => "The JWS was signed by a public key the server does not support", 81 | AcmeError::BadRevocationReason => "The revocation reason provided is not allowed by the server", 82 | AcmeError::BadSignatureAlgorithm => "The JWS was signed with an algorithm the server does not support", 83 | AcmeError::Caa => "Certification Authority Authorization (CAA) records forbid the CA from issuing a certificate", 84 | AcmeError::Compound => "Specific error conditions are indicated in the \"subproblems\" array", 85 | AcmeError::Connection => "The server could not connect to validation target", 86 | AcmeError::Dns => "There was a problem with a DNS query during identifier validation", 87 | AcmeError::ExternalAccountRequired => "The request must include a value for the \"externalAccountBinding\" field", 88 | AcmeError::IncorrectResponse => "Response received didn't match the challenge's requirements", 89 | AcmeError::InvalidContact => "A contact URL for an account was invalid", 90 | AcmeError::Malformed => "The request message was malformed", 91 | AcmeError::OrderNotReady => "The request attempted to finalize an order that is not ready to be finalized", 92 | AcmeError::RateLimited => "The request exceeds a rate limit", 93 | AcmeError::RejectedIdentifier => "The server will not issue certificates for the identifier", 94 | AcmeError::ServerInternal => "The server experienced an internal error", 95 | AcmeError::Tls => "The server received a TLS error during validation", 96 | AcmeError::Unauthorized => "The client lacks sufficient authorization", 97 | AcmeError::UnsupportedContact => "A contact URL for an account used an unsupported protocol scheme", 98 | AcmeError::UnsupportedIdentifier => "An identifier is of an unsupported type", 99 | AcmeError::UserActionRequired => "Visit the \"instance\" URL and take actions specified there", 100 | AcmeError::Unknown => "Unknown error", 101 | }; 102 | write!(f, "{}", msg) 103 | } 104 | } 105 | 106 | impl AcmeError { 107 | pub fn is_recoverable(&self) -> bool { 108 | *self == AcmeError::BadNonce 109 | || *self == AcmeError::Connection 110 | || *self == AcmeError::Dns 111 | || *self == AcmeError::Malformed 112 | || *self == AcmeError::RateLimited 113 | || *self == AcmeError::ServerInternal 114 | || *self == AcmeError::Tls 115 | } 116 | } 117 | 118 | impl From for AcmeError { 119 | fn from(_error: Error) -> Self { 120 | AcmeError::Unknown 121 | } 122 | } 123 | 124 | impl From for Error { 125 | fn from(error: AcmeError) -> Self { 126 | error.to_string().into() 127 | } 128 | } 129 | 130 | #[derive(Clone, PartialEq, Deserialize)] 131 | pub struct HttpApiError { 132 | #[serde(rename = "type")] 133 | error_type: Option, 134 | // title: Option, 135 | status: Option, 136 | detail: Option, 137 | // instance: Option, 138 | // TODO: implement subproblems 139 | } 140 | 141 | crate::acme_proto::structs::deserialize_from_str!(HttpApiError); 142 | 143 | impl fmt::Display for HttpApiError { 144 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 145 | let msg = self 146 | .detail 147 | .to_owned() 148 | .unwrap_or_else(|| self.get_acme_type().to_string()); 149 | let msg = match self.status { 150 | Some(s) => format!("Status {}: {}", s, msg), 151 | None => msg, 152 | }; 153 | write!(f, "{}", msg) 154 | } 155 | } 156 | 157 | impl HttpApiError { 158 | pub fn get_type(&self) -> String { 159 | self.error_type 160 | .to_owned() 161 | .unwrap_or_else(|| String::from("about:blank")) 162 | } 163 | 164 | pub fn get_acme_type(&self) -> AcmeError { 165 | self.get_type().into() 166 | } 167 | } 168 | 169 | impl From for Error { 170 | fn from(error: HttpApiError) -> Self { 171 | error.to_string().into() 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /acmed/src/certificate.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::Challenge; 2 | use crate::config::{Account, Domain}; 3 | use crate::hooks::{self, ChallengeHookData, Hook, HookEnvData, HookType, PostOperationHookData}; 4 | use crate::storage::{certificate_files_exists, get_certificate}; 5 | use acme_common::crypto::X509Certificate; 6 | use acme_common::error::Error; 7 | use log::{debug, info, trace, warn}; 8 | use std::collections::{HashMap, HashSet}; 9 | use std::fmt; 10 | use std::time::Duration; 11 | 12 | #[derive(Clone, Debug)] 13 | pub enum Algorithm { 14 | Rsa2048, 15 | Rsa4096, 16 | EcdsaP256, 17 | EcdsaP384, 18 | } 19 | 20 | impl Algorithm { 21 | pub fn from_str(s: &str) -> Result { 22 | match s.to_lowercase().as_str() { 23 | "rsa2048" => Ok(Algorithm::Rsa2048), 24 | "rsa4096" => Ok(Algorithm::Rsa4096), 25 | "ecdsa_p256" => Ok(Algorithm::EcdsaP256), 26 | "ecdsa_p384" => Ok(Algorithm::EcdsaP384), 27 | _ => Err(format!("{}: unknown algorithm.", s).into()), 28 | } 29 | } 30 | } 31 | 32 | impl fmt::Display for Algorithm { 33 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 34 | let s = match self { 35 | Algorithm::Rsa2048 => "rsa2048", 36 | Algorithm::Rsa4096 => "rsa4096", 37 | Algorithm::EcdsaP256 => "ecdsa-p256", 38 | Algorithm::EcdsaP384 => "ecdsa-p384", 39 | }; 40 | write!(f, "{}", s) 41 | } 42 | } 43 | 44 | #[derive(Clone, Debug)] 45 | pub struct Certificate { 46 | pub account: Account, 47 | pub domains: Vec, 48 | pub algo: Algorithm, 49 | pub kp_reuse: bool, 50 | pub endpoint_name: String, 51 | pub hooks: Vec, 52 | pub account_directory: String, 53 | pub crt_directory: String, 54 | pub crt_name: String, 55 | pub crt_name_format: String, 56 | pub cert_file_mode: u32, 57 | pub cert_file_owner: Option, 58 | pub cert_file_group: Option, 59 | pub pk_file_mode: u32, 60 | pub pk_file_owner: Option, 61 | pub pk_file_group: Option, 62 | pub env: HashMap, 63 | pub id: usize, 64 | } 65 | 66 | impl fmt::Display for Certificate { 67 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 68 | // TODO: set a more "funky" id 69 | write!(f, "crt-{:x}", self.id) 70 | } 71 | } 72 | 73 | impl Certificate { 74 | pub fn warn(&self, msg: &str) { 75 | warn!("{}: {}", &self, msg); 76 | } 77 | 78 | pub fn info(&self, msg: &str) { 79 | info!("{}: {}", &self, msg); 80 | } 81 | 82 | pub fn debug(&self, msg: &str) { 83 | debug!("{}: {}", &self, msg); 84 | } 85 | 86 | pub fn trace(&self, msg: &str) { 87 | trace!("{}: {}", &self, msg); 88 | } 89 | 90 | pub fn get_domain_challenge(&self, domain_name: &str) -> Result { 91 | let domain_name = domain_name.to_string(); 92 | for d in self.domains.iter() { 93 | // strip wildcards from domain before matching 94 | let base_domain = d.dns.trim_start_matches("*."); 95 | if base_domain == domain_name { 96 | let c = Challenge::from_str(&d.challenge)?; 97 | return Ok(c); 98 | } 99 | } 100 | Err(format!("{}: domain name not found", domain_name).into()) 101 | } 102 | 103 | fn is_expiring(&self, cert: &X509Certificate) -> Result { 104 | let expires_in = cert.expires_in()?; 105 | self.debug(&format!( 106 | "Certificate expires in {} days", 107 | expires_in.as_secs() / 86400 108 | )); 109 | // TODO: allow a custom duration (using time-parse ?) 110 | // 1814400 is 3 weeks (3 * 7 * 24 * 60 * 60) 111 | let renewal_time = Duration::new(1_814_400, 0); 112 | Ok(expires_in <= renewal_time) 113 | } 114 | 115 | fn has_missing_domains(&self, cert: &X509Certificate) -> bool { 116 | let cert_names = cert.subject_alt_names(); 117 | let req_names = self 118 | .domains 119 | .iter() 120 | .map(|v| v.dns.to_owned()) 121 | .collect::>(); 122 | let has_miss = req_names.difference(&cert_names).count() != 0; 123 | if has_miss { 124 | let domains = req_names 125 | .difference(&cert_names) 126 | .map(std::borrow::ToOwned::to_owned) 127 | .collect::>() 128 | .join(", "); 129 | self.debug(&format!( 130 | "The certificate does not include the following domains: {}", 131 | domains 132 | )); 133 | } 134 | has_miss 135 | } 136 | 137 | /// Return a comma-separated list of the domains this certificate is valid for. 138 | pub fn domain_list(&self) -> String { 139 | self.domains 140 | .iter() 141 | .map(|domain| &*domain.dns) 142 | .collect::>() 143 | .join(",") 144 | } 145 | 146 | pub fn should_renew(&self) -> Result { 147 | self.debug(&format!( 148 | "Checking for renewal (domains: {})", 149 | self.domain_list() 150 | )); 151 | if !certificate_files_exists(&self) { 152 | self.debug("certificate does not exist: requesting one"); 153 | return Ok(true); 154 | } 155 | let cert = get_certificate(&self)?; 156 | 157 | let renew = self.has_missing_domains(&cert); 158 | let renew = renew || self.is_expiring(&cert)?; 159 | 160 | if renew { 161 | self.debug("The certificate will be renewed now"); 162 | } else { 163 | self.debug("The certificate will not be renewed now"); 164 | } 165 | Ok(renew) 166 | } 167 | 168 | pub fn call_challenge_hooks( 169 | &self, 170 | file_name: &str, 171 | proof: &str, 172 | domain: &str, 173 | ) -> Result<(ChallengeHookData, HookType), Error> { 174 | let challenge = self.get_domain_challenge(domain)?; 175 | let mut hook_data = ChallengeHookData { 176 | challenge: challenge.to_string(), 177 | domain: domain.to_string(), 178 | file_name: file_name.to_string(), 179 | proof: proof.to_string(), 180 | is_clean_hook: false, 181 | env: HashMap::new(), 182 | }; 183 | hook_data.set_env(&self.env); 184 | for d in self.domains.iter().filter(|d| d.dns == domain) { 185 | hook_data.set_env(&d.env); 186 | } 187 | let hook_type = match challenge { 188 | Challenge::Http01 => (HookType::ChallengeHttp01, HookType::ChallengeHttp01Clean), 189 | Challenge::Dns01 => (HookType::ChallengeDns01, HookType::ChallengeDns01Clean), 190 | Challenge::TlsAlpn01 => ( 191 | HookType::ChallengeTlsAlpn01, 192 | HookType::ChallengeTlsAlpn01Clean, 193 | ), 194 | }; 195 | hooks::call(self, &hook_data, hook_type.0)?; 196 | Ok((hook_data, hook_type.1)) 197 | } 198 | 199 | pub fn call_challenge_hooks_clean( 200 | &self, 201 | data: &ChallengeHookData, 202 | hook_type: HookType, 203 | ) -> Result<(), Error> { 204 | hooks::call(self, data, hook_type) 205 | } 206 | 207 | pub fn call_post_operation_hooks(&self, status: &str, is_success: bool) -> Result<(), Error> { 208 | let domains = self 209 | .domains 210 | .iter() 211 | .map(|d| format!("{} ({})", d.dns, d.challenge)) 212 | .collect::>(); 213 | let mut hook_data = PostOperationHookData { 214 | domains, 215 | algorithm: self.algo.to_string(), 216 | status: status.to_string(), 217 | is_success, 218 | env: HashMap::new(), 219 | }; 220 | hook_data.set_env(&self.env); 221 | hooks::call(self, &hook_data, HookType::PostOperation)?; 222 | Ok(()) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [//]: # (Copyright 2019-2020 Rodolphe Bréard ) 3 | 4 | [//]: # (Copying and distribution of this file, with or without modification,) 5 | [//]: # (are permitted in any medium without royalty provided the copyright) 6 | [//]: # (notice and this notice are preserved. This file is offered as-is,) 7 | [//]: # (without any warranty.) 8 | 9 | # ACMEd 10 | 11 | [![Build Status](https://api.travis-ci.org/breard-r/acmed.svg?branch=master)](https://travis-ci.org/breard-r/acmed) 12 | [![Minimum rustc version](https://img.shields.io/badge/rustc-1.32.0+-lightgray.svg)](#build-from-source) 13 | [![LICENSE MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE-MIT.txt) 14 | [![LICENSE Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE-APACHE-2.0.txt) 15 | 16 | The Automatic Certificate Management Environment (ACME), is an internet standard ([RFC 8555](https://tools.ietf.org/html/rfc8555)) which allows to automate X.509 certificates signing by a Certification Authority (CA). ACMEd is one of the many clients for this protocol. 17 | 18 | 19 | ## Key features 20 | 21 | - http-01, dns-01 and [tls-alpn-01](https://tools.ietf.org/html/rfc8737) challenges 22 | - RSA 2048, RSA 4096, ECDSA P-256 and ECDSA P-384 certificates 23 | - Internationalized domain names support 24 | - Fully customizable challenge validation action 25 | - Fully customizable archiving method (yes, you can use git or anything else) 26 | - Nice and simple configuration file 27 | - A pre-built set of hooks that can be used in most circumstances 28 | - Run as a deamon: no need to set-up timers, crontab or other time-triggered process 29 | - Retry of HTTPS request rejected with a badNonce or other recoverable errors 30 | - Customizable HTTPS requests rate limits. 31 | - Optional key pair reuse (useful for [HPKP](https://en.wikipedia.org/wiki/HTTP_Public_Key_Pinning)) 32 | - For a given certificate, each domain name may be validated using a different challenge. 33 | - A standalone server dedicated to the tls-alpn-01 challenge validation (tacd). 34 | 35 | 36 | ## Planned features 37 | 38 | - IP Identifier Validation Extension [RFC 8738](https://tools.ietf.org/html/rfc8738) 39 | - STAR Certificates [RFC 8739](https://tools.ietf.org/html/rfc8739) 40 | - Daemon and certificates management via the `acmectl` tool 41 | - Nonce scoping configuration 42 | - HTTP/2 support 43 | 44 | 45 | ## Project status 46 | 47 | This project is usable, but is still a work in progress. Each release should works well and accordingly to its documentation. 48 | Because the API has not been stabilized yet, breaking changes may occur. Therefore, before any upgrade, you are invited to read the [CHANGELOG](CHANGELOG.md) and check if any change can break your setup. 49 | 50 | Please keep in mind this software has neither been subject to a peer review nor to a security audit. 51 | 52 | 53 | ## Documentation 54 | 55 | This project provides the following man pages: 56 | 57 | - acmed (8) 58 | - acmed.toml (5) 59 | - tacd (8) 60 | 61 | 62 | ## Build from source 63 | 64 | In order to compile ACMEd, you will need the [Rust](https://www.rust-lang.org/) compiler and its package manager, Cargo. The minimal required Rust version is 1.40.0, although it is recommended to use the latest stable one. 65 | 66 | ACMEd depends OpenSSL 1.1.0 or higher. 67 | 68 | On systems based on Debian/Ubuntu, you may need to install the `libssl-dev`, `build-essential` and `pkg-config` packages. 69 | 70 | On Alpine Linux, you may need to install the `openssl-dev` and `alpine-sdk` packages. Since Alpine Linux 3.12 you can use the `rust` and `cargo` packages from the community repository. Older versions of Alpine Linux will require you to install the stable version of Rust using rustup. 71 | 72 | ``` 73 | $ make 74 | $ make install 75 | ``` 76 | 77 | To build ACMEd and tacd inside a temporary Docker container, use the `contrib/build-docker.sh` helper script. It currently supports Debian Buster / Stretch. 78 | 79 | 80 | ## Frequently Asked Questions 81 | 82 | ### Why this project? 83 | 84 | After testing multiple ACME clients, I found out none supported all the features I wished for (see the key features above). It may have been possible to contribute or fork an existing project, however I believe those project made architectural choices incompatible with what i wanted, and therefore it would be as much or less work to start a new project from scratch. 85 | 86 | ### Is it free and open-source software? 87 | 88 | Yes, ACMEd is dual-licensed under the MIT and Apache 2.0 terms. See [LICENSE-MIT.txt](LICENSE-MIT.txt) and [LICENSE-APACHE-2.0.txt](LICENSE-APACHE-2.0.txt) for details. 89 | 90 | The man pages, the default hooks configuration file, the `CHANGELOG.md` and the `README.md` files are released under the [GNU All-Permissive License](https://www.gnu.org/prep/maintain/html_node/License-Notices-for-Other-Files.html). 91 | 92 | ### Can it automatically change my server configuration? 93 | 94 | Short answer: No. 95 | 96 | Long answer: At some points in a certificate's life, ACMEd triggers hook in order to let you customize how some actions are done, therefore you can use those hooks to modify any server configuration you wish. However, this may not be what you are looking for since it cannot proactively detect which certificates should be emitted since ACMEd only manages certificates that have already been declared in the configuration files. 97 | 98 | ### Should ACMEd run as root? 99 | 100 | Running ACMEd as root is the simplest configuration since you do not have to worry about access rights, especially within hooks (Eg: restart a service). 101 | 102 | However, if you are concerned with safety, you should create a dedicated user for ACMEd. Before doing so, please consider the following points: "Will your services be able to read both the private key and the certificate?" and "Will the ACMEd user be able to execute the hooks?". The later could be achieved using sudo or Polkit. 103 | 104 | ### Why is there no option to run ACMEd as a specific user or group? 105 | 106 | The reason some services has such an option is because at startup they may have to load data only accessible by root, hence they have to change the user themselves after those data are loaded. For example, this is wildly used in web servers so they load a private key, which should only be accessible by root. Since ACMEd does not have such requirement, it should be run directly as the correct user. 107 | 108 | ### How can I run ACMEd with systemd? 109 | 110 | An example service file is provided (see `acmed.service.example`). The file might need adjustments in order to work on your system (e.g. binary path, user, group, directories...), but it's probably a good starting point. 111 | 112 | ### Is it suitable for beginners? 113 | 114 | It depends on your definition of a beginner. This software is intended to be used by system administrator with a certain knowledge of their environment. Furthermore, it is also expected to know the bases of the ACME protocol. Let's Encrypt wrote a nice article about [how it works](https://letsencrypt.org/how-it-works/). 115 | 116 | ### Why is RSA 2048 the default? 117 | 118 | Despite the fact that RSA 4096, ECDSA P-256 and ECDSA P-384 are supported, those are not (yet) fitted to be the default choice. 119 | 120 | It is not obvious at the first sight, but [RSA 4096](https://gnupg.org/faq/gnupg-faq.html#no_default_of_rsa4096) is NOT twice more secure than RSA 2048. In fact, it adds a lot more calculation while providing only a small security improvement. If you think you will use it anyway since you are more concerned about security than performance, please check your certificate chain up to the root. Most of the time, the root certificate and the intermediates will be RSA 2048 ones (that is the case for [Let’s Encrypt](https://letsencrypt.org/certificates/)). If so, using RSA 4096 in the final certificate will not add any additional security since a system's global security level is equal to the level of its weakest point. 121 | 122 | ECDSA certificates may be a good alternative to RSA since, for the same security level, they are smaller and requires less computation, hence improve performance. Unfortunately, as X.509 certificates may be used in various contexts, some software may not support this not-so-recent technology. To achieve maximal compatibility while using ECC, you usually have to set-up an hybrid configuration with both an ECDSA and a RSA certificate to fall-back to. Therefore, even if you are encouraged to use ECDSA certificates, it should not currently be the default. That said, it may be in a soon future. 123 | -------------------------------------------------------------------------------- /acmed/src/storage.rs: -------------------------------------------------------------------------------- 1 | use crate::certificate::Certificate; 2 | use crate::hooks::{self, FileStorageHookData, HookEnvData, HookType}; 3 | use acme_common::b64_encode; 4 | use acme_common::crypto::{KeyPair, X509Certificate}; 5 | use acme_common::error::Error; 6 | use std::collections::HashMap; 7 | use std::fmt; 8 | use std::fs::{File, OpenOptions}; 9 | use std::io::{Read, Write}; 10 | use std::path::PathBuf; 11 | 12 | #[cfg(target_family = "unix")] 13 | use std::os::unix::fs::OpenOptionsExt; 14 | 15 | #[derive(Clone)] 16 | enum FileType { 17 | AccountPrivateKey, 18 | AccountPublicKey, 19 | PrivateKey, 20 | Certificate, 21 | } 22 | 23 | impl fmt::Display for FileType { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 25 | let s = match self { 26 | FileType::AccountPrivateKey => "priv-key", 27 | FileType::AccountPublicKey => "pub-key", 28 | FileType::PrivateKey => "pk", 29 | FileType::Certificate => "crt", 30 | }; 31 | write!(f, "{}", s) 32 | } 33 | } 34 | 35 | fn get_file_full_path( 36 | cert: &Certificate, 37 | file_type: FileType, 38 | ) -> Result<(String, String, PathBuf), Error> { 39 | let base_path = match file_type { 40 | FileType::AccountPrivateKey | FileType::AccountPublicKey => &cert.account_directory, 41 | FileType::PrivateKey => &cert.crt_directory, 42 | FileType::Certificate => &cert.crt_directory, 43 | }; 44 | let file_name = match file_type { 45 | FileType::AccountPrivateKey | FileType::AccountPublicKey => format!( 46 | "{account}.{file_type}.{ext}", 47 | account = b64_encode(&cert.account.name), 48 | file_type = file_type.to_string(), 49 | ext = "pem" 50 | ), 51 | FileType::PrivateKey | FileType::Certificate => { 52 | // TODO: use cert.crt_name_format instead of a string literal 53 | format!( 54 | "{name}_{algo}.{file_type}.{ext}", 55 | name = cert.crt_name, 56 | algo = cert.algo.to_string(), 57 | file_type = file_type.to_string(), 58 | ext = "pem" 59 | ) 60 | } 61 | }; 62 | let mut path = PathBuf::from(&base_path); 63 | path.push(&file_name); 64 | Ok((base_path.to_string(), file_name, path)) 65 | } 66 | 67 | fn get_file_path(cert: &Certificate, file_type: FileType) -> Result { 68 | let (_, _, path) = get_file_full_path(cert, file_type)?; 69 | Ok(path) 70 | } 71 | 72 | fn read_file(cert: &Certificate, path: &PathBuf) -> Result, Error> { 73 | cert.trace(&format!("Reading file {:?}", path)); 74 | let mut file = File::open(path)?; 75 | let mut contents = vec![]; 76 | file.read_to_end(&mut contents)?; 77 | Ok(contents) 78 | } 79 | 80 | #[cfg(unix)] 81 | fn set_owner(cert: &Certificate, path: &PathBuf, file_type: FileType) -> Result<(), Error> { 82 | let (uid, gid) = match file_type { 83 | FileType::Certificate => ( 84 | cert.cert_file_owner.to_owned(), 85 | cert.cert_file_group.to_owned(), 86 | ), 87 | FileType::PrivateKey => (cert.pk_file_owner.to_owned(), cert.pk_file_group.to_owned()), 88 | FileType::AccountPrivateKey | FileType::AccountPublicKey => { 89 | // The account private and public keys does not need to be accessible to users other different from the current one. 90 | return Ok(()); 91 | } 92 | }; 93 | let uid = match uid { 94 | Some(u) => { 95 | if u.bytes().all(|b| b.is_ascii_digit()) { 96 | let raw_uid = u.parse::().unwrap(); 97 | let nix_uid = nix::unistd::Uid::from_raw(raw_uid); 98 | Some(nix_uid) 99 | } else { 100 | let user = nix::unistd::User::from_name(&u)?; 101 | user.map(|u| u.uid) 102 | } 103 | } 104 | None => None, 105 | }; 106 | let gid = match gid { 107 | Some(g) => { 108 | if g.bytes().all(|b| b.is_ascii_digit()) { 109 | let raw_gid = g.parse::().unwrap(); 110 | let nix_gid = nix::unistd::Gid::from_raw(raw_gid); 111 | Some(nix_gid) 112 | } else { 113 | let grp = nix::unistd::Group::from_name(&g)?; 114 | grp.map(|g| g.gid) 115 | } 116 | } 117 | None => None, 118 | }; 119 | match uid { 120 | Some(u) => cert.trace(&format!("{:?}: setting the uid to {}", path, u.as_raw())), 121 | None => cert.trace(&format!("{:?}: uid unchanged", path)), 122 | }; 123 | match gid { 124 | Some(g) => cert.trace(&format!("{:?}: setting the gid to {}", path, g.as_raw())), 125 | None => cert.trace(&format!("{:?}: gid unchanged", path)), 126 | }; 127 | match nix::unistd::chown(path, uid, gid) { 128 | Ok(_) => Ok(()), 129 | Err(e) => Err(format!("{}", e).into()), 130 | } 131 | } 132 | 133 | fn write_file(cert: &Certificate, file_type: FileType, data: &[u8]) -> Result<(), Error> { 134 | let (file_directory, file_name, path) = get_file_full_path(cert, file_type.clone())?; 135 | let mut hook_data = FileStorageHookData { 136 | file_name, 137 | file_directory, 138 | file_path: path.to_owned(), 139 | env: HashMap::new(), 140 | }; 141 | hook_data.set_env(&cert.env); 142 | let is_new = !path.is_file(); 143 | 144 | if is_new { 145 | hooks::call(cert, &hook_data, HookType::FilePreCreate)?; 146 | } else { 147 | hooks::call(cert, &hook_data, HookType::FilePreEdit)?; 148 | } 149 | 150 | cert.trace(&format!("Writing file {:?}", path)); 151 | let mut file = if cfg!(unix) { 152 | let mut options = OpenOptions::new(); 153 | options.mode(match &file_type { 154 | FileType::Certificate => cert.cert_file_mode, 155 | FileType::PrivateKey => cert.pk_file_mode, 156 | FileType::AccountPublicKey => crate::DEFAULT_ACCOUNT_FILE_MODE, 157 | FileType::AccountPrivateKey => crate::DEFAULT_ACCOUNT_FILE_MODE, 158 | }); 159 | options.write(true).create(true).open(&path)? 160 | } else { 161 | File::create(&path)? 162 | }; 163 | file.write_all(data)?; 164 | if cfg!(unix) { 165 | set_owner(cert, &path, file_type)?; 166 | } 167 | 168 | if is_new { 169 | hooks::call(cert, &hook_data, HookType::FilePostCreate)?; 170 | } else { 171 | hooks::call(cert, &hook_data, HookType::FilePostEdit)?; 172 | } 173 | Ok(()) 174 | } 175 | 176 | pub fn get_account_keypair(cert: &Certificate) -> Result { 177 | let path = get_file_path(cert, FileType::AccountPrivateKey)?; 178 | let raw_key = read_file(cert, &path)?; 179 | let key = KeyPair::from_pem(&raw_key)?; 180 | Ok(key) 181 | } 182 | 183 | pub fn set_account_keypair(cert: &Certificate, key_pair: &KeyPair) -> Result<(), Error> { 184 | let pem_pub_key = key_pair.private_key_to_pem()?; 185 | let pem_priv_key = key_pair.public_key_to_pem()?; 186 | write_file(cert, FileType::AccountPublicKey, &pem_priv_key)?; 187 | write_file(cert, FileType::AccountPrivateKey, &pem_pub_key)?; 188 | Ok(()) 189 | } 190 | 191 | pub fn get_keypair(cert: &Certificate) -> Result { 192 | let path = get_file_path(cert, FileType::PrivateKey)?; 193 | let raw_key = read_file(cert, &path)?; 194 | let key = KeyPair::from_pem(&raw_key)?; 195 | Ok(key) 196 | } 197 | 198 | pub fn set_keypair(cert: &Certificate, key_pair: &KeyPair) -> Result<(), Error> { 199 | let data = key_pair.private_key_to_pem()?; 200 | write_file(cert, FileType::PrivateKey, &data) 201 | } 202 | 203 | pub fn get_certificate(cert: &Certificate) -> Result { 204 | let path = get_file_path(cert, FileType::Certificate)?; 205 | let raw_crt = read_file(cert, &path)?; 206 | let crt = X509Certificate::from_pem(&raw_crt)?; 207 | Ok(crt) 208 | } 209 | 210 | pub fn write_certificate(cert: &Certificate, data: &[u8]) -> Result<(), Error> { 211 | write_file(cert, FileType::Certificate, data) 212 | } 213 | 214 | fn check_files(cert: &Certificate, file_types: &[FileType]) -> bool { 215 | for t in file_types.to_vec() { 216 | let path = match get_file_path(cert, t) { 217 | Ok(p) => p, 218 | Err(_) => { 219 | return false; 220 | } 221 | }; 222 | cert.trace(&format!("Testing file path: {}", path.to_str().unwrap())); 223 | if !path.is_file() { 224 | return false; 225 | } 226 | } 227 | true 228 | } 229 | 230 | pub fn account_files_exists(cert: &Certificate) -> bool { 231 | let file_types = vec![FileType::AccountPrivateKey, FileType::AccountPublicKey]; 232 | check_files(cert, &file_types) 233 | } 234 | 235 | pub fn certificate_files_exists(cert: &Certificate) -> bool { 236 | let file_types = vec![FileType::PrivateKey, FileType::Certificate]; 237 | check_files(cert, &file_types) 238 | } 239 | -------------------------------------------------------------------------------- /acmed/src/acme_proto/structs/authorization.rs: -------------------------------------------------------------------------------- 1 | use crate::acme_proto::structs::{ApiError, HttpApiError, Identifier}; 2 | use acme_common::b64_encode; 3 | use acme_common::crypto::{sha256, KeyPair}; 4 | use acme_common::error::Error; 5 | use serde::Deserialize; 6 | use std::fmt; 7 | use std::str::FromStr; 8 | 9 | const ACME_OID: &str = "1.3.6.1.5.5.7.1"; 10 | const ID_PE_ACME_ID: usize = 31; 11 | const DER_OCTET_STRING_ID: usize = 0x04; 12 | const DER_STRUCT_NAME: &str = "DER"; 13 | 14 | #[derive(Deserialize)] 15 | pub struct Authorization { 16 | pub identifier: Identifier, 17 | pub status: AuthorizationStatus, 18 | pub expires: Option, 19 | pub challenges: Vec, 20 | pub wildcard: Option, 21 | } 22 | 23 | impl FromStr for Authorization { 24 | type Err = Error; 25 | 26 | fn from_str(data: &str) -> Result { 27 | let mut res: Self = serde_json::from_str(data)?; 28 | res.challenges.retain(|c| *c != Challenge::Unknown); 29 | Ok(res) 30 | } 31 | } 32 | 33 | impl ApiError for Authorization { 34 | fn get_error(&self) -> Option { 35 | for challenge in self.challenges.iter() { 36 | let err = challenge.get_error(); 37 | if err.is_some() { 38 | return err; 39 | } 40 | } 41 | None 42 | } 43 | } 44 | 45 | #[derive(Debug, PartialEq, Eq, Deserialize)] 46 | #[serde(rename_all = "lowercase")] 47 | pub enum AuthorizationStatus { 48 | Pending, 49 | Valid, 50 | Invalid, 51 | Deactivated, 52 | Expired, 53 | Revoked, 54 | } 55 | 56 | impl fmt::Display for AuthorizationStatus { 57 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 58 | let s = match self { 59 | AuthorizationStatus::Pending => "pending", 60 | AuthorizationStatus::Valid => "valid", 61 | AuthorizationStatus::Invalid => "invalid", 62 | AuthorizationStatus::Deactivated => "deactivated", 63 | AuthorizationStatus::Expired => "expired", 64 | AuthorizationStatus::Revoked => "revoked", 65 | }; 66 | write!(f, "{}", s) 67 | } 68 | } 69 | 70 | #[derive(PartialEq, Deserialize)] 71 | #[serde(tag = "type")] 72 | pub enum Challenge { 73 | #[serde(rename = "http-01")] 74 | Http01(TokenChallenge), 75 | #[serde(rename = "dns-01")] 76 | Dns01(TokenChallenge), 77 | #[serde(rename = "tls-alpn-01")] 78 | TlsAlpn01(TokenChallenge), 79 | #[serde(other)] 80 | Unknown, 81 | } 82 | 83 | deserialize_from_str!(Challenge); 84 | 85 | impl Challenge { 86 | pub fn get_url(&self) -> String { 87 | match self { 88 | Challenge::Http01(tc) | Challenge::Dns01(tc) | Challenge::TlsAlpn01(tc) => { 89 | tc.url.to_owned() 90 | } 91 | Challenge::Unknown => String::new(), 92 | } 93 | } 94 | 95 | pub fn get_proof(&self, key_pair: &KeyPair) -> Result { 96 | match self { 97 | Challenge::Http01(tc) => tc.key_authorization(key_pair), 98 | Challenge::Dns01(tc) => { 99 | let ka = tc.key_authorization(key_pair)?; 100 | let a = sha256(ka.as_bytes()); 101 | let a = b64_encode(&a); 102 | Ok(a) 103 | } 104 | Challenge::TlsAlpn01(tc) => { 105 | let acme_ext_name = format!("{}.{}", ACME_OID, ID_PE_ACME_ID); 106 | let ka = tc.key_authorization(key_pair)?; 107 | let proof = sha256(ka.as_bytes()); 108 | let proof_str = proof 109 | .iter() 110 | .map(|e| format!("{:02x}", e)) 111 | .collect::>() 112 | .join(":"); 113 | let value = format!( 114 | "critical,{}:{:02x}:{:02x}:{}", 115 | DER_STRUCT_NAME, 116 | DER_OCTET_STRING_ID, 117 | proof.len(), 118 | proof_str 119 | ); 120 | let acme_ext = format!("{}={}", acme_ext_name, value); 121 | Ok(acme_ext) 122 | } 123 | Challenge::Unknown => Ok(String::new()), 124 | } 125 | } 126 | 127 | pub fn get_file_name(&self) -> String { 128 | match self { 129 | Challenge::Http01(tc) => tc.token.to_owned(), 130 | Challenge::Dns01(_) | Challenge::TlsAlpn01(_) => String::new(), 131 | Challenge::Unknown => String::new(), 132 | } 133 | } 134 | } 135 | 136 | impl ApiError for Challenge { 137 | fn get_error(&self) -> Option { 138 | match self { 139 | Challenge::Http01(tc) | Challenge::Dns01(tc) | Challenge::TlsAlpn01(tc) => { 140 | tc.error.to_owned().map(Error::from) 141 | } 142 | Challenge::Unknown => None, 143 | } 144 | } 145 | } 146 | 147 | #[derive(PartialEq, Deserialize)] 148 | pub struct TokenChallenge { 149 | pub url: String, 150 | pub status: Option, 151 | pub validated: Option, 152 | pub error: Option, 153 | pub token: String, 154 | } 155 | 156 | impl TokenChallenge { 157 | fn key_authorization(&self, key_pair: &KeyPair) -> Result { 158 | let thumbprint = key_pair.jwk_public_key_thumbprint()?; 159 | let thumbprint = sha256(thumbprint.to_string().as_bytes()); 160 | let thumbprint = b64_encode(&thumbprint); 161 | let auth = format!("{}.{}", self.token, thumbprint); 162 | Ok(auth) 163 | } 164 | } 165 | 166 | #[derive(Debug, PartialEq, Eq, Deserialize)] 167 | #[serde(rename_all = "lowercase")] 168 | pub enum ChallengeStatus { 169 | Pending, 170 | Processing, 171 | Valid, 172 | Invalid, 173 | } 174 | 175 | #[cfg(test)] 176 | mod tests { 177 | use super::{Authorization, AuthorizationStatus, Challenge, ChallengeStatus}; 178 | use crate::acme_proto::structs::IdentifierType; 179 | use std::str::FromStr; 180 | 181 | #[test] 182 | fn test_authorization() { 183 | let data = "{ 184 | \"status\": \"pending\", 185 | \"identifier\": { 186 | \"type\": \"dns\", 187 | \"value\": \"example.com\" 188 | }, 189 | \"challenges\": [] 190 | }"; 191 | let a = Authorization::from_str(data); 192 | assert!(a.is_ok()); 193 | let a = a.unwrap(); 194 | assert_eq!(a.status, AuthorizationStatus::Pending); 195 | assert!(a.challenges.is_empty()); 196 | let i = a.identifier; 197 | assert_eq!(i.id_type, IdentifierType::Dns); 198 | assert_eq!(i.value, "example.com".to_string()); 199 | } 200 | 201 | #[test] 202 | fn test_authorization_challenge() { 203 | let data = "{ 204 | \"status\": \"pending\", 205 | \"identifier\": { 206 | \"type\": \"dns\", 207 | \"value\": \"example.com\" 208 | }, 209 | \"challenges\": [ 210 | { 211 | \"type\": \"dns-01\", 212 | \"status\": \"pending\", 213 | \"url\": \"https://example.com/chall/jYWxob3N0OjE\", 214 | \"token\": \"1y9UVMUvkqQVljCsnwlRLsbJcwN9nx-qDd6JHzXQQsw\" 215 | } 216 | ] 217 | }"; 218 | let a = Authorization::from_str(data); 219 | assert!(a.is_ok()); 220 | let a = a.unwrap(); 221 | assert_eq!(a.status, AuthorizationStatus::Pending); 222 | assert_eq!(a.challenges.len(), 1); 223 | let i = a.identifier; 224 | assert_eq!(i.id_type, IdentifierType::Dns); 225 | assert_eq!(i.value, "example.com".to_string()); 226 | } 227 | 228 | #[test] 229 | fn test_authorization_unknown_challenge() { 230 | let data = "{ 231 | \"status\": \"pending\", 232 | \"identifier\": { 233 | \"type\": \"dns\", 234 | \"value\": \"example.com\" 235 | }, 236 | \"challenges\": [ 237 | { 238 | \"type\": \"invalid-challenge-01\", 239 | \"status\": \"pending\", 240 | \"url\": \"https://example.com/chall/jYWxob3N0OjE\", 241 | \"token\": \"1y9UVMUvkqQVljCsnwlRLsbJcwN9nx-qDd6JHzXQQsw\" 242 | } 243 | ] 244 | }"; 245 | let a = Authorization::from_str(data); 246 | assert!(a.is_ok()); 247 | let a = a.unwrap(); 248 | assert_eq!(a.status, AuthorizationStatus::Pending); 249 | assert!(a.challenges.is_empty()); 250 | let i = a.identifier; 251 | assert_eq!(i.id_type, IdentifierType::Dns); 252 | assert_eq!(i.value, "example.com".to_string()); 253 | } 254 | 255 | #[test] 256 | fn test_invalid_authorization() { 257 | let data = "{ 258 | \"status\": \"pending\", 259 | \"identifier\": { 260 | \"type\": \"foo\", 261 | \"value\": \"bar\" 262 | }, 263 | \"challenges\": [] 264 | }"; 265 | let a = Authorization::from_str(data); 266 | assert!(a.is_err()); 267 | } 268 | 269 | #[test] 270 | fn test_http01_challenge() { 271 | let data = "{ 272 | \"type\": \"http-01\", 273 | \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", 274 | \"status\": \"pending\", 275 | \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" 276 | }"; 277 | let challenge = Challenge::from_str(data); 278 | assert!(challenge.is_ok()); 279 | let challenge = challenge.unwrap(); 280 | let c = match challenge { 281 | Challenge::Http01(c) => c, 282 | _ => { 283 | assert!(false); 284 | return; 285 | } 286 | }; 287 | assert_eq!( 288 | c.url, 289 | "https://example.com/acme/chall/prV_B7yEyA4".to_string() 290 | ); 291 | assert_eq!(c.status, Some(ChallengeStatus::Pending)); 292 | assert_eq!( 293 | c.token, 294 | "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0".to_string() 295 | ); 296 | assert!(c.validated.is_none()); 297 | assert!(c.error.is_none()); 298 | } 299 | 300 | #[test] 301 | fn test_dns01_challenge() { 302 | let data = "{ 303 | \"type\": \"http-01\", 304 | \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", 305 | \"status\": \"valid\", 306 | \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" 307 | }"; 308 | let challenge = Challenge::from_str(data); 309 | assert!(challenge.is_ok()); 310 | let challenge = challenge.unwrap(); 311 | let c = match challenge { 312 | Challenge::Http01(c) => c, 313 | _ => { 314 | assert!(false); 315 | return; 316 | } 317 | }; 318 | assert_eq!( 319 | c.url, 320 | "https://example.com/acme/chall/prV_B7yEyA4".to_string() 321 | ); 322 | assert_eq!(c.status, Some(ChallengeStatus::Valid)); 323 | assert_eq!( 324 | c.token, 325 | "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0".to_string() 326 | ); 327 | assert!(c.validated.is_none()); 328 | assert!(c.error.is_none()); 329 | } 330 | 331 | #[test] 332 | fn test_unknown_challenge_type() { 333 | let data = "{ 334 | \"type\": \"invalid-01\", 335 | \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", 336 | \"status\": \"pending\", 337 | \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" 338 | }"; 339 | let challenge = Challenge::from_str(data); 340 | assert!(challenge.is_ok()); 341 | match challenge.unwrap() { 342 | Challenge::Unknown => assert!(true), 343 | _ => assert!(false), 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /LICENSE-APACHE-2.0.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /acmed/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::certificate::Algorithm; 2 | use crate::hooks; 3 | use acme_common::error::Error; 4 | use acme_common::to_idna; 5 | use log::info; 6 | use serde::Deserialize; 7 | use std::collections::HashMap; 8 | use std::fmt; 9 | use std::fs::{self, File}; 10 | use std::io::prelude::*; 11 | use std::path::{Path, PathBuf}; 12 | 13 | macro_rules! set_cfg_attr { 14 | ($to: expr, $from: expr) => { 15 | if let Some(v) = $from { 16 | $to = Some(v); 17 | }; 18 | }; 19 | } 20 | 21 | fn get_stdin(hook: &Hook) -> Result { 22 | match &hook.stdin { 23 | Some(file) => match &hook.stdin_str { 24 | Some(_) => { 25 | let msg = format!( 26 | "{}: A hook cannot have both stdin and stdin_str", 27 | &hook.name 28 | ); 29 | Err(msg.into()) 30 | } 31 | None => Ok(hooks::HookStdin::File(file.to_string())), 32 | }, 33 | None => match &hook.stdin_str { 34 | Some(s) => Ok(hooks::HookStdin::Str(s.to_string())), 35 | None => Ok(hooks::HookStdin::None), 36 | }, 37 | } 38 | } 39 | 40 | #[derive(Deserialize)] 41 | #[serde(deny_unknown_fields)] 42 | pub struct Config { 43 | pub global: Option, 44 | #[serde(default)] 45 | pub endpoint: Vec, 46 | #[serde(default, rename = "rate-limit")] 47 | pub rate_limit: Vec, 48 | #[serde(default)] 49 | pub hook: Vec, 50 | #[serde(default)] 51 | pub group: Vec, 52 | #[serde(default)] 53 | pub account: Vec, 54 | #[serde(default)] 55 | pub certificate: Vec, 56 | #[serde(default)] 57 | pub include: Vec, 58 | } 59 | 60 | impl Config { 61 | fn get_rate_limit(&self, name: &str) -> Result<(usize, String), Error> { 62 | for rl in self.rate_limit.iter() { 63 | if rl.name == name { 64 | return Ok((rl.number, rl.period.to_owned())); 65 | } 66 | } 67 | Err(format!("{}: rate limit not found", name).into()) 68 | } 69 | 70 | pub fn get_account_dir(&self) -> String { 71 | let account_dir = match &self.global { 72 | Some(g) => match &g.accounts_directory { 73 | Some(d) => &d, 74 | None => crate::DEFAULT_ACCOUNTS_DIR, 75 | }, 76 | None => crate::DEFAULT_ACCOUNTS_DIR, 77 | }; 78 | account_dir.to_string() 79 | } 80 | 81 | pub fn get_hook(&self, name: &str) -> Result, Error> { 82 | for hook in self.hook.iter() { 83 | if name == hook.name { 84 | let h = hooks::Hook { 85 | name: hook.name.to_owned(), 86 | hook_type: hook.hook_type.to_owned(), 87 | cmd: hook.cmd.to_owned(), 88 | args: hook.args.to_owned(), 89 | stdin: get_stdin(&hook)?, 90 | stdout: hook.stdout.to_owned(), 91 | stderr: hook.stderr.to_owned(), 92 | allow_failure: hook 93 | .allow_failure 94 | .unwrap_or(crate::DEFAULT_HOOK_ALLOW_FAILURE), 95 | }; 96 | return Ok(vec![h]); 97 | } 98 | } 99 | for grp in self.group.iter() { 100 | if name == grp.name { 101 | let mut ret = vec![]; 102 | for hook_name in grp.hooks.iter() { 103 | let mut h = self.get_hook(&hook_name)?; 104 | ret.append(&mut h); 105 | } 106 | return Ok(ret); 107 | } 108 | } 109 | Err(format!("{}: hook not found", name).into()) 110 | } 111 | 112 | pub fn get_cert_file_mode(&self) -> u32 { 113 | match &self.global { 114 | Some(g) => match g.cert_file_mode { 115 | Some(m) => m, 116 | None => crate::DEFAULT_CERT_FILE_MODE, 117 | }, 118 | None => crate::DEFAULT_CERT_FILE_MODE, 119 | } 120 | } 121 | 122 | pub fn get_cert_file_user(&self) -> Option { 123 | match &self.global { 124 | Some(g) => g.cert_file_user.to_owned(), 125 | None => None, 126 | } 127 | } 128 | 129 | pub fn get_cert_file_group(&self) -> Option { 130 | match &self.global { 131 | Some(g) => g.cert_file_group.to_owned(), 132 | None => None, 133 | } 134 | } 135 | 136 | pub fn get_pk_file_mode(&self) -> u32 { 137 | match &self.global { 138 | Some(g) => match g.pk_file_mode { 139 | Some(m) => m, 140 | None => crate::DEFAULT_PK_FILE_MODE, 141 | }, 142 | None => crate::DEFAULT_PK_FILE_MODE, 143 | } 144 | } 145 | 146 | pub fn get_pk_file_user(&self) -> Option { 147 | match &self.global { 148 | Some(g) => g.pk_file_user.to_owned(), 149 | None => None, 150 | } 151 | } 152 | 153 | pub fn get_pk_file_group(&self) -> Option { 154 | match &self.global { 155 | Some(g) => g.pk_file_group.to_owned(), 156 | None => None, 157 | } 158 | } 159 | } 160 | 161 | #[derive(Clone, Deserialize)] 162 | #[serde(deny_unknown_fields)] 163 | pub struct GlobalOptions { 164 | pub accounts_directory: Option, 165 | pub certificates_directory: Option, 166 | pub cert_file_mode: Option, 167 | pub cert_file_user: Option, 168 | pub cert_file_group: Option, 169 | pub pk_file_mode: Option, 170 | pub pk_file_user: Option, 171 | pub pk_file_group: Option, 172 | #[serde(default)] 173 | pub env: HashMap, 174 | } 175 | 176 | #[derive(Clone, Deserialize)] 177 | #[serde(deny_unknown_fields)] 178 | pub struct Endpoint { 179 | pub name: String, 180 | pub url: String, 181 | pub tos_agreed: bool, 182 | #[serde(default)] 183 | pub rate_limits: Vec, 184 | } 185 | 186 | impl Endpoint { 187 | fn to_generic(&self, cnf: &Config) -> Result { 188 | let mut limits = vec![]; 189 | for rl_name in self.rate_limits.iter() { 190 | let (nb, timeframe) = cnf.get_rate_limit(&rl_name)?; 191 | limits.push((nb, timeframe)); 192 | } 193 | crate::endpoint::Endpoint::new(&self.name, &self.url, self.tos_agreed, &limits) 194 | } 195 | } 196 | 197 | #[derive(Clone, Deserialize)] 198 | #[serde(deny_unknown_fields)] 199 | pub struct RateLimit { 200 | pub name: String, 201 | pub number: usize, 202 | pub period: String, 203 | } 204 | 205 | #[derive(Deserialize)] 206 | #[serde(deny_unknown_fields)] 207 | pub struct Hook { 208 | pub name: String, 209 | #[serde(rename = "type")] 210 | pub hook_type: Vec, 211 | pub cmd: String, 212 | pub args: Option>, 213 | pub stdin: Option, 214 | pub stdin_str: Option, 215 | pub stdout: Option, 216 | pub stderr: Option, 217 | pub allow_failure: Option, 218 | } 219 | 220 | #[derive(Clone, Debug, Eq, PartialEq, Deserialize)] 221 | #[serde(rename_all = "kebab-case")] 222 | pub enum HookType { 223 | FilePreCreate, 224 | FilePostCreate, 225 | FilePreEdit, 226 | FilePostEdit, 227 | #[serde(rename = "challenge-http-01")] 228 | ChallengeHttp01, 229 | #[serde(rename = "challenge-http-01-clean")] 230 | ChallengeHttp01Clean, 231 | #[serde(rename = "challenge-dns-01")] 232 | ChallengeDns01, 233 | #[serde(rename = "challenge-dns-01-clean")] 234 | ChallengeDns01Clean, 235 | #[serde(rename = "challenge-tls-alpn-01")] 236 | ChallengeTlsAlpn01, 237 | #[serde(rename = "challenge-tls-alpn-01-clean")] 238 | ChallengeTlsAlpn01Clean, 239 | PostOperation, 240 | } 241 | 242 | #[derive(Deserialize)] 243 | #[serde(deny_unknown_fields)] 244 | pub struct Group { 245 | pub name: String, 246 | pub hooks: Vec, 247 | } 248 | 249 | #[derive(Clone, Debug, Deserialize)] 250 | #[serde(deny_unknown_fields)] 251 | pub struct Account { 252 | pub name: String, 253 | pub email: String, 254 | } 255 | 256 | #[derive(Deserialize)] 257 | #[serde(deny_unknown_fields)] 258 | pub struct Certificate { 259 | pub account: String, 260 | pub endpoint: String, 261 | pub domains: Vec, 262 | pub algorithm: Option, 263 | pub kp_reuse: Option, 264 | pub directory: Option, 265 | pub name: Option, 266 | pub name_format: Option, 267 | pub formats: Option>, 268 | pub hooks: Vec, 269 | #[serde(default)] 270 | pub env: HashMap, 271 | } 272 | 273 | impl Certificate { 274 | pub fn get_account(&self, cnf: &Config) -> Result { 275 | for account in cnf.account.iter() { 276 | if account.name == self.account { 277 | return Ok(account.clone()); 278 | } 279 | } 280 | Err(format!("{}: account not found", self.account).into()) 281 | } 282 | 283 | pub fn get_algorithm(&self) -> Result { 284 | let algo = match &self.algorithm { 285 | Some(a) => &a, 286 | None => acme_common::crypto::DEFAULT_ALGO, 287 | }; 288 | Algorithm::from_str(algo) 289 | } 290 | 291 | pub fn get_domains(&self) -> Result, Error> { 292 | let mut ret = vec![]; 293 | for d in self.domains.iter() { 294 | let mut nd = d.clone(); 295 | nd.dns = to_idna(&nd.dns)?; 296 | ret.push(nd); 297 | } 298 | Ok(ret) 299 | } 300 | 301 | pub fn get_kp_reuse(&self) -> bool { 302 | match self.kp_reuse { 303 | Some(b) => b, 304 | None => crate::DEFAULT_KP_REUSE, 305 | } 306 | } 307 | 308 | pub fn get_crt_name(&self) -> String { 309 | match &self.name { 310 | Some(n) => n.to_string(), 311 | None => self.domains.first().unwrap().dns.to_owned(), 312 | } 313 | .replace("*", "_") 314 | } 315 | 316 | pub fn get_crt_name_format(&self) -> String { 317 | match &self.name_format { 318 | Some(n) => n.to_string(), 319 | None => crate::DEFAULT_CERT_FORMAT.to_string(), 320 | } 321 | } 322 | 323 | pub fn get_crt_dir(&self, cnf: &Config) -> String { 324 | let crt_directory = match &self.directory { 325 | Some(d) => &d, 326 | None => match &cnf.global { 327 | Some(g) => match &g.certificates_directory { 328 | Some(d) => &d, 329 | None => crate::DEFAULT_CERT_DIR, 330 | }, 331 | None => crate::DEFAULT_CERT_DIR, 332 | }, 333 | }; 334 | crt_directory.to_string() 335 | } 336 | 337 | pub fn get_endpoint(&self, cnf: &Config) -> Result { 338 | for endpoint in cnf.endpoint.iter() { 339 | if endpoint.name == self.endpoint { 340 | let ep = endpoint.to_generic(cnf)?; 341 | return Ok(ep); 342 | } 343 | } 344 | Err(format!("{}: unknown endpoint.", self.endpoint).into()) 345 | } 346 | 347 | pub fn get_hooks(&self, cnf: &Config) -> Result, Error> { 348 | let mut res = vec![]; 349 | for name in self.hooks.iter() { 350 | let mut h = cnf.get_hook(&name)?; 351 | res.append(&mut h); 352 | } 353 | Ok(res) 354 | } 355 | } 356 | 357 | #[derive(Clone, Debug, Deserialize)] 358 | #[serde(deny_unknown_fields)] 359 | pub struct Domain { 360 | pub challenge: String, 361 | pub dns: String, 362 | #[serde(default)] 363 | pub env: HashMap, 364 | } 365 | 366 | impl fmt::Display for Domain { 367 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 368 | write!(f, "{}", self.dns) 369 | } 370 | } 371 | 372 | fn create_dir(path: &str) -> Result<(), Error> { 373 | if Path::new(path).is_dir() { 374 | Ok(()) 375 | } else { 376 | fs::create_dir_all(path)?; 377 | Ok(()) 378 | } 379 | } 380 | 381 | fn init_directories(config: &Config) -> Result<(), Error> { 382 | create_dir(&config.get_account_dir())?; 383 | for crt in config.certificate.iter() { 384 | create_dir(&crt.get_crt_dir(config))?; 385 | } 386 | Ok(()) 387 | } 388 | 389 | fn get_cnf_path(from: &PathBuf, file: &str) -> PathBuf { 390 | let mut path = from.clone(); 391 | path.pop(); 392 | path.push(file); 393 | path 394 | } 395 | 396 | fn read_cnf(path: &PathBuf) -> Result { 397 | info!("Loading configuration file: {}", path.display()); 398 | let mut file = File::open(path)?; 399 | let mut contents = String::new(); 400 | file.read_to_string(&mut contents)?; 401 | let mut config: Config = toml::from_str(&contents)?; 402 | for cnf_name in config.include.iter() { 403 | let cnf_path = get_cnf_path(path, cnf_name); 404 | let mut add_cnf = read_cnf(&cnf_path)?; 405 | config.endpoint.append(&mut add_cnf.endpoint); 406 | config.rate_limit.append(&mut add_cnf.rate_limit); 407 | config.hook.append(&mut add_cnf.hook); 408 | config.group.append(&mut add_cnf.group); 409 | config.account.append(&mut add_cnf.account); 410 | config.certificate.append(&mut add_cnf.certificate); 411 | if config.global.is_none() { 412 | config.global = add_cnf.global; 413 | } else if let Some(new_glob) = add_cnf.global { 414 | let mut tmp_glob = config.global.clone().unwrap(); 415 | set_cfg_attr!(tmp_glob.accounts_directory, new_glob.accounts_directory); 416 | set_cfg_attr!( 417 | tmp_glob.certificates_directory, 418 | new_glob.certificates_directory 419 | ); 420 | set_cfg_attr!(tmp_glob.cert_file_mode, new_glob.cert_file_mode); 421 | set_cfg_attr!(tmp_glob.cert_file_user, new_glob.cert_file_user); 422 | set_cfg_attr!(tmp_glob.cert_file_group, new_glob.cert_file_group); 423 | set_cfg_attr!(tmp_glob.pk_file_mode, new_glob.pk_file_mode); 424 | set_cfg_attr!(tmp_glob.pk_file_user, new_glob.pk_file_user); 425 | set_cfg_attr!(tmp_glob.pk_file_group, new_glob.pk_file_group); 426 | config.global = Some(tmp_glob); 427 | } 428 | } 429 | Ok(config) 430 | } 431 | 432 | fn dispatch_global_env_vars(config: &mut Config) { 433 | if let Some(glob) = &config.global { 434 | if !glob.env.is_empty() { 435 | for mut cert in config.certificate.iter_mut() { 436 | let mut new_vars = glob.env.clone(); 437 | for (k, v) in cert.env.iter() { 438 | new_vars.insert(k.to_string(), v.to_string()); 439 | } 440 | cert.env = new_vars; 441 | } 442 | } 443 | } 444 | } 445 | 446 | pub fn from_file(file_name: &str) -> Result { 447 | let path = PathBuf::from(file_name); 448 | let mut config = read_cnf(&path)?; 449 | dispatch_global_env_vars(&mut config); 450 | init_directories(&config)?; 451 | Ok(config) 452 | } 453 | -------------------------------------------------------------------------------- /man/en/acmed.toml.5: -------------------------------------------------------------------------------- 1 | .\" Copyright (c) 2019-2020 Rodolphe Bréard 2 | .\" 3 | .\" Copying and distribution of this file, with or without modification, 4 | .\" are permitted in any medium without royalty provided the copyright 5 | .\" notice and this notice are preserved. This file is offered as-is, 6 | .\" without any warranty. 7 | .Dd June 12, 2020 8 | .Dt ACMED.TOML 5 9 | .Os 10 | .Sh NAME 11 | .Nm acmed.toml 12 | .Nd ACMEd configuration file 13 | .Sh DESCRIPTION 14 | .Nm 15 | is the configuration file for 16 | .Xr acmed 8 . 17 | It is written in the 18 | .Em TOML 19 | format. The allowed elements are described below. 20 | .Bl -tag 21 | .It Ic include 22 | 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. 23 | .Pp 24 | In case or overlapping global option definition, the one of the last included file will be used. For example, if a file 25 | .Em A 26 | includes files 27 | .Em B 28 | and 29 | .Em C 30 | and all three defines the same global option, the final value will be the one defined in file 31 | .Em C . 32 | .It Ic global 33 | Table containing the global configuration options. 34 | .Bl -tag 35 | .It Cm accounts_directory Ar string 36 | Specify the directory where the accounts private and public keys are stored. 37 | .It Cm certificates_directory Ar string 38 | Specify the directory where the certificates and their associated private keys are stored. 39 | .It Cm cert_file_mode Ar integer 40 | Specify the permissions to use for newly-created certificates files. See 41 | .Xr chmod 2 42 | for more details. 43 | .It Cm cert_file_user Ar username|user_id Ft string 44 | Specify the user who will own newly-created certificates files. See 45 | .Xr chown 2 46 | for more details. 47 | .It Cm cert_file_group Ar group_name|group_id Ft string 48 | Specify the group who will own newly-created certificates files. See 49 | .Xr chown 2 50 | for more details. 51 | .It Ic env Ar table 52 | Table of environment variables that will be accessible from hooks. 53 | .It Cm pk_file_mode Ar integer 54 | Specify the permissions to use for newly-created private-key files. See 55 | .Xr chmod 2 56 | for more details. 57 | .It Cm pk_file_user Ar username|user_id Ft string 58 | Specify the user who will own newly-created private-key files. See 59 | .Xr chown 2 60 | for more details. 61 | .It Cm pk_file_group Ar group_name|group_id Ft string 62 | Specify the group who will own newly-created private-key files. See 63 | .Xr chown 2 64 | for more details. 65 | .El 66 | .It Ic rate-limit 67 | Array of table where each element defines a HTTPS rate limit. 68 | .Bl -tag 69 | .It Cm name Ar string 70 | The name the rate limit is registered under. Must be unique. 71 | .It Cm number Ar integer 72 | Number of requests authorized withing the time period. 73 | .It Cm period Ar string 74 | Period of time during which a maximal number of requests is authorized. The format is described in the 75 | .Sx TIME PERIODS 76 | section. 77 | .El 78 | .It Ic endpoint 79 | Array of table where each element defines a Certificate Authority 80 | .Pq CA 81 | which may be used to request certificates. 82 | .Bl -tag 83 | .It Cm name Ar string 84 | The name the endpoint is registered under. Must be unique. 85 | .It Cm rate_limits Ar array 86 | Array containing the names of the HTTPS rate limits to apply. 87 | .It Cm tos_agreed Ar boolean 88 | Set whether or not the user agrees to the Terms Of Service 89 | .Pq TOS . 90 | .It Cm url Ar string 91 | The endpoint's directory URL. 92 | .El 93 | .It Ic hook 94 | Array of table where each element defines a command that will be launched at a defined point. See section 95 | .Sx WRITING A HOOK 96 | for more details. 97 | .Bl -tag 98 | .It Cm allow_failure Ar boolean 99 | Defines if an error return value for this hook is allowed or not. If not allowed, a failure in this hook will fail the whole certificate request process. Default is false. 100 | .It Cm name Ar string 101 | The name the hook is registered under. Must be unique. 102 | .It Cm type Ar array 103 | Array of strings. Possible types are: 104 | .Bl -dash -compact 105 | .It 106 | challenge-http-01 107 | .It 108 | challenge-http-01-clean 109 | .It 110 | challenge-dns-01 111 | .It 112 | challenge-dns-01-clean 113 | .It 114 | challenge-tls-alpn-01 115 | .It 116 | challenge-tls-alpn-01-clean 117 | .It 118 | file-pre-create 119 | .It 120 | file-pre-edit 121 | .It 122 | file-post-create 123 | .It 124 | file-post-edit 125 | .It 126 | post-operation 127 | .El 128 | .It Ic cmd Ar string 129 | The name of the command that will be launched. 130 | .It Ic args Ar array 131 | Array of strings representing the command's arguments. 132 | .It Ic stdin Ar string 133 | Path to the file that will be written into the command's standard intput. Mutually exclusive with 134 | .Em stdin_str . 135 | .It Ic stdin_str Ar string 136 | String that will be written into the command's standard input. Mutually exclusive with 137 | .Em stdin . 138 | .It Ic stdout Ar string 139 | Path to the file where the command's standard output if written. 140 | .It Ic stderr Ar string 141 | Path to the file where the command's standard error output if written. 142 | .El 143 | .It Ic group 144 | Array of table allowing to group several hooks as one. A group is considered as new hook. 145 | .Bl -tag 146 | .It Cm name Ar string 147 | The name the group is registered under. This name is considered as a hook name. Must be unique. 148 | .It Cm hooks Ar array 149 | Array containing the names of the hooks that are grouped. The hooks are guaranteed to be called sequentially in the declaration order. 150 | .El 151 | .It Ic account 152 | Array of table representing an account on one or several CA. 153 | .Bl -tag 154 | .It Ic name Ar string 155 | The name the account is registered under. Must be unique. 156 | .It Ic email Ar string 157 | The email address used to contact the account's holder. 158 | .El 159 | .It Ic certificate 160 | Array of table representing a certificate that will be requested to a CA. 161 | .Pp 162 | Note that certificates are identified by the first domain in the list of domains. That means that if you reorder the domains so that a different domain is at the first position, a new certificate with a new name will be issued. 163 | .Bl -tag 164 | .It Ic account Ar string 165 | Name of the account to use. 166 | .It Ic endpoint Ar string 167 | Name of the endpoint to use. 168 | .It Ic env Ar table 169 | Table of environment variables that will be accessible from hooks. 170 | .It Ic domains Ar array 171 | Array of tables listing the domains that should be included in the certificate along with the challenge to use for each one. 172 | .Bl -tag 173 | .It Ic challenge Ar string 174 | The name of the challenge to use to prove the domain's ownership. Possible values are: 175 | .Bl -dash -compact 176 | .It 177 | http-01 178 | .It 179 | dns-01 180 | .It 181 | tls-alpn-01 182 | .El 183 | .It Ic dns Ar string 184 | The domain name. 185 | .It Ic env Ar table 186 | Table of environment variables that will be accessible from hooks. 187 | .El 188 | .It Ic algorithm Ar string 189 | Name of the asymetric cryptography algorithm used to generate the certificate's key pair. Possible values are : 190 | .Bl -dash -compact 191 | .It 192 | rsa2048 193 | .Aq default 194 | .It 195 | rsa4096 196 | .It 197 | ecdsa_p256 198 | .It 199 | ecdsa_p384 200 | .El 201 | .It Ic kp_reuse Ar boolean 202 | Set whether or not the private key should be reused when renewing the certificate. Default is false. 203 | .It Ic directory Ar string 204 | Path to the directory where certificates and their associated private keys are stored. 205 | .It Ic hooks Ar array 206 | Names of hooks that will be called when requesting a new certificate. The hooks are guaranteed to be called sequentially in the declaration order. 207 | .El 208 | .Sh WRITING A HOOK 209 | When requesting a certificate from a CA using ACME, there are three steps that are hard to automatize. The first one is solving challenges in order to prove the ownership of every domains to be included: It requires to interact with the configuration of other services, hence depends on how the infrastructure works. The second one is restarting all the services that use a given certificate, for the same reason. The last one is archiving: Although several default methods can be implemented, sometimes admins wants or are required to do it in a different way. 210 | .Pp 211 | In order to allow full automation of the three above steps without imposing arbitrary restrictions or methods, 212 | .Xr acmed 8 213 | uses hooks. Fundamentally, a hook is a command line template that will be called at a specific time of the process. Such an approach allows admins to use any executable script or program located on the machine to customize the process. 214 | .Pp 215 | For a given certificate, hooks are guaranteed to be called sequentially in the declaration order. It is therefore possible to have a hook that depends on another one. Nevertheless, several certificates may be renewed at the same time. Hence, hooks shall not use globing or any other action that may disrupt hooks called by a different certificate. 216 | .Pp 217 | A hook has a type that will influence both the moment it is called and the available template variables. It is possible to declare several types. In such a case, the hook will be invoked whenever one of its type request it. When called, the hook only have access to template variable for the current type. If a hook uses a template variable that does not exists for the current type it is invoked for, the variable is empty. 218 | .Pp 219 | When writing a hook, the values of 220 | .Em args , 221 | .Em stdin , 222 | .Em stdin_str , 223 | .Em stdout 224 | and 225 | .Em stderr 226 | are considered as template strings whereas 227 | .Em cmd 228 | is not. The template syntax is 229 | .Em Handlebars . 230 | See the 231 | .Sx STANDARDS 232 | section for a link to the 233 | .Em Handlebars 234 | specifications. 235 | .Pp 236 | The available types and the associated template variable are described below. 237 | .Bl -tag 238 | .It Ic challenge-http-01 239 | Invoked when the ownership of a domain must be proved using the 240 | .Em http-01 241 | challenge. The available template variables are: 242 | .Bl -tag -compact 243 | .It Cm challenge Ar string 244 | The name of the challenge type 245 | .Aq http-01 . 246 | Mostly used in hooks with multiple types. 247 | .It Cm domain Ar string 248 | The domain name whom ownership is currently being validated. 249 | .It Cm env Ar array 250 | Array containing all the environment variables. 251 | .It Cm file_name Ar string 252 | Name of the file containing the proof. This is not a full path and does not include the 253 | .Ql .well-known/acme-challenge/ 254 | prefix. 255 | .It Cm is_clean_hook Ar bool 256 | False 257 | .It Cm proof Ar string 258 | The content of the proof that must be written to 259 | .Em file_name . 260 | .El 261 | .It Ic challenge-http-01-clean 262 | Invoked once a domain ownership has been proven using the 263 | .Em http-01 264 | challenge. This hook is intended to remove the proof since it is no longer required. The template variables are strictly identical to those given in the corresponding 265 | .Em challenge-http-01 266 | hook, excepted 267 | .Em is_clean_hook 268 | which is set to 269 | .Em true . 270 | .It Ic challenge-dns-01 271 | Invoked when the ownership of a domain must be proved using the 272 | .Em dns-01 273 | challenge. The available template variables are: 274 | .Bl -tag -compact 275 | .It Cm challenge Ar string 276 | The name of the challenge type 277 | .Aq dns-01 . 278 | Mostly used in hooks with multiple types. 279 | .It Cm domain Ar string 280 | The domain name whom ownership is currently being validated. 281 | .It Cm env Ar array 282 | Array containing all the environment variables. 283 | .It Cm is_clean_hook Ar bool 284 | False 285 | .It Cm proof Ar string 286 | The content of the proof that must be written to a 287 | .Ql TXT 288 | entry of the DNS zone for the 289 | .Ql _acme-challenge 290 | subdomain. 291 | .El 292 | .It Ic challenge-dns-01-clean 293 | Invoked once a domain ownership has been proven using the 294 | .Em dns-01 295 | challenge. This hook is intended to remove the proof since it is no longer required. The template variables are strictly identical to those given in the corresponding 296 | .Em challenge-dns-01 297 | hook, excepted 298 | .Em is_clean_hook 299 | which is set to 300 | .Em true . 301 | .It Ic challenge-tls-alpn-01 302 | Invoked when the ownership of a domain must be proved using the 303 | .Em tls-alpn-01 304 | challenge. The available template variables are: 305 | .Bl -tag -compact 306 | .It Cm challenge Ar string 307 | The name of the challenge type 308 | .Aq tls-alpn-01 . 309 | Mostly used in hooks with multiple types. 310 | .It Cm domain Ar string 311 | The domain name whom ownership is currently being validated. 312 | .It Cm env Ar array 313 | Array containing all the environment variables. 314 | .It Cm is_clean_hook Ar bool 315 | False 316 | .It Cm proof Ar string 317 | Plain-text representation of the 318 | .Em acmeIdentifier 319 | extension that should be used in the self-signed certificate presented when a TLS connection is initiated with the 320 | .Qd acme-tls/1 321 | ALPN extension value. 322 | .Xr acmed 8 323 | will not generate the certificate itself since it can be done using 324 | .Xr tacd 8 . 325 | .El 326 | .It Ic challenge-tls-alpn-01-clean 327 | Invoked once a domain ownership has been proven using the 328 | .Em tls-alpn-01 329 | challenge. This hook is intended to remove the proof since it is no longer required. The template variables are strictly identical to those given in the corresponding 330 | .Em challenge-tls-alpn-01 331 | hook, excepted 332 | .Em is_clean_hook 333 | which is set to 334 | .Em true . 335 | .It Ic file-pre-create 336 | Invoked 337 | .Em before 338 | a non-existent file 339 | .Em created . 340 | The available template variables are: 341 | .Bl -tag -compact 342 | .It Cm env Ar array 343 | Array containing all the environment variables. 344 | .It Cm file_directory Ar string 345 | Name of the directory where the impacted file is located. 346 | .It Cm file_name Ar string 347 | Name of the impacted file. 348 | .It Cm file_path Ar string 349 | Full path to the impacted file. 350 | .El 351 | .It Ic file-pre-edit 352 | Invoked 353 | .Em before 354 | an existent file 355 | .Em modified . 356 | The available template variables are the same as those available for the 357 | .Em file-pre-create 358 | type. 359 | .It Ic file-post-create 360 | Invoked 361 | .Em after 362 | a non-existent file 363 | .Em created . 364 | The available template variables are the same as those available for the 365 | .Em file-pre-create 366 | type. 367 | .It Ic file-post-edit 368 | Invoked 369 | .Em after 370 | an existent file 371 | .Em modified . 372 | The available template variables are the same as those available for the 373 | .Em file-pre-create 374 | type. 375 | .It Ic post-operation 376 | Invoked at the end of the certificate request process. The available template variables are: 377 | .Bl -tag -compact 378 | .It Cm algorithm Ar string 379 | Name of the algorithm used in the certificate. 380 | .It Cm domains Ar string 381 | Array containing the domain names included in the requested certificate. 382 | .It Cm env Ar array 383 | Array containing all the environment variables. 384 | .It Cm is_success Ar boolean 385 | True if the certificate request is successful. 386 | .It Cm status Ar string 387 | Human-readable status. If the certificate request failed, it contains the error description. 388 | .El 389 | .El 390 | .Sh DEFAULT HOOKS 391 | Because many people have the same needs, ACMEd comes with a set of hooks that should serve most situations. Hook names being unique, the following names and any other name starting by those is reserved and should not be used. 392 | .Bl -tag 393 | .It Pa git 394 | This hook uses 395 | .Xr git 1 396 | to archive private keys, public keys and certificates. It is possible to customize the commit username and email by using respectively the 397 | .Ev GIT_USERNAME 398 | and 399 | .Ev GIT_EMAIL 400 | environment variables. 401 | .It Pa http-01-echo 402 | This hook is designed to solve the http-01 challenge. For this purpose, it will write the proof into 403 | .Pa {{env.HTTP_ROOT}}/{{domain}}/.well-known/acme-challenge/{{file_name}} . 404 | .Pp 405 | The web server must be configured so the file 406 | .Pa http://{{domain}}/.well-known/acme-challenge/{{file_name}} 407 | can be accessed from the CA. 408 | .Pp 409 | If 410 | .Ev HTTP_ROOT 411 | is not specified, it will be set to 412 | .Pa /var/www . 413 | .It Pa tls-alpn-01-tacd-tcp 414 | This hook is designed to solve the tls-alpn-01 challenge using 415 | .Xr tacd 8 . 416 | It requires 417 | .Xr pkill 1 418 | to support the 419 | .Em Ar -F 420 | option. 421 | .Pp 422 | .Xr tacd 8 423 | will listen on the host defined by the 424 | .Ev TACD_HOST 425 | environment variable (default is the domain to be validated) and on the port defined by the 426 | .Ev TACD_PORT 427 | environment variable (default is 5001). 428 | .Pp 429 | .Xr tacd 8 430 | will store its pid into 431 | .Pa {{TACD_PID_ROOT}}/tacd_{{domain}}.pid . 432 | If 433 | .Ev TACD_PID_ROOT 434 | is not specified, it will be set to 435 | .Pa /run . 436 | .It Pa tls-alpn-01-tacd-unix 437 | This hook is designed to solve the tls-alpn-01 challenge using 438 | .Xr tacd 8 . 439 | It requires 440 | .Xr pkill 1 441 | to support the 442 | .Em Ar -F 443 | option. 444 | .Pp 445 | .Xr tacd 8 446 | will listen on the unix socket 447 | .Pa {{env.TACD_SOCK_ROOT}}/tacd_{{domain}}.sock . 448 | If 449 | .Ev TACD_SOCK_ROOT 450 | is not specified, it will be set to 451 | .Pa /run . 452 | .Pp 453 | .Xr tacd 8 454 | will store its pid into 455 | .Pa {{TACD_PID_ROOT}}/tacd_{{domain}}.pid . 456 | If 457 | .Ev TACD_PID_ROOT 458 | is not specified, it will be set to 459 | .Pa /run . 460 | .El 461 | .Sh TIME PERIODS 462 | ACMEd uses its own time period format, which is vaguely inspired by the ISO 8601 one. Periods are formatted as 463 | .Ar PM[PM...] 464 | where 465 | .Ar M 466 | is case sensitive character representing a length and 467 | .Ar P 468 | is an integer representing a multiplayer for the following length. The authorized length are: 469 | .Bl -dash -compact 470 | .It 471 | .Ar s : 472 | second 473 | .It 474 | .Ar m : 475 | minute 476 | .It 477 | .Ar h : 478 | hour 479 | .It 480 | .Ar d : 481 | day 482 | .It 483 | .Ar w : 484 | week 485 | .El 486 | The 487 | .Ar PM 488 | couples can be specified multiple times and in any order. 489 | .Pp 490 | For example, 491 | .Dq 1d42s and 492 | .Dq 40s20h4h2s 493 | both represents a period of one day and forty-two seconds. 494 | .Sh FILES 495 | .Bl -tag 496 | .It Pa /etc/acmed/acmed.toml 497 | Default 498 | .Xr acmed 8 499 | configuration file. 500 | .It Pa /etc/acmed/accounts 501 | Default accounts private and public keys directory. 502 | .It Pa /etc/acmed/certs 503 | Default certificates and associated private keys directory. 504 | .Sh EXAMPLES 505 | The following example defines a typical endpoint, account and certificate for a domain and several subdomains. 506 | .Bd -literal -offset indent 507 | [[endpoint]] 508 | name = "example name" 509 | url = "https://acme.example.org/directory" 510 | tos_agreed = true 511 | 512 | [[account]] 513 | name = "my test account" 514 | email = "certs@exemple.net" 515 | 516 | [[certificate]] 517 | endpoint = "example name" 518 | account = "my test account" 519 | domains = [ 520 | { dns = "exemple.net", challenge = "http-01"}, 521 | { dns = "1.exemple.net", challenge = "dns-01"}, 522 | { dns = "2.exemple.net", challenge = "tls-alpn-01", env.TACD_PORT="5010"}, 523 | { dns = "3.exemple.net", challenge = "tls-alpn-01", env.TACD_PORT="5011"}, 524 | ] 525 | hooks = ["git", "http-01-echo", "tls-alpn-01-tacd-tcp", "some-dns-01-hook"] 526 | env.HTTP_ROOT = "/srv/http" 527 | .Ed 528 | .Pp 529 | It is possible to use 530 | .Xr echo 1 531 | to solve the 532 | .Em http-01 533 | challenge and 534 | .Xr rm 1 535 | to clean it. 536 | .Xr mkdir 1 537 | and 538 | .Xr chmod 1 539 | are used to prevent issues related to file access. 540 | .Bd -literal -offset indent 541 | [[hook]] 542 | name = "http-01-echo-mkdir" 543 | type = ["challenge-http-01"] 544 | cmd = "mkdir" 545 | args = [ 546 | "-m", "0755", 547 | "-p", "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge" 548 | ] 549 | 550 | [[hook]] 551 | name = "http-01-echo-echo" 552 | type = ["challenge-http-01"] 553 | cmd = "echo" 554 | args = ["{{proof}}"] 555 | stdout = "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 556 | 557 | [[hook]] 558 | name = "http-01-echo-chmod" 559 | type = ["challenge-http-01-clean"] 560 | cmd = "chmod" 561 | args = [ 562 | "a+r", 563 | "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 564 | ] 565 | 566 | [[hook]] 567 | name = "http-01-echo-clean" 568 | type = ["challenge-http-01-clean"] 569 | cmd = "rm" 570 | args = [ 571 | "-f", 572 | "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{domain}}/.well-known/acme-challenge/{{file_name}}" 573 | ] 574 | .Ed 575 | .Pp 576 | The hooks from the previous example can be grouped in order to reduce the number of hooks to define in the certificate. 577 | .Bd -literal -offset indent 578 | [[group]] 579 | name = "http-01-echo-var-www" 580 | hooks = [ 581 | "http-01-echo-mkdir", 582 | "http-01-echo-echo", 583 | "http-01-echo-chmod", 584 | "http-01-echo-clean" 585 | ] 586 | 587 | [[certificate]] 588 | # Some fields omitted 589 | hooks = ["http-01-echo"] 590 | env.HTTP_ROOT = "/srv/http" 591 | .Ed 592 | .Pp 593 | 594 | It is also possible to use 595 | .Xr sendmail 8 596 | in a hook in order to notif someone when the certificate request process is done. 597 | .Bd -literal -offset indent 598 | [[hook]] 599 | name = "email-report" 600 | type = ["post-operation"] 601 | cmd = "sendmail" 602 | args = [ 603 | "-f", "noreply.certs@example.net", 604 | "contact@example.net" 605 | ] 606 | stdin_str = """Subject: Certificate renewal {{#if is_success}}succeeded{{else}}failed{{/if}} for {{domains.[0]}} 607 | 608 | The following certificate has {{#unless is_success}}*not* {{/unless}}been renewed. 609 | domains: {{#each domains}}{{#if @index}}, {{/if}}{{this}}{{/each}} 610 | algorithm: {{algorithm}} 611 | status: {{status}}""" 612 | .Ed 613 | .Sh SEE ALSO 614 | .Xr acmed 8 , 615 | .Xr tacd 8 616 | .Sh STANDARDS 617 | .Bl 618 | .It 619 | .Rs 620 | .%A Tom Preston-Werner 621 | .%D July 2018 622 | .%T TOML v0.5.0 623 | .%U https://github.com/toml-lang/toml 624 | .Re 625 | .It 626 | .Rs 627 | .%A Yehuda Katz 628 | .%T Handlebars 629 | .%U https://handlebarsjs.com/ 630 | .Re 631 | .El 632 | .Sh AUTHORS 633 | .An Rodolphe Bréard 634 | .Aq rodolphe@breard.tf 635 | --------------------------------------------------------------------------------