diff --git a/.rustfmt.toml b/.rustfmt.toml
index 750ac8a..d1afa00 100644
--- a/.rustfmt.toml
+++ b/.rustfmt.toml
@@ -6,3 +6,6 @@ reorder_modules = true
match_block_trailing_comma = true
use_field_init_shorthand = true
use_small_heuristics = "Off"
+# clean up multiple use crate:: that share the same root import
+# run with cargo +nightly fmt
+imports_granularity = "Crate"
diff --git a/Cargo.lock b/Cargo.lock
index c773153..5cd9b62 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -121,9 +121,9 @@ dependencies = [
[[package]]
name = "arc-swap"
-version = "1.9.1"
+version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
dependencies = [
"rustversion",
]
@@ -146,7 +146,7 @@ dependencies = [
"nom",
"num-traits",
"rusticata-macros",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
"time",
]
@@ -175,9 +175,9 @@ dependencies = [
[[package]]
name = "async-trait"
-version = "0.1.91"
+version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@@ -203,7 +203,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
- "untrusted 0.7.1",
"zeroize",
]
@@ -427,6 +426,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "chrono"
version = "0.4.45"
@@ -517,6 +533,16 @@ dependencies = [
"memchr",
]
+[[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"
version = "0.10.1"
@@ -533,6 +559,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -725,6 +760,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -803,6 +847,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -924,8 +977,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -936,10 +991,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
[[package]]
name = "getset"
version = "0.1.6"
@@ -1036,9 +1105,9 @@ dependencies = [
[[package]]
name = "http"
-version = "1.4.2"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
@@ -1123,18 +1192,23 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
"libc",
+ "percent-encoding",
"pin-project-lite",
"socket2",
+ "system-configuration",
"tokio",
"tower-service",
"tracing",
+ "windows-registry",
]
[[package]]
@@ -1161,12 +1235,115 @@ dependencies = [
"cc",
]
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1194,7 +1371,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64"
dependencies = [
"async-trait",
- "aws-lc-rs",
"base64",
"bytes",
"http",
@@ -1205,14 +1381,21 @@ dependencies = [
"hyper-rustls",
"hyper-util",
"rcgen",
+ "ring",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
"tokio",
]
+[[package]]
+name = "ipnet"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1246,7 +1429,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
"walkdir",
"windows-link",
]
@@ -1337,6 +1520,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
[[package]]
name = "local-ip-address"
version = "0.6.12"
@@ -1372,6 +1561,12 @@ dependencies = [
"hashbrown 0.16.1",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1387,6 +1582,12 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1869,6 +2070,15 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -1940,18 +2150,79 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
name = "proxy"
version = "0.2.3"
dependencies = [
+ "arc-swap",
"async-trait",
"chrono",
"http",
"instant-acme",
"pingora",
"rcgen",
+ "reqwest",
+ "rustls",
"serde",
- "thiserror 2.0.19",
+ "serde_json",
+ "thiserror 2.0.20",
"tokio",
"toml",
]
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.20",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.20",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -1967,6 +2238,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
version = "0.8.6"
@@ -1988,6 +2265,17 @@ dependencies = [
"rand_core 0.9.5",
]
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -2026,13 +2314,27 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rcgen"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
- "aws-lc-rs",
"pem",
"ring",
"rustls-pki-types",
@@ -2079,6 +2381,46 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -2089,7 +2431,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
- "untrusted 0.9.0",
+ "untrusted",
"windows-sys 0.52.0",
]
@@ -2154,12 +2496,14 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.40"
+version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
+ "log",
"once_cell",
+ "ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -2184,6 +2528,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -2193,7 +2538,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
- "core-foundation",
+ "core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
@@ -2223,7 +2568,7 @@ dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
- "untrusted 0.9.0",
+ "untrusted",
]
[[package]]
@@ -2269,7 +2614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2323,9 +2668,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.149"
+version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -2427,6 +2772,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -2494,6 +2845,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -2505,6 +2865,27 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.11.1",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2516,11 +2897,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.19"
+version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
- "thiserror-impl 2.0.19",
+ "thiserror-impl 2.0.20",
]
[[package]]
@@ -2536,9 +2917,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
-version = "2.0.19"
+version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@@ -2595,6 +2976,31 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokio"
version = "1.53.1"
@@ -2670,9 +3076,9 @@ dependencies = [
[[package]]
name = "toml"
-version = "1.1.3+spec-1.1.0"
+version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap 2.14.0",
"serde_core",
@@ -2694,9 +3100,9 @@ dependencies = [
[[package]]
name = "toml_parser"
-version = "1.1.2+spec-1.1.0"
+version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
@@ -2707,6 +3113,45 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.11.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
[[package]]
name = "tower-service"
version = "0.3.3"
@@ -2783,15 +3228,27 @@ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
-version = "0.7.1"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
-name = "untrusted"
-version = "0.9.0"
+name = "url"
+version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
@@ -2852,6 +3309,16 @@ dependencies = [
"wasm-bindgen-shared",
]
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
@@ -2884,6 +3351,26 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "web-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
@@ -2965,6 +3452,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3086,6 +3584,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
[[package]]
name = "x509-parser"
version = "0.18.1"
@@ -3093,7 +3597,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
- "aws-lc-rs",
"data-encoding",
"der-parser",
"lazy_static",
@@ -3101,7 +3604,7 @@ dependencies = [
"oid-registry",
"ring",
"rusticata-macros",
- "thiserror 2.0.19",
+ "thiserror 2.0.20",
"time",
]
@@ -3115,6 +3618,29 @@ dependencies = [
"time",
]
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -3135,12 +3661,66 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/Cargo.toml b/Cargo.toml
index adfe95c..2a8f7d5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,25 +19,38 @@ unused = { level = "allow", priority = -1 }
[dependencies]
# Async
-async-trait = "0.1.91"
+async-trait = "0.1.92"
tokio = { version = "1.53.1", features = ["full"] }
+# Arc
+arc-swap = "1.9.2"
+
# Certs
# used Ring as backend for default
rcgen = { version = "0.14.8", features = ["default"] }
# match the backed from rcgen
-instant-acme = { version = "0.8.5", features = ["rcgen"] }
+instant-acme = { version = "0.8.5", default-features = false, features = [
+ "ring",
+ "rcgen",
+ "hyper-rustls",
+] }
+rustls = "0.23.43"
+
# TOML
-toml = "1.1.3"
+toml = "1.1.4"
# Json
serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
# Others
-thiserror = "2.0.19"
+thiserror = "2.0.20"
chrono = { version = "0.4.45", features = ["serde"] }
# HTTP
-http = "1.4.2"
+http = "1.5.0"
pingora = { version = "0.8.1", features = ["proxy", "boringssl"] }
+
+# HTTP Client
+reqwest = { version = "0.13.4", features = ["json"] }
diff --git a/README.md b/README.md
index 7029610..705aa30 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,34 @@
-# Reverse Proxy
+# reverse-proxy
-> A small, fast, single-binary reverse proxy with TLS termination and per-host routing. Drop in a TOML file, point DNS at it, done.
+A reverse proxy for self-hosting, in a single binary. You point it at your hosts in a TOML file, it terminates TLS where you ask it to, and forwards to your upstreams. Built on Cloudflare's [Pingora](https://github.com/cloudflare/pingora) (the engine behind a chunk of their edge) with BoringSSL for TLS, and shipped as a hardened container image: non-root, read-only rootfs, no shell.
-

-
-Built in Rust on top of Cloudflare's [Pingora](https://github.com/cloudflare/pingora), shipped as a hardened container (~few MB, non-root, read-only rootfs). Designed for self-hosting: one process in front of all your services, configured from a single file.
+
## Why
-I normally use `nginxproxy/nginx-proxy` for my self-hosted projects. It works, but every container has to live on the same Docker network with a `VIRTUAL_HOST` label. That couples the proxy to the apps in a way I don't love.
+I run `nginxproxy/nginx-proxy` for my self-hosted projects. It works, but it only talks to apps that live on the same Docker network with `VIRTUAL_HOST` labels, and that coupling between the proxy and my deployment setup never sat right with me.
-So I wrote my own (by hand, old-school, no AI writing the code) to learn the internals and end up with something that:
+So I wrote my own (by hand, old-school, no AI writing the code) to learn how a proxy actually works. What came out of it:
-- Has **one place** to declare what gets proxied (a TOML file).
-- Doesn't care how upstreams are deployed: containers, host processes, anywhere reachable.
-- Boots fast, binds two ports, terminates TLS, forwards. That's it.
+- One file declares what gets proxied.
+- It doesn't care how your apps are deployed. Container, host process, a machine on the LAN, anything it can reach.
+- It boots, binds 80 and 443, terminates TLS, forwards. That's it.
## Features
-| Status | Feature |
-| ------ | -------------------------------------------------------- |
-| done | HTTP & HTTPS listeners (Pingora + BoringSSL) |
-| done | Per-host routing from a single `config.toml` |
-| done | Self-signed certificate generation (`rcgen`) on boot |
-| done | Per-route TLS mode: `self_signed`, `acme`, `none` |
-| done | HTTP → HTTPS redirect (301) for routes with a cert |
-| done | Hardened container: non-root, read-only fs, dropped caps |
-| done | Reach apps on the host via `host.docker.internal` |
-| wip | Let's Encrypt / ACME issuance (`rustls-acme`) |
-| todo | Structured logs (errors today, access logs next) |
-| todo | Full test coverage for behavioural guarantees |
-| todo | Performance benchmarks |
+- HTTP and HTTPS listeners (both always on)
+- Routing by `Host` header from a single TOML file
+- Self-signed certificates, generated on boot for `self_signed` routes
+- A 301 redirect from HTTP to HTTPS for routes that have a cert
+- ACME / Let's Encrypt: account creation, an hourly renewal loop, and a Cloudflare DNS provider (end-to-end issuance is still WIP, see the roadmap)
+- A hardened container image: non-root, read-only rootfs, all capabilities dropped
+- Multi-arch images (amd64, arm64) published to Docker Hub and GHCR per release
## Quick start
-The image is published to Docker Hub as `maxvanderschee/reverse-proxy` (also available on GHCR as `ghcr.io/mvdschee/reverse-proxy`). Two things to provide:
+You provide two things: a `config.toml` with your hosts, and a compose file.
-1. A `config.toml` describing your routes.
-2. A `docker-compose.yml` that mounts it.
+The image is published as `maxvanderschee/reverse-proxy` on Docker Hub and as `ghcr.io/mvdschee/reverse-proxy` on GHCR. You get multi-arch builds (amd64, arm64) per release tag, plus `latest`.
**`config.toml`**
@@ -54,6 +45,23 @@ cert_type = "none"
host = "api.example.com"
upstream = "host.docker.internal:8000"
cert_type = "self_signed"
+
+# ACME is still a work in progress (see the roadmap): the account, the
+# renewal loop and the DNS provider are in, but an order is never finalized,
+# so until that lands this route gets no cert and its HTTPS side won't serve
+# TLS. Keep it here as a shape reference, or flip it to self_signed if you
+# want this host actually working.
+[[routes]]
+host = "blog.example.com"
+upstream = "host.docker.internal:2000"
+cert_type = "acme"
+
+# needed for the DNS-01 challenge. the renewal loop skips acme routes that
+# don't have a provider. the challenge prefix is always _acme-challenge.,
+# it can't be configured.
+[routes.dns_provider.cloudflare]
+zone_id = "your-cloudflare-zone-id"
+api_token = "your-cloudflare-api-token"
```
**`docker-compose.yml`**
@@ -63,6 +71,8 @@ services:
proxy:
image: maxvanderschee/reverse-proxy:latest # or ghcr.io/mvdschee/reverse-proxy:latest
restart: unless-stopped
+ # the image runs non-root and can't bind 80/443, hence 8080/8443
+ # (see "The container image" below)
ports:
- "80:8080"
- "443:8443"
@@ -76,93 +86,91 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
- tmpfs:
- - /tmp
+ healthcheck:
+ test: ["CMD", "nc", "-z", "127.0.0.1", "8080"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
volumes:
proxy-certs:
```
-Then:
+Then `docker compose up -d`, point your DNS at the box, and you're done. Requests for a configured host get proxied, and everything else gets a `421 Misdirected Request`.
-```sh
-docker compose up -d
-```
+## Config
-Point DNS at the host, and traffic on `:80`/`:443` for the configured hosts will be terminated and proxied to the upstreams.
+The file is small: an `[acme]` table and a list of `[[routes]]`. The full schema, with comments, lives in [`example/example.toml`](example/example.toml).
-## Configuration
+| Field | Required | What it does |
+| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `acme.email` | yes | Contact email for the Let's Encrypt account. Required by the parser even if every route is `none`. |
+| `routes[].host` | yes | The `Host` header to match, e.g. `app.example.com`. |
+| `routes[].upstream` | yes | The `host:port` to forward to, over plain HTTP. Use `host.docker.internal:` to reach apps running on the Docker host. |
+| `routes[].cert_type` | no | `none` (HTTP only), `self_signed`, or `acme` (WIP, see the roadmap). Defaults to `none`. |
+| `routes[].dns_provider` | no | Only meaningful for `acme` routes. The DNS provider used for the DNS-01 challenge. Currently Cloudflare only (`zone_id` + `api_token`); `acme` routes without it are skipped by the renewal loop. |
-The full schema lives in [`example/example.toml`](example/example.toml). The fields:
+A couple of things worth knowing:
-| Field | Required | Description |
-| -------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
-| `acme.email` | yes | Contact email for Let's Encrypt (used once ACME lands; required today even if every route is `none`). |
-| `routes[].host` | yes | The `Host` header to match (e.g. `app.example.com`). |
-| `routes[].upstream` | yes | `host:port` to forward to. Use `host.docker.internal:` to reach the host machine from Docker. |
-| `routes[].cert_type` | no | `self_signed` (default works on boot), `acme` (WIP), or `none` (HTTP only). Defaults to `acme`. |
+- `host` is matched exactly against the `Host` header. A request for `app.example.com:443` will not match a route for `app.example.com`. That's a deliberate strictness, not a bug.
+- A route counts as "TLS" when its `cert_type` is anything other than `none`. TLS routes get the 301 on the HTTP listener, and the HTTPS listener serves them with the cert in `CERT_DIR` (`.pem` / `.key`). `self_signed` routes get one on boot, `acme` routes get one once the issuance flow finishes.
+- If a route's cert files are missing at startup, the proxy boots anyway and logs a warning.
-## How it works
+## Environment variables
-```
- ┌──────────────────────────────────────────┐
- │ Pingora process │
-client │ ┌────────────────┐ ┌──────────────┐ │ HTTP
- ───► │ │ TLS termination│ ──►│ Host router │ ─┼──────► upstream
- │ └────────────────┘ └──────────────┘ │
- │ (BoringSSL) (config.toml) │
- └──────────────────────────────────────────┘
-```
+| Variable | Binary default | In the image | What it does |
+| ------------- | -------------- | ------------------------ | -------------------------------------------------------------------------------- |
+| `CONFIG_PATH` | _none, exits_ | `/etc/proxy/config.toml` | Path to the TOML config. The binary exits without one. |
+| `CERT_DIR` | `.certs/` | `/var/lib/proxy/certs` | Where certs are written, and where the ACME account file (`acme_account`) lives. |
+| `HTTP_PORT` | `80` | `8080` | Port for the HTTP listener. |
+| `HTTPS_PORT` | `443` | `8443` | Port for the HTTPS listener. |
-On startup the binary:
+The image bakes in the right-hand column (the Dockerfile sets all four), which is why the compose file above doesn't set any of them: it just mounts your config at the path the image already expects, and maps `80`/`443` onto `8080`/`8443`. The port remap exists because the container runs as `nonroot` (uid 65532) with all capabilities dropped, and without `CAP_NET_BIND_SERVICE` a process can't bind a port below 1024.
-1. Reads `config.toml` from `CONFIG_PATH`.
-2. Ensures `CERT_DIR` exists and is writable.
-3. Generates self-signed certs via `rcgen` for any route configured as `self_signed`.
-4. Boots Pingora with one HTTPS listener (SNI-routed) and one HTTP listener, both reading the same per-host route table. The HTTP listener 301-redirects to HTTPS for any route that has a cert.
+The listeners always bind `0.0.0.0`.
-Routing is purely `Host`-based: incoming `Host` header → route entry → forward to upstream over plain HTTP.
+## The container image
-## Under the hood
+Both build and runtime stages use the [Docker Hardened Images](https://hub.docker.com/hardened-images/catalog) alpine base. The runtime image ships the binary, `libgcc_s.so.1` (the binary is dynamically linked and needs the unwinder at runtime), musl, busybox, and CA certificates. There's no shell and no package manager in there.
-A few decisions worth knowing about if you want to dig in or contribute.
+One build detail that is a little unusual: we cross-compile to `*-unknown-linux-musl` but with `-crt-static` disabled. Fully static musl binaries can't `dlopen`, which breaks bindgen's libclang loader during the build. The runtime image ships musl, so the resulting (mostly dynamic) binary runs fine on it.
-**Pingora + BoringSSL.** The proxy is built on Pingora (the engine behind a chunk of Cloudflare's edge), with the `boringssl` feature instead of the OpenSSL default. That keeps the binary self-contained and avoids dragging system OpenSSL in.
+The proxy runs on the public internet, so I harden the container:
-**Why `8080`/`8443` inside the container.** The image runs as user `nonroot` (uid `65532`) with `cap_drop: ALL` and `no-new-privileges`. A process without `CAP_NET_BIND_SERVICE` can't bind to ports below 1024, so the binary listens on `8080`/`8443` and the compose file maps the standard ports onto them. The defaults of the binary itself (`HTTP_PORT`/`HTTPS_PORT` env vars) are `80`/`443`; the Dockerfile overrides them.
+- `read_only: true` — the proxy only writes its certs, and those go to the named volume. There's no reason the rest of the filesystem should be writable, so if it gets compromised it can't alter its own binary or config.
+- `cap_drop: ALL` and `no-new-privileges: true` — run with no capabilities and no setuid escalation. If the proxy is compromised, the damage stays in the process.
-**Hardened base image.** Built and runtime images are both [Docker Hardened Images](https://hub.docker.com/hardened-images/catalog) (alpine-base). Runtime image ships only the binary, `musl`, `ca-certs`, and `libgcc_s.so.1` (needed by the dynamically-linked binary's unwinder). No shell, no package manager, no extras.
+## Internals
-**Static-ish musl build.** Cross-compiled to `*-unknown-linux-musl`, but with `-crt-static` disabled so that build-script artifacts (notably `bindgen`'s `dlopen` of `libclang`) work. The runtime image ships `musl`, so the binary still runs cleanly.
+A few implementation notes if you want to dig in or contribute:
-**No async runtime juggling.** `main()` is synchronous. Pingora owns its own Tokio runtime, so the binary just initializes config, certs, and hands control to Pingora's server loop.
+- `main()` is synchronous. Pingora owns its own Tokio runtime, so the binary just loads the config, sorts out the certs, and hands over to the server loop.
+- The cert store is a `HashMap` of host to (cert, key) behind an `ArcSwap`. When the renewal loop is finished it can swap in a new cert without a restart. The swap isn't implemented yet; that's the missing piece of the ACME work.
+- The ACME account credentials are persisted in `CERT_DIR/acme_account`, so a restart reuses the account instead of registering a new one every time.
+- A background task wakes up every hour and drives the renewal loop for any `acme` routes.
-**Per-route TLS modes.** Each route picks its own cert strategy. `none` skips TLS entirely (handy for an internal-only host). `self_signed` writes a fresh cert on every restart. `rcgen`'s defaults give it a ~2000-year validity, so there's no in-process renewal loop; the restart is the rotation. `acme` is wired into the type system but not yet issuing.
+## Roadmap
-## Environment variables
+Roughly in the order I'm doing things:
-| Var | Default | Purpose |
-| ------------- | ------------------------- | ------------------------------------------- |
-| `CONFIG_PATH` | _required_ | Path to `config.toml`. |
-| `CERT_DIR` | `.certs/` | Where generated/issued certs are persisted. |
-| `HTTP_PORT` | `80` (image sets `8080`) | Port the HTTP listener binds to. |
-| `HTTPS_PORT` | `443` (image sets `8443`) | Port the HTTPS listener binds to. |
+1. **Finish the ACME flow.** Create and verify the DNS challenge records, confirm the record is in place before finalizing, issue a staging cert first, then prod, and swap the renewed cert into the store. Until this is done, `acme` routes won't get real certs.
+2. **Access logs and a proper logging story.** Errors go to stdout today.
+3. **A test suite for the behavioural bits.** Routing, redirects, cert handling, error paths.
+4. **Benchmarks.** Baseline numbers to compare against nginx, so I can judge changes by measurement instead of vibes.
+5. **Idiomatic Rust.** V1 was correctness-first. The internals get tidied up once the shape is stable.
-## Roadmap
+## On AI assistance
-In rough priority order:
+I'm upfront about how much AI I use on my projects. This one the Rust is written by hand, I wanted to understand every line of it, and this was mostly an excuse to learn how a proxy works. Where I did use AI:
-1. **ACME issuance.** Wire up `rustls-acme` so the `acme` cert_type flips from "wired" to "working".
-2. **Structured logging.** Error logs are in place; access logs and a sane structured format come next.
-3. **Full test coverage.** Behavioural guarantees (routing, TLS modes, redirect, error paths) backed by tests, not just manual checks.
-4. **Performance benchmarks.** Measure baseline throughput/latency vs. nginx, so future changes can be judged against numbers instead of vibes.
-5. **Make each module idiomatic.** V1 is correctness-first; polish the internals once the surface is stable.
+- Researching trade-offs (Pingora vs. the alternatives, BoringSSL vs. Rustls, musl vs. glibc)
+- Drafting and polishing this README
+- Dockerfile, I have done enough of those. Really did not feel like writing that by hand :)
-## AI disclaimer
+The code itself is mine, so if you find a bug, that's on me.
-I'll be upfront on every public project about what was done with AI. For this one, I wanted to write the Rust myself to keep the skills sharp and have 100% understanding of every bit of code. AI was used for:
+## Contributing
-- Research and tradeoff discussions
-- Cleanup of the README and other prose
-- Talking through code-level solutions
-- Generating the Docker image scaffolding from a spec
+- **Open an issue first.** I'd rather talk through what you want to change, and how, in an issue than review a random PR.
+- **No AI-written code.** The Rust in this repo is written by hand, and I'd like it to stay that way. It's fine to use AI to help you write a change, but you must understand the code you're changing. This is a Rust project, and your profile should show it.
diff --git a/docker-compose.yml b/docker-compose.yml
index dec2d7b..469c2b5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,12 @@
services:
proxy:
# Replace with the actual image
- # image: ghcr.io/mvdschee/proxy:latest
+ # image: ghcr.io/mvdschee/reverse-proxy:latest
build:
context: .
dockerfile: .docker/Dockerfile
ports:
+ # nonroot user can't bind to ports < 1024, so map to 8080/8443
- "80:8080"
- "443:8443"
# Lets upstream = "host.docker.internal:" reach apps running
@@ -23,8 +24,6 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
- tmpfs:
- - /tmp
healthcheck:
# Busybox `nc -z` confirms the HTTP listener is bound. It does
# not exercise the proxy path; the listener simply has to accept.
diff --git a/example/example.toml b/example/example.toml
index 69e6f8a..c8a6494 100644
--- a/example/example.toml
+++ b/example/example.toml
@@ -1,12 +1,27 @@
[acme]
-email = "max@example.com"
+email = "max@example.com" # use a real email this will fail the even on local testing
[[routes]]
host = "git.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
-cert_type = "self_signed" # default is "acme"
+cert_type = "self_signed" # default is "none"
[[routes]]
host = "test.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
-cert_type = "none" # default is "acme"
+cert_type = "none" # default is "none"
+
+# acme needs a dns provider for the DNS-01 challenge. the background renewal
+# loop skips acme routes that have none. cert_type = "acme" is still WIP:
+# the order is created and the dns provider is in, but the challenge is never
+# finalized, so no cert is issued yet.
+[[routes]]
+host = "blog.example.com"
+upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
+cert_type = "acme"
+
+# which dns provider to use for the DNS-01 challenge. cloudflare is the only
+# one for now. the challenge prefix is always _acme-challenge. and fixed.
+[routes.dns_provider.cloudflare]
+zone_id = "your-cloudflare-zone-id"
+api_token = "your-cloudflare-api-token"
diff --git a/makefile b/makefile
index 3cd59f6..b0e05da 100644
--- a/makefile
+++ b/makefile
@@ -1,5 +1,5 @@
dev:
- CONFIG_PATH=./example/example.toml watchexec -q -c -w src --exts rs --restart "cargo run"
+ CONFIG_PATH=./example/local.toml watchexec -q -c -w src --exts rs --restart "cargo run"
scan:
foxguard --config .foxguard.yml
diff --git a/src/config.rs b/src/config.rs
index 6a6ddde..3669513 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,12 +1,13 @@
use crate::{
Error, Result,
core::models::{
- certs::{CertDir, Email},
+ certs::{CertAccountPath, CertDir, Email},
proxy::{ProxyInputAddress, ProxyPort},
routes::Route,
tasks::TaskInterval,
},
};
+use instant_acme::AccountCredentials;
use serde::Deserialize;
use std::{env, fs};
@@ -14,19 +15,24 @@ const CONFIG_PATH_ENV: &str = "CONFIG_PATH";
const CERT_DIR_ENV: &str = "CERT_DIR";
const HTTP_PORT_ENV: &str = "HTTP_PORT";
const HTTPS_PORT_ENV: &str = "HTTPS_PORT";
+pub const ACME_CHALLENGE_PREFIX: &str = "_acme-challenge.";
const CERT_DIR_DEFAULT: &str = ".certs/";
-const HTTP_PORT_DEFAULT: u16 = 80;
-const HTTPS_PORT_DEFAULT: u16 = 443;
+// this will be stored in the .certs/ or depending on where the user wants to store it
+const CERT_CREDENTIAL_FILE: &str = "acme_account";
+const HTTP_PORT_DEFAULT: u16 = 880;
+const HTTPS_PORT_DEFAULT: u16 = 8443;
const INPUT_ADDRESS: &str = "0.0.0.0";
-// in seconds
-const CERT_BACKGROUND_TASK_INTERVAL: u64 = 120;
+/// in seconds
+const CERT_BACKGROUND_TASK_INTERVAL: u64 = 3600; // 1 hour
#[derive(Debug, Clone)]
pub struct Config {
pub email: Email,
pub cert_dir: CertDir,
+ // opague string type as it can't be cloned when its in AccountCredentials type
+ pub cert_account_path: CertAccountPath,
pub routes: Vec,
pub task_interval: TaskInterval,
pub http_port: ProxyPort,
@@ -51,6 +57,8 @@ impl Config {
let config_file = parse_toml_config(config_path)?;
let cert_dir = load_env(CERT_DIR_ENV).unwrap_or_else(|_| CERT_DIR_DEFAULT.to_string());
+ let cert_account_path = format!("{}/{}", cert_dir, CERT_CREDENTIAL_FILE);
+
let http_port =
load_env(HTTP_PORT_ENV).ok().and_then(|v| v.parse().ok()).unwrap_or(HTTP_PORT_DEFAULT);
let https_port = load_env(HTTPS_PORT_ENV)
@@ -61,6 +69,7 @@ impl Config {
Ok(Config {
email: config_file.acme.email.clone(),
cert_dir: CertDir::from(cert_dir),
+ cert_account_path: CertAccountPath::from(cert_account_path),
routes: config_file.routes.clone(),
task_interval: TaskInterval::from(CERT_BACKGROUND_TASK_INTERVAL),
http_port: ProxyPort::from(http_port),
diff --git a/src/core/handlers/certs.rs b/src/core/handlers/certs.rs
index f9d088d..ec2980f 100644
--- a/src/core/handlers/certs.rs
+++ b/src/core/handlers/certs.rs
@@ -1,73 +1,286 @@
use crate::{
Error, Result,
core::{
- handlers::filesystem::{safe_path, write_file},
+ handlers::filesystem::{check_file_exists, read_file, safe_path, write_file},
models::{
- certs::{CertificateConfig, CertificateType},
+ certs::{
+ CertAccountPath, CertDir, CertPath, CertificateConfig, CertificateType, Email,
+ KeyPath, TlsMaterial, TlsStore,
+ },
+ dns::{Cloudflare, CloudflareProvider, DnsProvider, ProviderCredentail},
+ routes::Host,
tasks::TaskInterval,
},
},
- info,
+ error, info,
+ services::{
+ certs::{
+ acme::{create_account, create_order, init_account, load_account},
+ self_signed::create_self_signed_certificate_files,
+ },
+ http::create_client,
+ },
+ warn,
};
+use arc_swap::ArcSwap;
+use async_trait::async_trait;
+use instant_acme::{Account, AccountCredentials, Identifier, NewOrder, OrderStatus};
+use pingora::{server::ShutdownWatch, services::background::BackgroundService, tls};
use rcgen::{CertifiedKey, generate_simple_self_signed};
-use std::time::Duration;
+use std::{collections::HashMap, fs, sync::Arc, time::Duration};
use tokio::time;
-pub fn generate_certs(certificate_configs: Vec) -> Result<()> {
- for config in &certificate_configs {
- // self signed certificates are good until the year 4096
- // this will be replace every restart so it's safe to keep using the default setting
+pub fn create_self_signed_certs(certificate_configs: &Vec) -> Result<()> {
+ for config in certificate_configs {
match config.cert_type {
CertificateType::SelfSigned => {
- info!("generating self-signed certificate for {}", config.host);
+ // self signed certificates are good until the year 4096
+ // this will be replace every restart so it's safe to keep using the default setting
+ // for selfsigned we will create the certs here right away
+ create_self_signed_certificate_files(config);
+ },
+ _ => {},
+ }
+ }
- let subject_alt_names = vec![config.host.to_string()];
- let pem_filename = format!("{}.pem", config.host);
- let key_filename = format!("{}.key", config.host);
+ Ok(())
+}
- let key_path = safe_path(&config.cert_dir, &key_filename)?;
- let pem_path = safe_path(&config.cert_dir, &pem_filename)?;
+pub fn load_tls_store(certificate_configs: &Vec) -> Result {
+ let mut tls_certs = HashMap::new();
- let CertifiedKey {
+ for config in certificate_configs {
+ if config.cert_type != CertificateType::None {
+ let (key_path, cert_path) = certificate_paths(&config.host, &config.cert_dir)?;
+
+ let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path);
+
+ // We only show a warning so its easier to debug once its running,
+ // but we are not stopping any traffic.
+ if !has_tls_files {
+ warn!("Certificate files not found for host '{}' but is expected", &config.host);
+ continue;
+ }
+
+ let cert_bytes = read_file(&cert_path)?;
+ let cert = tls::x509::X509::from_pem(&cert_bytes)
+ .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?;
+
+ let key_bytes = read_file(&key_path)?;
+ let key = tls::pkey::PKey::private_key_from_pem(&key_bytes)
+ .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?;
+
+ tls_certs.insert(
+ config.host.clone(),
+ TlsMaterial {
cert,
- signing_key,
- } = generate_simple_self_signed(subject_alt_names)
- .map_err(|e| Error::Certificate(e.to_string()))?;
+ key,
+ },
+ );
+ }
+ }
- let pem_serialized = cert.pem();
- let key_serialized = signing_key.serialize_pem();
+ let tls_store: TlsStore = Arc::new(ArcSwap::from_pointee(tls_certs));
- write_file(pem_path, pem_serialized.as_bytes())?;
- write_file(key_path, key_serialized.as_bytes())?;
+ Ok(tls_store)
+}
+
+pub fn certificate_paths(host: &Host, cert_dir: &CertDir) -> Result<(KeyPath, CertPath)> {
+ let cert_filename = format!("{}.pem", host);
+ let key_filename = format!("{}.key", host);
+
+ let key_path = safe_path(cert_dir, &key_filename)?;
+ let cert_path = safe_path(cert_dir, &cert_filename)?;
+
+ Ok((key_path, cert_path))
+}
+
+pub struct CertBackgroundRenewal {
+ pub certificate_configs: Vec,
+ pub cert_account_path: CertAccountPath,
+ pub task_interval: TaskInterval,
+ pub tls_store: TlsStore,
+ pub email: Email,
+}
+
+impl CertBackgroundRenewal {
+ pub fn new(
+ certificate_configs: Vec,
+ cert_account_path: CertAccountPath,
+ task_interval: TaskInterval,
+ tls_store: TlsStore,
+ email: Email,
+ ) -> Self {
+ Self {
+ certificate_configs,
+ cert_account_path,
+ task_interval,
+ tls_store,
+ email,
+ }
+ }
+}
+
+#[async_trait]
+impl BackgroundService for CertBackgroundRenewal {
+ // start should never return this will stop the background task,
+ // this means we have to be a little more verbose with our error handeling.
+ // TLDR; just continue on any error :D, problem for the next loop :')
+ async fn start(&self, mut shutdown: ShutdownWatch) {
+ let http_client = match create_client() {
+ Ok(client) => client,
+ Err(e) => {
+ error!("Can't start cert renewal loop: {}", e);
+ return;
},
- CertificateType::Acme => {
- info!("generating acme certificate for {}", config.host);
+ };
+
+ let account = match resolve_acme_account(&self.cert_account_path, &self.email).await {
+ Ok(account) => account,
+ Err(err) => {
+ error!("Failed to resolve ACME account: {err:?}");
+ return;
},
- CertificateType::None => {},
+ };
+
+ let configs = self
+ .certificate_configs
+ .clone()
+ .into_iter()
+ .filter(|c| c.cert_type == CertificateType::Acme);
+
+ loop {
+ for config in configs.clone() {
+ let order_result = create_order(&account, &config.host).await;
+
+ // if no DNS credentials are provided there is DNS validation.
+ // we should terminate early on.
+ let dns_service_config = match config.provider_config {
+ Some(config) => config,
+ None => {
+ warn!("No DNS credentials provided for {}", config.host);
+ continue;
+ },
+ };
+
+ let dns_service = get_dns_services(dns_service_config);
+
+ let mut order = match order_result {
+ Ok(order) => order,
+ Err(err) => {
+ error!("{err:?}");
+ continue;
+ },
+ };
+
+ let state = order.state();
+ info!("order state: {:#?}", state);
+
+ // TODO what does pending means? can we use this to gate the refresh on it. like this will tell us its time to refresh the dns record.
+ if !matches!(state.status, OrderStatus::Pending) {
+ warn!("Skipping non-Pending order: {:?}", state.status);
+ continue;
+ }
+
+ // TODO dont verify the value go straigh to update we are going to get get_challenge_record once we have put it and use it as a input check before finalizing
+
+ // Pick the desired challenge type and prepare the response.
+
+ // let mut authorizations = order.authorizations();
+ // while let Some(result) = authorizations.next().await {
+ // let mut authz = result?;
+ // match authz.status {
+ // AuthorizationStatus::Pending => {},
+ // AuthorizationStatus::Valid => continue,
+ // _ => todo!(),
+ // }
+
+ // // We'll use the DNS challenges for this example, but you could
+ // // pick something else to use here.
+
+ // let mut challenge = authz
+ // .challenge(ChallengeType::Dns01)
+ // .ok_or_else(|| anyhow::anyhow!("no dns01 challenge found"))?;
+
+ // println!("Please set the following DNS record then press the Return key:");
+ // println!(
+ // "_acme-challenge.{} IN TXT {}",
+ // challenge.identifier(),
+ // challenge.key_authorization()?.dns_value()
+ // );
+ // io::stdin().read_line(&mut String::new())?;
+
+ // challenge.set_ready().await?;
+ // }
+
+ // // Exponentially back off until the order becomes ready or invalid.
+
+ // let status = order.poll_ready(&RetryPolicy::default()).await?;
+ // if status != OrderStatus::Ready {
+ // return Err(anyhow::anyhow!("unexpected order status: {status:?}"));
+ // }
+
+ // // Finalize the order and print certificate chain, private key and account credentials.
+
+ // let private_key_pem = order.finalize().await?;
+ // let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await?;
+
+ // info!("certificate chain:\n\n{cert_chain_pem}");
+ // info!("private key:\n\n{private_key_pem}");
+
+ // write to the file system
+ //
+ // swap the file content in the store with the new values if any
+ //
+ //
+ // note: we write to the file system so we can pick the files up and load them in the store when we restart or bootup
+ // this so we don't have to deal here with loading if the files are there (so we only have to check here if the order is invalid or valid and swap when its time)
+ // so on boot we load all the tls certs from self-signed / acme and check in this flow it its valid or not and fix it with a swap.
+ }
+
+ info!("background thing");
+
+ tokio::select! {
+ _ = tokio::time::sleep(Duration::from_secs(*self.task_interval)) => {}
+ _ = shutdown.changed() => break,
+ }
}
}
+}
- Ok(())
+fn get_dns_services(config: ProviderCredentail) -> impl DnsProvider {
+ match config {
+ ProviderCredentail::Cloudflare(config) => CloudflareProvider {
+ zone_id: config.zone_id,
+ api_token: config.api_token,
+ challenge_prefix: config.challenge_prefix,
+ },
+ }
}
-pub async fn background_certs_task(
- certificates: Vec,
- task_interval: TaskInterval,
-) -> Result<()> {
- // only acme certificates need to be renewed
- let certificates = certificates
- .into_iter()
- .filter(|cert| cert.cert_type == CertificateType::Acme)
- .collect::>();
+async fn resolve_acme_account(
+ cert_account_path: &CertAccountPath,
+ email: &Email,
+) -> Result {
+ // set crypto lib to load/create the account
+ init_account();
- loop {
- info!("certificates: {}", certificates.len());
+ if let Ok(credentials) = get_acme_account(cert_account_path) {
+ return load_account(credentials).await;
+ }
- // for certificate in &certificates {
- // }
+ let (account, credentials) = create_account(email).await?;
+ let content = serde_json::to_vec(&credentials)
+ .map_err(|e| Error::Acme(format!("Failed to serialize ACME account: {}", e)))?;
+ write_file(cert_account_path.clone(), &content)?;
- time::sleep(Duration::from_secs(*task_interval)).await;
- }
+ Ok(account)
+}
- Ok(())
+// any erorr one this part we will simply create a new account
+fn get_acme_account(cert_account_path: &CertAccountPath) -> Result {
+ let raw_content = read_file(cert_account_path)?;
+
+ serde_json::from_slice(&raw_content)
+ .map_err(|e| Error::Acme(format!("Failed to parse ACME account: {}", e)))
}
diff --git a/src/core/handlers/proxy.rs b/src/core/handlers/proxy.rs
index 62693f4..a257d6f 100644
--- a/src/core/handlers/proxy.rs
+++ b/src/core/handlers/proxy.rs
@@ -1,21 +1,22 @@
use crate::{
Error, Result,
core::{
- handlers::filesystem::read_file,
+ handlers::{certs::CertBackgroundRenewal, filesystem::read_file},
models::{
- certs::{TlsCerts, TlsMaterial},
+ certs::{TlsMaterial, TlsStore},
proxy::{ProxyConfig, ProxyRoute, ProxyRouteMap},
},
},
error,
};
+use arc_swap::ArcSwap;
use async_trait::async_trait;
use http::header;
use pingora::{
ErrorType,
http::ResponseHeader,
listeners::{TlsAccept, tls::TlsSettings},
- prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult},
+ prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult, background_service},
proxy::{ProxyHttp, Session, http_proxy_service},
server::{Server, configuration::ServerConf},
services::Service,
@@ -23,7 +24,12 @@ use pingora::{
};
use std::{collections::HashMap, sync::Arc};
-pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<()> {
+pub fn run_proxy(
+ proxy_config: ProxyConfig,
+ routes: Vec,
+ tls_store: TlsStore,
+ renewal: CertBackgroundRenewal,
+) -> Result<()> {
let mut server = Server::new(None).map_err(|e| Error::Proxy(e.to_string()))?;
server.bootstrap();
@@ -31,34 +37,13 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<(
let server_conf = server.configuration.clone();
let http_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.http_port);
let https_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.https_port);
-
let mut routes_map = HashMap::new();
- let mut tls_certs = HashMap::new();
for route in routes {
- if *route.tls {
- let cert_bytes = read_file(&route.cert_path)?;
- let cert = tls::x509::X509::from_pem(&cert_bytes)
- .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?;
-
- let key_bytes = read_file(&route.key_path)?;
- let key = tls::pkey::PKey::private_key_from_pem(&key_bytes)
- .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?;
-
- tls_certs.insert(
- route.host.clone(),
- TlsMaterial {
- cert,
- key,
- },
- );
- }
-
routes_map.insert(route.host.clone(), route);
}
let routes_map = Arc::new(routes_map);
- let tls_certs = Arc::new(tls_certs);
// plain proxies with redirect
let plain_service =
@@ -66,10 +51,12 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<(
server.add_service(plain_service);
// tls proxies
- let tls_service =
- tls_routes_service(server_conf, https_addr.clone(), routes_map.clone(), tls_certs)?;
+ let tls_service = tls_routes_service(server_conf, https_addr, routes_map, tls_store)?;
server.add_service(tls_service);
+ // background cert services
+ server.add_service(background_service("cert-renewal", renewal));
+
server.run_forever();
}
@@ -77,14 +64,14 @@ pub fn tls_routes_service(
server_conf: Arc,
listen_addr: String,
routes_map: ProxyRouteMap,
- tls_certs: TlsCerts,
+ tls_store: TlsStore,
) -> Result {
let proxy_app = ProxyToUpstream::new(routes_map.clone(), false);
let mut service = http_proxy_service(&server_conf, proxy_app);
- let sni_resolver = SniResolver::new(tls_certs);
- let callback = Box::new(sni_resolver);
+ let cert_resolver = CertResolver::new(tls_store);
+ let callback = Box::new(cert_resolver);
let tls_settings =
TlsSettings::with_callbacks(callback).map_err(|e| Error::Proxy(e.to_string()))?;
service.add_tls_with_settings(&listen_addr, None, tls_settings);
@@ -186,20 +173,20 @@ fn host_from_session(session: &Session) -> Option<&str> {
session.get_header(header::HOST).and_then(|h| h.to_str().ok())
}
-struct SniResolver {
- certs: TlsCerts,
+struct CertResolver {
+ tls_store: TlsStore,
}
-impl SniResolver {
- fn new(tls_certs: TlsCerts) -> Self {
+impl CertResolver {
+ fn new(tls_store: TlsStore) -> Self {
Self {
- certs: tls_certs,
+ tls_store,
}
}
}
#[async_trait]
-impl TlsAccept for SniResolver {
+impl TlsAccept for CertResolver {
async fn certificate_callback(&self, ssl: &mut ssl::SslRef) -> () {
let sni_provided = ssl.servername(ssl::NameType::HOST_NAME).map(str::to_owned);
@@ -208,10 +195,12 @@ impl TlsAccept for SniResolver {
return;
};
+ let certs = self.tls_store.load();
+
let Some(TlsMaterial {
cert,
key,
- }) = self.certs.get(sni_provided.as_str())
+ }) = certs.get(sni_provided.as_str())
else {
error!("No certificate found for SNI: {}", sni_provided);
return;
diff --git a/src/core/models/certs.rs b/src/core/models/certs.rs
index 818149a..4ad5470 100644
--- a/src/core/models/certs.rs
+++ b/src/core/models/certs.rs
@@ -1,20 +1,21 @@
-use crate::core::models::routes::Host;
+use crate::{
+ core::models::{dns::ProviderCredentail, filesystem::SafePath, routes::Host},
+ string_newtype,
+};
+use arc_swap::ArcSwap;
use pingora::tls::{
pkey::{PKey, Private},
x509::X509,
};
use serde::Deserialize;
-use std::collections::HashMap;
-use std::fmt;
-use std::ops::Deref;
-use std::sync::Arc;
+use std::{collections::HashMap, sync::Arc};
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CertificateType {
SelfSigned,
- #[default]
Acme,
+ #[default]
None,
}
@@ -22,8 +23,8 @@ pub enum CertificateType {
pub struct CertificateConfig {
pub host: Host,
pub cert_dir: CertDir,
- pub email: Email,
pub cert_type: CertificateType,
+ pub provider_config: Option,
}
pub struct TlsMaterial {
@@ -31,52 +32,16 @@ pub struct TlsMaterial {
pub key: PKey,
}
-pub type TlsCerts = Arc>;
-
-// --- EMAIL ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct Email(String);
+pub type KeyPath = SafePath;
+pub type CertPath = SafePath;
-impl Deref for Email {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for Email {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
+pub type TlsStore = Arc>>;
-impl From for Email {
- fn from(s: String) -> Self {
- Email(s)
- }
-}
+// --- EMAIL ---
+string_newtype!(Email, derive(Deserialize));
// --- CERT_DIR ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct CertDir(String);
-
-impl Deref for CertDir {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for CertDir {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
+string_newtype!(CertDir, derive(Deserialize));
-impl From for CertDir {
- fn from(s: String) -> Self {
- CertDir(s)
- }
-}
+// --- CERT_ACCOUNT_PATH ---
+pub type CertAccountPath = SafePath;
diff --git a/src/core/models/dns.rs b/src/core/models/dns.rs
new file mode 100644
index 0000000..e16a458
--- /dev/null
+++ b/src/core/models/dns.rs
@@ -0,0 +1,59 @@
+use crate::{
+ Error, Result, config::ACME_CHALLENGE_PREFIX, core::models::routes::Host, string_newtype,
+};
+use reqwest::Client;
+use serde::Deserialize;
+
+pub trait DnsProvider: Send + Sync + 'static {
+ /// Only returns single record
+ /// gets all records but internally filters out all records either,
+ /// after the call or if it supports it directly on the endpoint call.
+ fn get_challenge_record(&self, host: &Host) -> Result;
+ /// Single fuction to set and update
+ /// will return the set record (if returned otherwise fake it)
+ fn update_challenge_record(&self, host: &Host, value: &str) -> Result;
+}
+
+fn default_challenge_prefix() -> String {
+ ACME_CHALLENGE_PREFIX.to_string()
+}
+
+// --- DNS Record ---
+string_newtype!(Record, derive(Deserialize));
+
+// --- DNS Provider credentials ---
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ProviderCredentail {
+ Cloudflare(CloudflareProvider),
+}
+
+// --- add here any other providers credentials ---
+
+// --- CloudFlare ---
+
+// config coming from TOML file
+#[derive(Debug, Clone, Deserialize)]
+pub struct CloudflareProvider {
+ pub zone_id: ZoneId,
+ pub api_token: ApiToken,
+ // challenge_prefix is a default value from ACME,
+ // this can not be set or changed from the userside
+ // but its here to keep keep it as a single config
+ // per provider
+ #[serde(skip, default = "default_challenge_prefix")]
+ pub challenge_prefix: String,
+}
+
+// struct that hold client
+pub struct Cloudflare {
+ client: Client,
+ config: CloudflareProvider,
+}
+
+// Zone ID
+string_newtype!(ZoneId, derive(Deserialize));
+
+// API token
+string_newtype!(ApiToken, derive(Deserialize));
diff --git a/src/core/models/filesystem.rs b/src/core/models/filesystem.rs
index 53c4570..3a70b54 100644
--- a/src/core/models/filesystem.rs
+++ b/src/core/models/filesystem.rs
@@ -1,25 +1,4 @@
-use std::{fmt, ops::Deref};
+use crate::string_newtype;
// --- SAFEPATH ---
-#[derive(Debug, Clone)]
-pub struct SafePath(String);
-
-impl Deref for SafePath {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for SafePath {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for SafePath {
- fn from(s: String) -> Self {
- SafePath(s)
- }
-}
+string_newtype!(SafePath);
diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs
index 1cf9ecb..7052b71 100644
--- a/src/core/models/mod.rs
+++ b/src/core/models/mod.rs
@@ -1,4 +1,5 @@
pub mod certs;
+pub mod dns;
pub mod filesystem;
pub mod proxy;
pub mod routes;
diff --git a/src/core/models/proxy.rs b/src/core/models/proxy.rs
index dab8d40..7db4914 100644
--- a/src/core/models/proxy.rs
+++ b/src/core/models/proxy.rs
@@ -1,10 +1,13 @@
-use crate::core::models::{
- certs::CertDir,
- filesystem::SafePath,
- routes::{Host, Upstream},
+use crate::{
+ core::models::{
+ certs::CertDir,
+ filesystem::SafePath,
+ routes::{Host, Upstream},
+ },
+ string_newtype,
};
use http::{Response, StatusCode, header};
-use std::{collections::HashMap, fmt, ops::Deref, sync::Arc};
+use std::{collections::HashMap, ops::Deref, sync::Arc};
pub type ProxyRouteMap = Arc>;
@@ -13,13 +16,10 @@ pub struct ProxyRoute {
pub host: Host,
pub upstream: Upstream,
pub tls: ProxyTls,
- pub cert_path: SafePath,
- pub key_path: SafePath,
}
#[derive(Debug, Clone)]
pub struct ProxyConfig {
- pub cert_dir: CertDir,
pub http_port: ProxyPort,
pub https_port: ProxyPort,
pub input_address: ProxyInputAddress,
@@ -62,25 +62,4 @@ impl From for ProxyPort {
}
// --- PROXY INPUT ADDRESS ---
-#[derive(Debug, Clone)]
-pub struct ProxyInputAddress(String);
-
-impl Deref for ProxyInputAddress {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for ProxyInputAddress {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for ProxyInputAddress {
- fn from(s: String) -> Self {
- ProxyInputAddress(s)
- }
-}
+string_newtype!(ProxyInputAddress);
diff --git a/src/core/models/routes.rs b/src/core/models/routes.rs
index 17649ec..b44a2e3 100644
--- a/src/core/models/routes.rs
+++ b/src/core/models/routes.rs
@@ -1,6 +1,8 @@
-use crate::core::models::certs::CertificateType;
+use crate::{
+ core::models::{certs::CertificateType, dns::ProviderCredentail},
+ string_newtype,
+};
use serde::Deserialize;
-use std::{fmt, ops::Deref};
#[derive(Debug, Clone, Deserialize)]
pub struct Route {
@@ -8,19 +10,11 @@ pub struct Route {
pub upstream: Upstream,
#[serde(default)]
pub cert_type: CertificateType,
+ pub dns_provider: Option,
}
// --- HOST ---
-#[derive(Debug, Clone, Deserialize, Hash, Eq, PartialEq)]
-pub struct Host(String);
-
-impl Deref for Host {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
+string_newtype!(Host, derive(Deserialize, Hash, Eq, PartialEq));
impl std::borrow::Borrow for Host {
fn borrow(&self) -> &str {
@@ -28,38 +22,5 @@ impl std::borrow::Borrow for Host {
}
}
-impl fmt::Display for Host {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for Host {
- fn from(s: String) -> Self {
- Host(s)
- }
-}
-
// --- UPSTREAM ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct Upstream(String);
-
-impl Deref for Upstream {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for Upstream {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for Upstream {
- fn from(s: String) -> Self {
- Upstream(s)
- }
-}
+string_newtype!(Upstream, derive(Deserialize));
diff --git a/src/core/models/tasks.rs b/src/core/models/tasks.rs
index 1cc2b46..8722ed1 100644
--- a/src/core/models/tasks.rs
+++ b/src/core/models/tasks.rs
@@ -2,6 +2,7 @@ use serde::Deserialize;
use std::ops::Deref;
// --- TASK INTERVAL ---
+/// Interval for a task in seconds.
#[derive(Debug, Clone, Deserialize)]
pub struct TaskInterval(u64);
diff --git a/src/core/setup.rs b/src/core/setup.rs
index b133345..4be3f61 100644
--- a/src/core/setup.rs
+++ b/src/core/setup.rs
@@ -2,12 +2,16 @@ use crate::{
Error, Result,
core::{
handlers::{
- certs::generate_certs,
+ certs::{
+ CertBackgroundRenewal, certificate_paths, create_self_signed_certs, load_tls_store,
+ },
filesystem::{check_file_exists, safe_path},
proxy::run_proxy,
},
models::{
- certs::{CertDir, CertificateConfig, CertificateType, Email},
+ certs::{
+ CertAccountPath, CertDir, CertificateConfig, CertificateType, Email, TlsStore,
+ },
proxy::{ProxyConfig, ProxyRoute, ProxyTls},
routes::Route,
tasks::TaskInterval,
@@ -46,12 +50,15 @@ impl HandleFileSystem {
pub struct HandleCertificates {
certificate_configs: Vec,
+ cert_account_path: CertAccountPath,
task_interval: TaskInterval,
+ email: Email,
}
impl HandleCertificates {
pub fn new(
cert_dir: CertDir,
+ cert_account_path: CertAccountPath,
email: Email,
routes: Vec,
task_interval: TaskInterval,
@@ -61,72 +68,69 @@ impl HandleCertificates {
.map(|route| CertificateConfig {
host: route.host.clone(),
cert_dir: cert_dir.clone(),
- email: email.clone(),
cert_type: route.cert_type.clone(),
+ provider_config: route.dns_provider.clone(),
})
.collect::>();
Self {
certificate_configs,
+ cert_account_path,
task_interval,
+ email,
}
}
- pub fn run(self) -> Result<()> {
- generate_certs(self.certificate_configs.clone())?;
+ pub fn run(self) -> Result<(TlsStore, CertBackgroundRenewal)> {
+ create_self_signed_certs(&self.certificate_configs)?;
- // spawn(async move {
- // info!("starting certificates tasks...");
- // background_certs_task(self.certificates, self.task_interval).await;
- // });
+ let store = load_tls_store(&self.certificate_configs)?;
+ let renewal = CertBackgroundRenewal::new(
+ self.certificate_configs.clone(),
+ self.cert_account_path.clone(),
+ self.task_interval.clone(),
+ store.clone(),
+ self.email.clone(),
+ );
- Ok(())
+ Ok((store, renewal))
}
}
pub struct HandleProxy {
proxy_config: ProxyConfig,
proxy_routes: Vec,
+ tls_store: TlsStore,
}
impl HandleProxy {
- pub fn new(proxy_config: ProxyConfig, routes: Vec) -> Result {
+ pub fn new(proxy_config: ProxyConfig, routes: Vec, tls_store: TlsStore) -> Result {
let mut proxy_routes = Vec::new();
for route in routes {
- let cert_filename = format!("{}.pem", route.host);
- let key_filename = format!("{}.key", route.host);
-
- let key_path = safe_path(&proxy_config.cert_dir, &key_filename)?;
- let cert_path = safe_path(&proxy_config.cert_dir, &cert_filename)?;
-
- let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path);
-
- // We only show a warning so its easier to debug once its running,
- // but we are not stopping any traffic.
- if !has_tls_files && route.cert_type != CertificateType::None {
- warn!("Certificate files not found for host `{}` but is expected", route.host);
- }
-
proxy_routes.push(ProxyRoute {
host: route.host.clone(),
upstream: route.upstream.clone(),
tls: ProxyTls::from(route.cert_type != CertificateType::None),
- cert_path,
- key_path,
});
}
Ok(Self {
proxy_config,
proxy_routes,
+ tls_store,
})
}
- pub fn run(&self) -> Result<()> {
+ pub fn run(&self, renewal: CertBackgroundRenewal) -> Result<()> {
info!("proxy running...");
- run_proxy(self.proxy_config.clone(), self.proxy_routes.clone())?;
+ run_proxy(
+ self.proxy_config.clone(),
+ self.proxy_routes.clone(),
+ self.tls_store.clone(),
+ renewal,
+ )?;
Err(Error::Proxy("proxy exited".to_string()))
}
diff --git a/src/error.rs b/src/error.rs
index a9f79ac..10da973 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,18 +13,33 @@ pub enum Error {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
+ #[error("request error: {0}")]
+ Request(reqwest::Error),
+
#[error("file system error: {0}")]
FileSystem(String),
#[error("certificate error: {0}")]
Certificate(String),
+ #[error("Acme error: {0}")]
+ Acme(String),
+
#[error("proxy error: {0}")]
Proxy(String),
+ #[error("dns error: {0}")]
+ Dns(String),
+
#[error("config error: {0}")]
Config(String),
#[error("unknown error")]
Unknown,
}
+
+impl From for Error {
+ fn from(err: reqwest::Error) -> Self {
+ Error::Request(err)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index ad61be0..1a54adc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,7 @@ pub use error::{Error, Result};
mod config;
mod core;
pub mod error;
+mod services;
mod utils;
// entry needs to be synchronous as pingora has there own
@@ -25,21 +26,21 @@ fn main() -> Result<()> {
// and spin up background tasks to refresh certificates
let cert_handler = HandleCertificates::new(
config.cert_dir.clone(),
+ config.cert_account_path.clone(),
config.email,
config.routes.clone(),
config.task_interval,
);
- cert_handler.run()?;
+ let (store, renewal) = cert_handler.run()?;
// start the proxy
let proxy_config = ProxyConfig {
- cert_dir: config.cert_dir.clone(),
http_port: config.http_port,
https_port: config.https_port,
input_address: config.input_address,
};
- let proxy_handler = HandleProxy::new(proxy_config, config.routes)?;
- proxy_handler.run()?;
+ let proxy_handler = HandleProxy::new(proxy_config, config.routes, store)?;
+ proxy_handler.run(renewal)?;
Err(Error::MainLoopClosed)
}
diff --git a/src/services/certs/acme.rs b/src/services/certs/acme.rs
new file mode 100644
index 0000000..4aadaf4
--- /dev/null
+++ b/src/services/certs/acme.rs
@@ -0,0 +1,67 @@
+use crate::{
+ Error, Result,
+ core::models::{
+ certs::{CertificateConfig, Email},
+ routes::Host,
+ },
+ info,
+};
+use instant_acme::{
+ Account, AccountCredentials, Identifier, LetsEncrypt, NewAccount, NewOrder, Order,
+};
+use rustls::crypto::CryptoProvider;
+
+pub fn init_account() {
+ CryptoProvider::install_default(rustls::crypto::ring::default_provider());
+}
+
+pub async fn load_account(credentials: AccountCredentials) -> Result {
+ let account = Account::builder()
+ .map_err(|e| Error::Acme(e.to_string()))?
+ .from_credentials(credentials)
+ .await
+ .map_err(|e| Error::Acme(e.to_string()))?;
+
+ info!("Reusing ACME account");
+
+ Ok(account)
+}
+
+// this one returns credentials, because we are going to save it to the file
+// for later usage to start the process with the same credentials
+pub async fn create_account(email: &Email) -> Result<(Account, AccountCredentials)> {
+ CryptoProvider::install_default(rustls::crypto::ring::default_provider());
+
+ let (account, credentials) = Account::builder()
+ .map_err(|e| Error::Acme(e.to_string()))?
+ .create(
+ &NewAccount {
+ contact: &[&format!("mailto:{}", email.as_str())],
+ terms_of_service_agreed: true,
+ only_return_existing: false,
+ },
+ // TODO: switch to prod
+ LetsEncrypt::Staging.url().to_owned(),
+ None,
+ )
+ .await
+ .map_err(|e| Error::Acme(e.to_string()))?;
+
+ info!("Created new ACME account");
+
+ Ok((account, credentials))
+}
+
+pub async fn create_order(account: &Account, host: &Host) -> Result {
+ let identifier = Identifier::Dns(host.to_string());
+ // instant_acme support multiple host per order,
+ // but we need to know which dns needs to be updated
+ // which is why we split them up, this is maybe something we can refine in V2
+ let identifiers = vec![identifier];
+ let order_result = account
+ .new_order(&NewOrder::new(&identifiers))
+ .await
+ .map_err(|e| Error::Acme(format!("Failed with new_order: {}", e)));
+
+ order_result
+}
diff --git a/src/services/certs/mod.rs b/src/services/certs/mod.rs
new file mode 100644
index 0000000..7c4495a
--- /dev/null
+++ b/src/services/certs/mod.rs
@@ -0,0 +1,2 @@
+pub mod acme;
+pub mod self_signed;
diff --git a/src/services/certs/self_signed.rs b/src/services/certs/self_signed.rs
new file mode 100644
index 0000000..21099bf
--- /dev/null
+++ b/src/services/certs/self_signed.rs
@@ -0,0 +1,34 @@
+use crate::{
+ Error, Result,
+ core::{
+ handlers::{
+ certs::certificate_paths,
+ filesystem::{safe_path, write_file},
+ },
+ models::certs::CertificateConfig,
+ },
+ info,
+};
+use rcgen::{CertifiedKey, generate_simple_self_signed};
+
+pub fn create_self_signed_certificate_files(config: &CertificateConfig) -> Result<()> {
+ info!("generating self-signed certificate for {}", config.host);
+
+ let subject_alt_names = vec![config.host.to_string()];
+
+ let (key_path, cert_path) = certificate_paths(&config.host, &config.cert_dir)?;
+
+ let CertifiedKey {
+ cert,
+ signing_key,
+ } = generate_simple_self_signed(subject_alt_names)
+ .map_err(|e| Error::Certificate(e.to_string()))?;
+
+ let cert_serialized = cert.pem();
+ let key_serialized = signing_key.serialize_pem();
+
+ write_file(cert_path, cert_serialized.as_bytes())?;
+ write_file(key_path, key_serialized.as_bytes())?;
+
+ Ok(())
+}
diff --git a/src/services/dns/cloudflare.rs b/src/services/dns/cloudflare.rs
new file mode 100644
index 0000000..b41363a
--- /dev/null
+++ b/src/services/dns/cloudflare.rs
@@ -0,0 +1,18 @@
+use serde::de::value;
+
+use crate::{
+ Error, Result,
+ core::models::{
+ dns::{CloudflareProvider, DnsProvider, Record},
+ routes::Host,
+ },
+};
+
+impl DnsProvider for CloudflareProvider {
+ fn get_challenge_record(&self, host: &Host) -> Result {
+ Err(Error::Dns("not implemented!".to_string()))
+ }
+ fn update_challenge_record(&self, host: &Host, value: &str) -> Result {
+ Err(Error::Dns("not implemented!".to_string()))
+ }
+}
diff --git a/src/services/dns/mod.rs b/src/services/dns/mod.rs
new file mode 100644
index 0000000..c727ae5
--- /dev/null
+++ b/src/services/dns/mod.rs
@@ -0,0 +1 @@
+mod cloudflare;
diff --git a/src/services/http/mod.rs b/src/services/http/mod.rs
new file mode 100644
index 0000000..731029c
--- /dev/null
+++ b/src/services/http/mod.rs
@@ -0,0 +1,15 @@
+use crate::Result;
+use reqwest::Client;
+use std::time::Duration;
+
+// simply return client no need for struct,
+// or extras
+pub fn create_client() -> Result {
+ let client = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .pool_idle_timeout(Duration::from_secs(90))
+ .pool_max_idle_per_host(10)
+ .build()?;
+
+ Ok(client)
+}
diff --git a/src/services/mod.rs b/src/services/mod.rs
new file mode 100644
index 0000000..cc7959b
--- /dev/null
+++ b/src/services/mod.rs
@@ -0,0 +1,3 @@
+pub mod certs;
+pub mod dns;
+pub mod http;
diff --git a/src/utils.rs b/src/utils.rs
index b253deb..4225ce5 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -18,3 +18,30 @@ macro_rules! error {
println!("\x1b[90m{} \x1b[31m{} \x1b[0m{}", chrono::Local::now().format("%H:%M:%S%.3f %d-%m-%y"), "[ERROR]", format_args!($($arg)*))
};
}
+
+#[macro_export]
+macro_rules! string_newtype {
+ ($name:ident $(, derive($($extra:path),+ $(,)?))?) => {
+ #[derive(Debug, Clone $(, $($extra),+)?)]
+ pub struct $name(String);
+
+ impl ::std::ops::Deref for $name {
+ type Target = String;
+ fn deref(&self) -> &String {
+ &self.0
+ }
+ }
+
+ impl ::std::fmt::Display for $name {
+ fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+ }
+
+ impl From for $name {
+ fn from(s: String) -> Self {
+ $name(s)
+ }
+ }
+ };
+}