From 5c1472fe50d358e6c027aa32b70337fda7c4ed8c Mon Sep 17 00:00:00 2001 From: Condorra Date: Sun, 3 Nov 2024 21:36:49 +1100 Subject: [PATCH] Support getting a TLS certificate with ACME as an alternative option --- Cargo.lock | 482 ++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 + src/acme.rs | 211 ++++++++++++++++++ src/dynamic_config.rs | 172 +++++++++++++++ src/main.rs | 342 ++++++------------------------ src/ws_svc.rs | 129 +++++++++++ 6 files changed, 1049 insertions(+), 291 deletions(-) create mode 100644 src/acme.rs create mode 100644 src/dynamic_config.rs create mode 100644 src/ws_svc.rs diff --git a/Cargo.lock b/Cargo.lock index f685919..5b5fd03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index fd4a457..0a63995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/acme.rs b/src/acme.rs new file mode 100644 index 0000000..2ba545a --- /dev/null +++ b/src/acme.rs @@ -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 { + 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 = 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::>(), + }) + .await?; + + let mut cur_tokens: Vec<(String, String)> = vec![]; + let mut challenges_to_validate: Vec = 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, + config_data: Data, +) -> 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; + } + } + } + } + }); +} diff --git a/src/dynamic_config.rs b/src/dynamic_config.rs new file mode 100644 index 0000000..1cd9cce --- /dev/null +++ b/src/dynamic_config.rs @@ -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>, + pub certified_key: Option>, + pub static_root: String, + pub acme_challenge_tokens: BTreeMap, +} +pub type DynamicConfigLock = Arc>; + +#[derive(Debug)] +pub struct DynconfigCertResolver(pub DynamicConfigLock); + +impl ResolvesServerCert for DynconfigCertResolver { + fn resolve( + &self, + _client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + 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 for DynStaticFiles { + type Response = ServiceResponse; + type Error = Error; + type Config = (); + type Service = DynStaticFiles; + type InitError = (); + type Future = Ready>; + + fn new_service(&self, _: ()) -> Self::Future { + ready(Ok(self.clone())) + } +} + +impl Service for DynStaticFiles { + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + 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> { + let certs: Vec = rustls_pemfile::certs(&mut BufReader::new(&mut File::open( + &tls.certificate_chain_file, + )?)) + .collect::, 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 { + Ok(DynamicConfigData { + host_map: config + .muds + .iter() + .map(|c| { + ( + c.hostname.clone(), + MudConfig:: { + 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::>>(), + 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(()) +} diff --git a/src/main.rs b/src/main.rs index 6d563f7..aaefdf3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, } -#[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 { pub allowed_origins: Vec, } -fn with_mudconfig( - req: &HttpRequest, - config_data: Data, -) -> Result, 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, - 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::(response) -} - // We load this on each connection so it can change. fn fetch_startup_script(filename: &str) -> anyhow::Result { Ok(fs::read_to_string(filename)?) @@ -225,133 +119,13 @@ fn validate_config_file(config: &ServerConfig) { } } -#[derive(Debug)] -struct DynamicConfigData { - host_map: BTreeMap>, - certified_key: Option>, - static_root: String, -} -type DynamicConfigLock = Arc>; - -#[derive(Debug)] -struct DynconfigCertResolver(DynamicConfigLock); - -impl ResolvesServerCert for DynconfigCertResolver { - fn resolve( - &self, - _client_hello: rustls::server::ClientHello<'_>, - ) -> Option> { - 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 for DynStaticFiles { - type Response = ServiceResponse; - type Error = Error; - type Config = (); - type Service = DynStaticFiles; - type InitError = (); - type Future = Ready>; - - fn new_service(&self, _: ()) -> Self::Future { - ready(Ok(self.clone())) - } -} - -impl Service for DynStaticFiles { - type Response = ServiceResponse; - type Error = Error; - type Future = LocalBoxFuture<'static, Result>; - - 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 { - Ok(DynamicConfigData { - host_map: config - .muds - .iter() - .map(|c| { - ( - c.hostname.clone(), - MudConfig:: { - 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::>>(), - certified_key: match &config.tls { - None => None, - Some(tls) => { - let certs: Vec = rustls_pemfile::certs(&mut BufReader::new( - &mut File::open(&tls.certificate_chain_file)?, - )) - .collect::, 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 = 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; diff --git a/src/ws_svc.rs b/src/ws_svc.rs new file mode 100644 index 0000000..a500445 --- /dev/null +++ b/src/ws_svc.rs @@ -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, +) -> Result, 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, + 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::(response) +}