Support getting a TLS certificate with ACME as an alternative option

This commit is contained in:
Condorra 2024-11-03 21:36:49 +11:00
parent 4a8cd0c8a6
commit 5c1472fe50
6 changed files with 1049 additions and 291 deletions

482
Cargo.lock generated
View File

@ -63,8 +63,8 @@ dependencies = [
"encoding_rs",
"flate2",
"futures-core",
"h2",
"http",
"h2 0.3.26",
"http 0.2.12",
"httparse",
"httpdate",
"itoa",
@ -100,7 +100,7 @@ checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
dependencies = [
"bytestring",
"cfg-if",
"http",
"http 0.2.12",
"regex",
"regex-lite",
"serde",
@ -337,7 +337,7 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
dependencies = [
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -347,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
dependencies = [
"anstyle",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -356,6 +356,62 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-trait"
version = "0.1.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.3.0"
@ -559,6 +615,22 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.13"
@ -587,6 +659,26 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.3.11"
@ -619,6 +711,17 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
@ -676,7 +779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -710,6 +813,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.30"
@ -797,7 +909,26 @@ dependencies = [
"futures-core",
"futures-sink",
"futures-util",
"http",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.1.0",
"indexmap",
"slab",
"tokio",
@ -823,7 +954,7 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
dependencies = [
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -837,6 +968,40 @@ dependencies = [
"itoa",
]
[[package]]
name = "http"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.1.0",
]
[[package]]
name = "http-body-util"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
dependencies = [
"bytes",
"futures-util",
"http 1.1.0",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
@ -861,6 +1026,63 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2 0.4.6",
"http 1.1.0",
"http-body",
"httparse",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333"
dependencies = [
"futures-util",
"http 1.1.0",
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http 1.1.0",
"http-body",
"hyper",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "idna"
version = "0.5.0"
@ -887,6 +1109,28 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "instant-acme"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37221e690dcc5d0ea7c1f70decda6ae3495e72e8af06bca15e982193ffdf4fc4"
dependencies = [
"async-trait",
"base64",
"bytes",
"http 1.1.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"ring",
"rustls-pki-types",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -1046,7 +1290,7 @@ dependencies = [
"libc",
"log",
"wasi",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -1065,12 +1309,40 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.36.3"
@ -1080,12 +1352,27 @@ dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "parking_lot"
version = "0.12.3"
@ -1115,6 +1402,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
dependencies = [
"base64",
"serde",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -1212,6 +1509,19 @@ dependencies = [
"getrandom",
]
[[package]]
name = "rcgen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54077e1872c46788540de1ea3d7f4ccb1983d12f9aa909b234468676c1a36779"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.3"
@ -1268,7 +1578,7 @@ dependencies = [
"libc",
"spin",
"untrusted",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -1292,6 +1602,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "0.38.35"
@ -1302,7 +1621,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -1314,12 +1633,26 @@ dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.1.3"
@ -1354,12 +1687,44 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "schannel"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.23"
@ -1467,7 +1832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -1493,6 +1858,37 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror"
version = "1.0.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.3.36"
@ -1554,7 +1950,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@ -1626,6 +2022,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.40"
@ -1646,6 +2048,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.17.0"
@ -1714,6 +2122,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@ -1750,6 +2167,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -1833,14 +2259,44 @@ dependencies = [
"anyhow",
"env_logger",
"futures-util",
"instant-acme",
"log",
"rcgen",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"time",
"tokio",
"toml",
"wildmatch",
"x509-parser",
]
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]

View File

@ -12,11 +12,15 @@ actix-ws = "0.3.0"
anyhow = "1.0.86"
env_logger = "0.11.5"
futures-util = "0.3.30"
instant-acme = "0.7.2"
log = "0.4.22"
rcgen = "0.13.1"
rustls = "0.23.12"
rustls-pemfile = "2.1.3"
serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.127"
time = "0.3.36"
tokio = { version = "1.39.3", features = ["net", "macros", "tokio-macros", "rt-multi-thread", "signal"] }
toml = "0.8.19"
wildmatch = { version = "2.3.4", features = ["serde"] }
x509-parser = "0.16.0"

211
src/acme.rs Normal file
View File

@ -0,0 +1,211 @@
use std::{
fs::File,
io::{Read, Write},
};
use crate::{
dynamic_config::{rehash_config, DynamicConfigLock},
AcmeConfig, ServerConfig, TlsConfig,
};
use actix_web::{
error::ErrorNotFound,
get,
web::{self, Data},
Responder,
};
use anyhow::bail;
use instant_acme::{
Account, AuthorizationStatus, ChallengeType, Identifier, NewAccount, NewOrder, OrderStatus,
};
use log::{error, info, warn};
use rcgen::{CertificateParams, DistinguishedName, KeyPair};
use time::{Duration, OffsetDateTime};
use tokio::time::sleep;
use x509_parser::pem::parse_x509_pem;
// This explicitly checks not_after, since it is for renewal - we don't want
// to renew because a certificate isn't valid yet etc...
fn get_current_expiry(certfile: &str) -> anyhow::Result<OffsetDateTime> {
let mut contents = Vec::new();
File::open(certfile)?.read_to_end(&mut contents)?;
let pem = parse_x509_pem(&contents)?.1;
Ok(pem.parse_x509()?.validity.not_after.to_datetime())
}
async fn try_renew_certificate(
config_file: &str,
dynconfig: &DynamicConfigLock,
tls: &TlsConfig,
acme: &AcmeConfig,
) -> anyhow::Result<()> {
let mut contents = String::new();
let account: Account = match File::open(&acme.acme_account_file)
.and_then(|mut f| f.read_to_string(&mut contents))
.map_err(anyhow::Error::from)
.and_then(|_| serde_json::from_str(&contents).map_err(anyhow::Error::from))
{
Ok(creds) => Account::from_credentials(creds).await?,
Err(e) => {
info!(
"Can't load existing credentials ({}), going to create new ones.",
e
);
if !acme.contact_email.contains('@') {
bail!("contact_email configuration not set to a valid email");
}
let mut email_url = acme.contact_email.to_owned();
if !email_url.starts_with("mailto:") {
email_url = format!("mailto:{}", email_url);
}
let (account, account_creds) = instant_acme::Account::create(
&NewAccount {
contact: &[&email_url],
only_return_existing: false,
// We should document this as assumed somewhere.
terms_of_service_agreed: true,
},
&acme.acme_provider,
None,
)
.await?;
File::create(&acme.acme_account_file)?
.write_all(serde_json::to_string(&account_creds)?.as_bytes())?;
account
}
};
let hosts: Vec<String> = dynconfig.read().await.host_map.keys().cloned().collect();
let mut order = account
.new_order(&NewOrder {
identifiers: &hosts
.iter()
.map(|h| Identifier::Dns(h.clone()))
.collect::<Vec<Identifier>>(),
})
.await?;
let mut cur_tokens: Vec<(String, String)> = vec![];
let mut challenges_to_validate: Vec<String> = vec![];
for auth in &order.authorizations().await? {
if auth.status == AuthorizationStatus::Pending {
match auth
.challenges
.iter()
.find(|ch| ch.r#type == ChallengeType::Http01)
{
None => bail!(
"ACME provider didn't provide option for HTTP-01 verification for {:#?}",
auth.identifier
),
Some(ch) => {
cur_tokens.push((
ch.token.clone(),
order.key_authorization(ch).as_str().to_owned(),
));
challenges_to_validate.push(ch.url.clone());
}
}
}
}
dynconfig.write().await.acme_challenge_tokens = cur_tokens.into_iter().collect();
for ch in &challenges_to_validate {
order.set_challenge_ready(ch).await?;
}
loop {
sleep(Duration::seconds(10).unsigned_abs()).await;
let state = order.refresh().await?;
if state.status == OrderStatus::Invalid {
bail!("Certificate order went to Invalid status");
}
if state.status == OrderStatus::Ready {
break;
}
warn!("ACME validation is still not ready 10s after requesting validation. Trying again in 10s.");
}
// We are now fully validated, and ready to generate a CSR...
let mut cert_params: CertificateParams = CertificateParams::new(hosts)?;
cert_params.distinguished_name = DistinguishedName::new();
let keypair: KeyPair = KeyPair::generate()?;
let csr = cert_params.serialize_request(&keypair)?;
order.finalize(csr.der()).await?;
let cert = loop {
sleep(Duration::seconds(1).unsigned_abs()).await;
match order.certificate().await? {
None => {
warn!("ACME certificate still not ready after 10s. Waiting another 10s.");
}
Some(cert) => break cert,
}
};
File::create(&tls.certificate_chain_file)?.write_all(cert.as_bytes())?;
File::create(&tls.private_key_file)?.write_all(keypair.serialize_pem().as_bytes())?;
rehash_config(config_file, dynconfig).await?;
Ok(())
}
#[get("/.well-known/acme-challenge/{token}")]
pub async fn acme_challenge(
path: web::Path<String>,
config_data: Data<DynamicConfigLock>,
) -> impl Responder {
let resp = config_data
.read()
.await
.acme_challenge_tokens
.get(&path.into_inner())
.cloned();
match resp {
None => Err(ErrorNotFound("No such token expected")),
Some(resp) => Ok(resp),
}
}
pub async fn start_acme_if_appropriate(
config_file: &str,
dynconfig: &DynamicConfigLock,
config: &ServerConfig,
) {
let config = config.clone();
let config_file = config_file.to_owned();
let dynconfig = dynconfig.clone();
tokio::spawn(async move {
if let Some(tls) = &config.tls {
if let Some(acme) = &tls.acme {
loop {
if let Ok(expiry) = get_current_expiry(&tls.certificate_chain_file) {
let check_horizon = OffsetDateTime::now_utc()
+ Duration::days(acme.renew_if_days_left as i64);
if expiry >= check_horizon {
let time_left = expiry - check_horizon;
info!("Certificate is valid for {} - rechecking then.", time_left);
sleep(time_left.unsigned_abs()).await;
continue;
}
info!("Certificate found but due for expiry.");
} else {
info!("No valid certificate - requesting one.");
}
if let Err(e) = try_renew_certificate(&config_file, &dynconfig, tls, acme).await
{
error!(
"Renewing certificate failed with error: {}. Trying again in one day.",
e
);
sleep(Duration::days(1).unsigned_abs()).await;
}
}
}
}
});
}

172
src/dynamic_config.rs Normal file
View File

@ -0,0 +1,172 @@
use actix_files::Files;
use actix_web::{
self,
body::BoxBody,
dev::{
self, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
ServiceResponse,
},
error::ErrorInternalServerError,
Error,
};
use anyhow::anyhow;
use futures_util::future::LocalBoxFuture;
use log::error;
use rustls::{
crypto::{aws_lc_rs, CryptoProvider},
pki_types::CertificateDer,
server::ResolvesServerCert,
sign::CertifiedKey,
};
use std::{
collections::BTreeMap,
fs::File,
future::{ready, Ready},
io::{self, BufReader},
sync::Arc,
};
use tokio::sync::RwLock;
use wildmatch::WildMatch;
use crate::{load_config_file, validate_config_file, MudConfig, ServerConfig, TlsConfig};
#[derive(Debug)]
pub struct DynamicConfigData {
pub host_map: BTreeMap<String, MudConfig<WildMatch>>,
pub certified_key: Option<Arc<CertifiedKey>>,
pub static_root: String,
pub acme_challenge_tokens: BTreeMap<String, String>,
}
pub type DynamicConfigLock = Arc<RwLock<DynamicConfigData>>;
#[derive(Debug)]
pub struct DynconfigCertResolver(pub DynamicConfigLock);
impl ResolvesServerCert for DynconfigCertResolver {
fn resolve(
&self,
_client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
self.0
.try_read()
.ok()
.and_then(|v| v.certified_key.as_ref().cloned())
}
}
#[derive(Clone)]
pub struct DynStaticFiles(pub DynamicConfigLock);
impl HttpServiceFactory for DynStaticFiles {
fn register(self, config: &mut actix_web::dev::AppService) {
let rdef = if config.is_root() {
ResourceDef::root_prefix("/")
} else {
ResourceDef::prefix("/")
};
config.register_service(rdef, None, self, None)
}
}
impl ServiceFactory<ServiceRequest> for DynStaticFiles {
type Response = ServiceResponse;
type Error = Error;
type Config = ();
type Service = DynStaticFiles;
type InitError = ();
type Future = Ready<Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
ready(Ok(self.clone()))
}
}
impl Service<ServiceRequest> for DynStaticFiles {
type Response = ServiceResponse<BoxBody>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
dev::always_ready!();
fn call(&self, req: ServiceRequest) -> Self::Future {
let dynconfig = self.0.clone();
Box::pin(async move {
let files =
Files::new("/", &dynconfig.read().await.static_root).index_file("index.html");
files
.new_service(())
.await
.map_err(|_| ErrorInternalServerError("Internal service error"))?
.call(req)
.await
})
}
}
fn load_certified_key(tls: &TlsConfig) -> anyhow::Result<Arc<CertifiedKey>> {
let certs: Vec<CertificateDer> = rustls_pemfile::certs(&mut BufReader::new(&mut File::open(
&tls.certificate_chain_file,
)?))
.collect::<Result<Vec<CertificateDer>, io::Error>>()?;
let key =
rustls_pemfile::private_key(&mut BufReader::new(&mut File::open(&tls.private_key_file)?))?
.ok_or_else(|| anyhow!("No private key found in private key file"))?;
let key = aws_lc_rs::default_provider()
.key_provider
.load_private_key(key)?;
CryptoProvider::get_default();
Ok(CertifiedKey::new(certs, key).into())
}
pub fn make_dynamic_config_data(config: &ServerConfig) -> anyhow::Result<DynamicConfigData> {
Ok(DynamicConfigData {
host_map: config
.muds
.iter()
.map(|c| {
(
c.hostname.clone(),
MudConfig::<WildMatch> {
hostname: c.hostname.clone(),
upstream_mud: c.upstream_mud.clone(),
banner_to_mud: c.banner_to_mud.clone(),
startup_script_file: c.startup_script_file.clone(),
allowed_origins: c
.allowed_origins
.iter()
.map(|ao| WildMatch::new(ao))
.collect(),
},
)
})
.collect::<BTreeMap<String, MudConfig<WildMatch>>>(),
certified_key: match &config.tls {
None => None,
Some(tls) => match load_certified_key(tls) {
Ok(ck) => Some(ck),
Err(e) => {
error!(
"Couldn't load TLS certs: {}. Will continue in case updated data \
is coming after rehash, but the server won't work for now.",
e
);
None
}
},
},
static_root: config.static_root.clone(),
acme_challenge_tokens: BTreeMap::new(),
})
}
pub async fn rehash_config(
config_file: &str,
dynconfig_lock: &DynamicConfigLock,
) -> anyhow::Result<()> {
let config: ServerConfig = load_config_file(config_file)?;
validate_config_file(&config);
let dynconfig: DynamicConfigData = make_dynamic_config_data(&config)?;
*dynconfig_lock.write().await = dynconfig;
Ok(())
}

View File

@ -1,59 +1,65 @@
use actix_files::Files;
use actix_web::{
self,
body::BoxBody,
dev::{
self, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
ServiceResponse,
},
error::{
ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorNotFound, InternalError,
},
get,
http::{
header::{HOST, ORIGIN},
StatusCode,
},
middleware::Logger,
rt::{self, net::TcpStream},
web::{self, Data},
App, Error, HttpRequest, HttpResponse, HttpServer, Responder,
use acme::{acme_challenge, start_acme_if_appropriate};
use actix_web::{middleware::Logger, web::Data, App, HttpServer};
use dynamic_config::{
make_dynamic_config_data, rehash_config, DynStaticFiles, DynamicConfigLock,
DynconfigCertResolver,
};
use actix_ws::{AggregatedMessage, CloseCode, Closed};
use futures_util::{future::LocalBoxFuture, FutureExt, StreamExt};
use futures_util::FutureExt;
use log::{info, warn};
use rustls::{
crypto::{aws_lc_rs, CryptoProvider},
pki_types::CertificateDer,
server::ResolvesServerCert,
sign::CertifiedKey,
};
use serde::Deserialize;
use serde_json::json;
use std::{
collections::BTreeMap,
env,
fs::{self, File},
future::{ready, Ready},
io::{self, BufReader},
sync::{Arc, RwLock},
};
use std::{env, fs, sync::Arc};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
select,
signal::unix::{signal, SignalKind},
spawn,
sync::oneshot,
sync::{oneshot, RwLock},
};
use wildmatch::WildMatch;
use ws_svc::ws;
#[derive(Deserialize)]
mod acme;
mod dynamic_config;
mod ws_svc;
#[derive(Deserialize, Clone)]
#[serde(default)]
pub struct TlsConfig {
pub private_key_file: String,
pub certificate_chain_file: String,
pub acme: Option<AcmeConfig>,
}
#[derive(Deserialize)]
impl Default for TlsConfig {
fn default() -> Self {
Self {
private_key_file: "/etc/privkey.pem".to_owned(),
certificate_chain_file: "/etc/fullchain.pem".to_owned(),
acme: None,
}
}
}
#[derive(Deserialize, Clone)]
#[serde(default)]
pub struct AcmeConfig {
pub contact_email: String,
pub acme_account_file: String,
pub acme_provider: String,
pub renew_if_days_left: u8,
}
impl Default for AcmeConfig {
fn default() -> Self {
Self {
contact_email: "".to_owned(),
acme_account_file: "/etc/acme_account.txt".to_owned(),
acme_provider: "https://acme-v02.api.letsencrypt.org/directory".to_owned(),
renew_if_days_left: 30,
}
}
}
#[derive(Deserialize, Clone)]
#[serde(default)]
pub struct ServerConfig {
pub listen_port: u16,
pub bind_address: String,
@ -83,118 +89,6 @@ pub struct MudConfig<WildType> {
pub allowed_origins: Vec<WildType>,
}
fn with_mudconfig(
req: &HttpRequest,
config_data: Data<DynamicConfigLock>,
) -> Result<MudConfig<WildMatch>, Error> {
match req.headers().get(&HOST) {
None => Err(ErrorBadRequest("Missing Host header"))?,
Some(host) => match config_data
.read()
.unwrap()
.host_map
.get(host.to_str().unwrap_or("invalid"))
{
None => Err(ErrorNotFound(
"No MUD matching your request is currently enabled",
))?,
Some(v) => Ok(v.clone()),
},
}
}
#[get("/ws")]
async fn ws(
config_data: Data<DynamicConfigLock>,
req: HttpRequest,
body: web::Payload,
) -> impl Responder {
let mud = with_mudconfig(&req, config_data)?;
match req.headers().get(&ORIGIN) {
None => Err(ErrorForbidden("Missing origin"))?,
Some(origin) => {
if !mud
.allowed_origins
.iter()
.any(|o| o.matches(origin.to_str().unwrap_or("invalid")))
{
Err(ErrorForbidden("Disallowed origin"))?;
}
}
}
let (response, mut session, stream) = actix_ws::handle(&req, body)?;
let mut stream = stream.aggregate_continuations().max_continuation_size(1024);
let mut tcp_stream: TcpStream = TcpStream::connect(&mud.upstream_mud).await?;
let subst_banner = mud
.banner_to_mud
.replace(
"%i",
&req.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_owned()),
)
.replace("%n", "\r\n");
tcp_stream.write_all(subst_banner.as_bytes()).await?;
let script = fetch_startup_script(&mud.startup_script_file)
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
rt::spawn(async move {
if session
.text(
json!({
"RunLua": script
})
.to_string(),
)
.await
.is_err()
{
let _ = session.close(Some(CloseCode::Normal.into())).await;
return;
}
let mut readbuf: [u8; 1024] = [0; 1024];
loop {
tokio::select! {
ws_msg = stream.next() => {
match ws_msg {
None => break,
Some(Err(_e)) => break,
Some(Ok(AggregatedMessage::Binary(bin))) => {
if tcp_stream.write_all(&bin).await.is_err() {
break
}
}
Some(Ok(AggregatedMessage::Ping(msg))) => {
if let Err(Closed) = session.pong(&msg).await {
break
}
}
Some(Ok(_)) => {},
}
},
tcp_data_len = tcp_stream.read(&mut readbuf) => {
match tcp_data_len {
Err(_e) => break,
Ok(0) => break,
Ok(n) =>
if let Err(Closed) = session.binary(readbuf[0..n].to_vec()).await {
break;
}
}
}
}
}
let _ = session.close(Some(CloseCode::Normal.into())).await;
});
Ok::<HttpResponse, Error>(response)
}
// We load this on each connection so it can change.
fn fetch_startup_script(filename: &str) -> anyhow::Result<String> {
Ok(fs::read_to_string(filename)?)
@ -225,133 +119,13 @@ fn validate_config_file(config: &ServerConfig) {
}
}
#[derive(Debug)]
struct DynamicConfigData {
host_map: BTreeMap<String, MudConfig<WildMatch>>,
certified_key: Option<Arc<CertifiedKey>>,
static_root: String,
}
type DynamicConfigLock = Arc<RwLock<DynamicConfigData>>;
#[derive(Debug)]
struct DynconfigCertResolver(DynamicConfigLock);
impl ResolvesServerCert for DynconfigCertResolver {
fn resolve(
&self,
_client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
self.0.read().unwrap().certified_key.as_ref().cloned()
}
}
#[derive(Clone)]
struct DynStaticFiles(DynamicConfigLock);
impl HttpServiceFactory for DynStaticFiles {
fn register(self, config: &mut actix_web::dev::AppService) {
let rdef = if config.is_root() {
ResourceDef::root_prefix("/")
} else {
ResourceDef::prefix("/")
};
config.register_service(rdef, None, self, None)
}
}
impl ServiceFactory<ServiceRequest> for DynStaticFiles {
type Response = ServiceResponse;
type Error = Error;
type Config = ();
type Service = DynStaticFiles;
type InitError = ();
type Future = Ready<Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
ready(Ok(self.clone()))
}
}
impl Service<ServiceRequest> for DynStaticFiles {
type Response = ServiceResponse<BoxBody>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
dev::always_ready!();
fn call(&self, req: ServiceRequest) -> Self::Future {
let files = Files::new("/", &self.0.read().unwrap().static_root).index_file("index.html");
Box::pin(async move {
files
.new_service(())
.await
.map_err(|_| ErrorInternalServerError("Internal service error"))?
.call(req)
.await
})
}
}
fn make_dynamic_config_data(config: &ServerConfig) -> anyhow::Result<DynamicConfigData> {
Ok(DynamicConfigData {
host_map: config
.muds
.iter()
.map(|c| {
(
c.hostname.clone(),
MudConfig::<WildMatch> {
hostname: c.hostname.clone(),
upstream_mud: c.upstream_mud.clone(),
banner_to_mud: c.banner_to_mud.clone(),
startup_script_file: c.startup_script_file.clone(),
allowed_origins: c
.allowed_origins
.iter()
.map(|ao| WildMatch::new(ao))
.collect(),
},
)
})
.collect::<BTreeMap<String, MudConfig<WildMatch>>>(),
certified_key: match &config.tls {
None => None,
Some(tls) => {
let certs: Vec<CertificateDer> = rustls_pemfile::certs(&mut BufReader::new(
&mut File::open(&tls.certificate_chain_file)?,
))
.collect::<Result<Vec<CertificateDer>, io::Error>>()?;
let key = rustls_pemfile::private_key(&mut BufReader::new(&mut File::open(
&tls.private_key_file,
)?))?
.expect("No private key found in private key file");
let key = aws_lc_rs::default_provider()
.key_provider
.load_private_key(key)?;
CryptoProvider::get_default();
Some(CertifiedKey::new(certs, key).into())
}
},
static_root: config.static_root.clone(),
})
}
fn rehash_config(config_file: &str, dynconfig_lock: &DynamicConfigLock) -> anyhow::Result<()> {
let config: ServerConfig = load_config_file(config_file)?;
validate_config_file(&config);
let dynconfig: DynamicConfigData = make_dynamic_config_data(&config)?;
let dcref: &mut DynamicConfigData = &mut dynconfig_lock.write().unwrap();
*dcref = dynconfig;
Ok(())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.init();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let args: Vec<String> = env::args().collect();
let config_file = match args.get(1) {
None => "/etc/worldwideportal_server.toml".to_owned(),
@ -366,6 +140,8 @@ async fn main() -> anyhow::Result<()> {
validate_config_file(&config);
let dynconfig: DynamicConfigLock = RwLock::new(make_dynamic_config_data(&config)?).into();
start_acme_if_appropriate(&config_file, &dynconfig, &config).await;
// This is a double Arc, but actix doesn't provide an alternative.
let data = Data::new(dynconfig.clone());
@ -378,14 +154,24 @@ async fn main() -> anyhow::Result<()> {
.app_data(server_data.clone())
.default_service(static_file_svc.clone())
.service(ws)
.service(acme_challenge)
});
let server = match &dynconfig.read().unwrap().certified_key {
let server = match &config.tls {
None => server.bind((config.bind_address.clone(), config.listen_port))?,
Some(_tls) => {
Some(tls) => {
let tls_config = rustls::server::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(DynconfigCertResolver(dynconfig.clone())));
let server = if tls.acme.is_some() {
// We always bind port 80 if they are using ACME - it is essential for
// the http-01 challenge to work (and must be that port).
server.bind((config.bind_address.clone(), 80))?
} else {
server
};
server.bind_rustls_0_23(
(config.bind_address.clone(), config.listen_port),
tls_config,
@ -400,7 +186,7 @@ async fn main() -> anyhow::Result<()> {
loop {
select! {
_ = sighup.recv() => {
let _ = rehash_config(&config_file, &dynconfig);
let _ = rehash_config(&config_file, &dynconfig).await;
}
_ = exit_rx.clone() => {
return;

129
src/ws_svc.rs Normal file
View File

@ -0,0 +1,129 @@
use crate::{dynamic_config::DynamicConfigLock, fetch_startup_script, MudConfig};
use actix_web::{
error::{ErrorBadRequest, ErrorForbidden, ErrorNotFound, InternalError},
get,
http::{
header::{HOST, ORIGIN},
StatusCode,
},
rt::{self, net::TcpStream},
web::{self, Data},
Error, HttpRequest, HttpResponse, Responder,
};
use actix_ws::{AggregatedMessage, CloseCode, Closed};
use futures_util::StreamExt;
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use wildmatch::WildMatch;
async fn with_mudconfig(
req: &HttpRequest,
config_data: Data<DynamicConfigLock>,
) -> Result<MudConfig<WildMatch>, Error> {
match req.headers().get(&HOST) {
None => Err(ErrorBadRequest("Missing Host header"))?,
Some(host) => match config_data
.read()
.await
.host_map
.get(host.to_str().unwrap_or("invalid"))
{
None => Err(ErrorNotFound(
"No MUD matching your request is currently enabled",
))?,
Some(v) => Ok(v.clone()),
},
}
}
#[get("/ws")]
pub async fn ws(
config_data: Data<DynamicConfigLock>,
req: HttpRequest,
body: web::Payload,
) -> impl Responder {
let mud = with_mudconfig(&req, config_data).await?;
match req.headers().get(&ORIGIN) {
None => Err(ErrorForbidden("Missing origin"))?,
Some(origin) => {
if !mud
.allowed_origins
.iter()
.any(|o| o.matches(origin.to_str().unwrap_or("invalid")))
{
Err(ErrorForbidden("Disallowed origin"))?;
}
}
}
let (response, mut session, stream) = actix_ws::handle(&req, body)?;
let mut stream = stream.aggregate_continuations().max_continuation_size(1024);
let mut tcp_stream: TcpStream = TcpStream::connect(&mud.upstream_mud).await?;
let subst_banner = mud
.banner_to_mud
.replace(
"%i",
&req.peer_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_owned()),
)
.replace("%n", "\r\n");
tcp_stream.write_all(subst_banner.as_bytes()).await?;
let script = fetch_startup_script(&mud.startup_script_file)
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))?;
rt::spawn(async move {
if session
.text(
json!({
"RunLua": script
})
.to_string(),
)
.await
.is_err()
{
let _ = session.close(Some(CloseCode::Normal.into())).await;
return;
}
let mut readbuf: [u8; 1024] = [0; 1024];
loop {
tokio::select! {
ws_msg = stream.next() => {
match ws_msg {
None => break,
Some(Err(_e)) => break,
Some(Ok(AggregatedMessage::Binary(bin))) => {
if tcp_stream.write_all(&bin).await.is_err() {
break
}
}
Some(Ok(AggregatedMessage::Ping(msg))) => {
if let Err(Closed) = session.pong(&msg).await {
break
}
}
Some(Ok(_)) => {},
}
},
tcp_data_len = tcp_stream.read(&mut readbuf) => {
match tcp_data_len {
Err(_e) => break,
Ok(0) => break,
Ok(n) =>
if let Err(Closed) = session.binary(readbuf[0..n].to_vec()).await {
break;
}
}
}
}
}
let _ = session.close(Some(CloseCode::Normal.into())).await;
});
Ok::<HttpResponse, Error>(response)
}