Add YAML-configured ACLs with Basic and IP authorization
This commit is contained in:
Generated
+345
-2
@@ -11,12 +11,75 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.92"
|
version = "0.1.92"
|
||||||
@@ -40,12 +103,36 @@ version = "0.23.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -64,6 +151,96 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"ctutils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -81,6 +258,12 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -180,28 +363,59 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 6.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermes"
|
name = "hermes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"clap",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"ipnet",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-appender-tracing",
|
"opentelemetry-appender-tracing",
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"serde_norway",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -249,6 +463,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -395,12 +618,28 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -492,6 +731,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry"
|
name = "opentelemetry"
|
||||||
version = "0.33.1"
|
version = "0.33.1"
|
||||||
@@ -575,12 +820,33 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"phc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phc"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"ctutils",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -658,6 +924,12 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -684,7 +956,7 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -735,12 +1007,39 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -748,6 +1047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -770,6 +1070,19 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_norway"
|
||||||
|
version = "0.9.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml-norway",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -817,6 +1130,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1053,12 +1372,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml-norway"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1077,6 +1408,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -1274,6 +1611,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ description = "A minimal HTTP forward proxy with OpenTelemetry observability"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
argon2 = { version = "0.6", features = ["zeroize"] }
|
||||||
|
base64 = "0.23"
|
||||||
bytes = "1.11"
|
bytes = "1.11"
|
||||||
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
http = "1.4"
|
http = "1.4"
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
hyper = { version = "1.12", features = ["client", "http1", "server"] }
|
hyper = { version = "1.12", features = ["client", "http1", "server"] }
|
||||||
@@ -16,11 +19,16 @@ opentelemetry = "0.33.1"
|
|||||||
opentelemetry-appender-tracing = "0.33.1"
|
opentelemetry-appender-tracing = "0.33.1"
|
||||||
opentelemetry-otlp = "0.33.1"
|
opentelemetry-otlp = "0.33.1"
|
||||||
opentelemetry_sdk = "0.33.1"
|
opentelemetry_sdk = "0.33.1"
|
||||||
|
ipnet = "2.12"
|
||||||
|
rpassword = "7.5"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_norway = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-opentelemetry = "0.34"
|
tracing-opentelemetry = "0.34"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
zeroize = "1.9"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes
|
# Hermes
|
||||||
|
|
||||||
Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streaming plain HTTP requests and HTTPS tunneling with `CONNECT`, with OpenTelemetry traces, metrics, and logs exported over OTLP/HTTP.
|
Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streaming plain HTTP requests, HTTPS tunneling with `CONNECT`, YAML-configured ACLs, and OpenTelemetry traces, metrics, and logs exported over OTLP/HTTP.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -8,10 +8,12 @@ Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streamin
|
|||||||
- HTTPS and arbitrary TCP tunneling with `CONNECT host:port`
|
- HTTPS and arbitrary TCP tunneling with `CONNECT host:port`
|
||||||
- Streaming request and response bodies
|
- Streaming request and response bodies
|
||||||
- Upstream connection pooling and configurable timeouts
|
- Upstream connection pooling and configurable timeouts
|
||||||
|
- Ordered allow/deny ACLs using Basic authentication and/or client IP networks
|
||||||
|
- Salted Argon2id password hashes; plaintext passwords are never stored in YAML
|
||||||
- Removal of standard and `Connection`-nominated hop-by-hop headers
|
- Removal of standard and `Connection`-nominated hop-by-hop headers
|
||||||
- W3C Trace Context extraction and injection for plain HTTP traffic
|
- W3C Trace Context extraction and injection for plain HTTP traffic
|
||||||
- Structured, trace-correlated logs on stdout and through OTLP
|
- Structured, trace-correlated logs on stdout and through OTLP
|
||||||
- Bounded-cardinality HTTP and tunnel metrics
|
- Bounded-cardinality HTTP, tunnel, and ACL metrics
|
||||||
- Localhost-only binding by default
|
- Localhost-only binding by default
|
||||||
|
|
||||||
TLS is not terminated or inspected. HTTPS remains end-to-end encrypted between the client and destination.
|
TLS is not terminated or inspected. HTTPS remains end-to-end encrypted between the client and destination.
|
||||||
@@ -20,10 +22,10 @@ TLS is not terminated or inspected. HTTPS remains end-to-end encrypted between t
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build
|
cargo build
|
||||||
cargo run
|
cargo run -- --config examples/hermes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
The proxy listens on `127.0.0.1:8080` by default.
|
The default configuration path is `hermes.yaml`. The included example listens on `127.0.0.1:8080` and allows only localhost clients.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Plain HTTP
|
# Plain HTTP
|
||||||
@@ -33,16 +35,74 @@ curl --proxy http://127.0.0.1:8080 http://example.com/
|
|||||||
curl --proxy http://127.0.0.1:8080 https://example.com/
|
curl --proxy http://127.0.0.1:8080 https://example.com/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Proxy configuration
|
## YAML configuration
|
||||||
|
|
||||||
| Environment variable | Default | Description |
|
```yaml
|
||||||
|
listen_addr: "127.0.0.1:8080"
|
||||||
|
connect_timeout_ms: 10000
|
||||||
|
upstream_timeout_ms: 30000
|
||||||
|
|
||||||
|
acl:
|
||||||
|
default_action: deny
|
||||||
|
|
||||||
|
users:
|
||||||
|
alice:
|
||||||
|
password_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- name: alice
|
||||||
|
action: allow
|
||||||
|
users: ["alice"]
|
||||||
|
|
||||||
|
- name: office-network
|
||||||
|
action: allow
|
||||||
|
networks: ["10.20.0.0/16", "2001:db8:20::/48"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `HERMES_LISTEN_ADDR` | `127.0.0.1:8080` | Downstream listen address |
|
| `listen_addr` | `127.0.0.1:8080` | Downstream listen address |
|
||||||
| `HERMES_CONNECT_TIMEOUT_MS` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
|
| `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
|
||||||
| `HERMES_UPSTREAM_TIMEOUT_MS` | `30000` | Time limit for receiving upstream HTTP response headers |
|
| `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers |
|
||||||
| `RUST_LOG` | `info` | `tracing` filter, for example `hermes=debug` |
|
| `acl.default_action` | `deny` | Action when no rule matches |
|
||||||
|
| `acl.users` | empty | Usernames and Argon2id PHC password hashes |
|
||||||
|
| `acl.rules` | empty | Ordered ACL rules |
|
||||||
|
|
||||||
Do not expose Hermes on a public interface without adding appropriate authentication and destination access controls. The default configuration intentionally binds to localhost.
|
Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.
|
||||||
|
|
||||||
|
`RUST_LOG` controls log filtering and defaults to `info`, for example `RUST_LOG=hermes=debug`.
|
||||||
|
|
||||||
|
### ACL rules
|
||||||
|
|
||||||
|
- Rules are evaluated in order; the **first match wins**.
|
||||||
|
- Separate user and network rules provide Basic-auth OR IP-address authorization.
|
||||||
|
- If a rule contains both `users` and `networks`, both selectors must match. Entries within each selector are ORed.
|
||||||
|
- A rule must contain at least one selector; accidental match-all rules are rejected.
|
||||||
|
- IP matching uses the actual TCP peer address. `X-Forwarded-For` is never trusted.
|
||||||
|
- Requests without required credentials receive `407 Proxy Authentication Required` and a Basic `Proxy-Authenticate` challenge.
|
||||||
|
- Invalid credentials receive 407; authenticated or IP-identified clients denied by policy receive 403.
|
||||||
|
- ACLs are enforced before upstream HTTP requests or `CONNECT` connections are made.
|
||||||
|
- `Proxy-Authorization` is consumed by Hermes and is never forwarded upstream.
|
||||||
|
|
||||||
|
For Basic authentication, curl can supply proxy credentials with `--proxy-user`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proxy http://127.0.0.1:8080 \
|
||||||
|
--proxy-user 'alice:your-password' \
|
||||||
|
https://example.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate a password hash
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- hash-password
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper prompts without echoing, confirms the password, and prints a PHC string such as `$argon2id$v=19$...`. Copy the complete string into the user's `password_hash` field. The PHC string includes a cryptographically random salt, the Argon2id parameters, and the hash; no separate salt field is needed. Running the helper twice for the same password produces different salted hashes.
|
||||||
|
|
||||||
|
Password verification uses bounded blocking work so Argon2 does not stall Tokio's async runtime. Decoded password buffers are zeroized after use.
|
||||||
|
|
||||||
|
**Basic authentication is not encrypted in transit.** Argon2 protects the stored password hash, but a client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls.
|
||||||
|
|
||||||
## OpenTelemetry
|
## OpenTelemetry
|
||||||
|
|
||||||
@@ -51,10 +111,10 @@ Hermes exports traces, metrics, and logs independently to an OpenTelemetry Colle
|
|||||||
```sh
|
```sh
|
||||||
export OTEL_SERVICE_NAME=hermes
|
export OTEL_SERVICE_NAME=hermes
|
||||||
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||||
cargo run
|
cargo run -- --config examples/hermes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Signal-specific settings such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, and `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` are also supported by the exporter. Standard OTLP headers, timeouts, and trace sampling environment variables can be used.
|
Signal-specific settings such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, and `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` are also supported. Standard OTLP headers, timeouts, and trace sampling environment variables can be used.
|
||||||
|
|
||||||
Traces and logs are batched, metrics are exported periodically, and all providers are flushed during shutdown. An unavailable collector does not prevent the proxy from forwarding traffic, although exporter errors may appear in stdout logs.
|
Traces and logs are batched, metrics are exported periodically, and all providers are flushed during shutdown. An unavailable collector does not prevent the proxy from forwarding traffic, although exporter errors may appear in stdout logs.
|
||||||
|
|
||||||
@@ -78,11 +138,11 @@ In another terminal, run Hermes and send a request through it. The collector wil
|
|||||||
- `proxy.connect`: `CONNECT` handshake and tunnel lifetime
|
- `proxy.connect`: `CONNECT` handshake and tunnel lifetime
|
||||||
- `proxy.upstream_connect`: upstream TCP connection establishment
|
- `proxy.upstream_connect`: upstream TCP connection establishment
|
||||||
|
|
||||||
For plain HTTP, incoming W3C `traceparent` and `tracestate` headers establish the parent context, and a new context is injected upstream. Trace context cannot be propagated inside an encrypted `CONNECT` tunnel.
|
For plain HTTP, incoming W3C `traceparent` and `tracestate` headers establish the parent context, and a new context is injected upstream. Trace context cannot be propagated inside an encrypted `CONNECT` tunnel. Request spans include the ACL decision, reason, and matched rule name without credentials.
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
Hermes uses standard HTTP instrument names where applicable and adds proxy-specific tunnel instruments:
|
Hermes uses standard HTTP instrument names where applicable and adds proxy-specific instruments:
|
||||||
|
|
||||||
- `hermes.proxy.requests`
|
- `hermes.proxy.requests`
|
||||||
- `http.server.request.duration`
|
- `http.server.request.duration`
|
||||||
@@ -92,17 +152,18 @@ Hermes uses standard HTTP instrument names where applicable and adds proxy-speci
|
|||||||
- `hermes.proxy.tunnels.active`
|
- `hermes.proxy.tunnels.active`
|
||||||
- `hermes.proxy.tunnel.duration`
|
- `hermes.proxy.tunnel.duration`
|
||||||
- `hermes.proxy.tunnel.bytes`
|
- `hermes.proxy.tunnel.bytes`
|
||||||
|
- `hermes.proxy.acl.decisions`
|
||||||
|
|
||||||
Metric attributes are deliberately bounded. Raw URLs, destination hostnames, and client addresses are not metric attributes.
|
Metric attributes are deliberately bounded. Raw URLs, destination hostnames, usernames, rule names, and client addresses are not metric attributes.
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
|
|
||||||
Startup, shutdown, request failures, and tunnel completion are emitted as structured `tracing` events. Logs emitted inside a trace span include its trace and span IDs in OTLP. Headers, bodies, credentials, and query strings are not logged by the proxy.
|
Startup, shutdown, ACL decisions, request completion, request failures, and tunnel completion are emitted as structured `tracing` events. Logs emitted inside a trace span include its trace and span IDs in OTLP. Headers, bodies, passwords, authorization values, password hashes, and query strings are not logged by the proxy.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- HTTP/1.1 downstream and upstream only; no HTTP/2 or gRPC proxying
|
- HTTP/1.1 downstream and upstream only; no HTTP/2 or gRPC proxying
|
||||||
- No TLS interception, caching, authentication, ACLs, or rate limiting
|
- No downstream TLS, TLS interception, caching, rate limiting, or configuration hot reload
|
||||||
- No HTTP Upgrade forwarding other than `CONNECT`
|
- No HTTP Upgrade forwarding other than `CONNECT`
|
||||||
- No dynamic configuration or Envoy xDS support
|
- No dynamic configuration or Envoy xDS support
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
listen_addr: "127.0.0.1:8080"
|
||||||
|
connect_timeout_ms: 10000
|
||||||
|
upstream_timeout_ms: 30000
|
||||||
|
|
||||||
|
acl:
|
||||||
|
default_action: deny
|
||||||
|
users: {}
|
||||||
|
rules:
|
||||||
|
- name: localhost
|
||||||
|
action: allow
|
||||||
|
networks: ["127.0.0.1/32", "::1/128"]
|
||||||
|
|
||||||
|
# To require Basic authentication, generate a hash with
|
||||||
|
# `cargo run -- hash-password`, then configure a user and rule:
|
||||||
|
#
|
||||||
|
# acl:
|
||||||
|
# default_action: deny
|
||||||
|
# users:
|
||||||
|
# alice:
|
||||||
|
# password_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
# rules:
|
||||||
|
# - name: alice
|
||||||
|
# action: allow
|
||||||
|
# users: ["alice"]
|
||||||
+550
@@ -0,0 +1,550 @@
|
|||||||
|
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
Algorithm, Argon2, Params, Version,
|
||||||
|
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
|
||||||
|
};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use http::{HeaderMap, header::PROXY_AUTHORIZATION};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
BoxError,
|
||||||
|
config::{AclConfig, Action, RuleConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_AUTHORIZATION_HEADER_LEN: usize = 4096;
|
||||||
|
const MAX_USERNAME_LEN: usize = 256;
|
||||||
|
const MAX_PASSWORD_LEN: usize = 1024;
|
||||||
|
const MAX_CONCURRENT_VERIFICATIONS: usize = 4;
|
||||||
|
const MIN_ARGON2_MEMORY_KIB: u32 = 4096;
|
||||||
|
const MAX_ARGON2_MEMORY_KIB: u32 = 65_536;
|
||||||
|
const MAX_ARGON2_ITERATIONS: u32 = 10;
|
||||||
|
const MAX_ARGON2_PARALLELISM: u32 = 4;
|
||||||
|
|
||||||
|
pub(crate) struct Acl {
|
||||||
|
default_action: Action,
|
||||||
|
users: std::collections::BTreeMap<String, User>,
|
||||||
|
rules: Vec<Rule>,
|
||||||
|
has_user_rules: bool,
|
||||||
|
verification_limit: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct User {
|
||||||
|
password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Rule {
|
||||||
|
name: String,
|
||||||
|
action: Action,
|
||||||
|
users: BTreeSet<String>,
|
||||||
|
networks: Vec<IpNet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum Decision {
|
||||||
|
Allow,
|
||||||
|
Deny,
|
||||||
|
Challenge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Allow => "allow",
|
||||||
|
Self::Deny => "deny",
|
||||||
|
Self::Challenge => "challenge",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct AclOutcome {
|
||||||
|
pub decision: Decision,
|
||||||
|
pub reason: &'static str,
|
||||||
|
pub rule: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AclOutcome {
|
||||||
|
fn rule(rule: &Rule) -> Self {
|
||||||
|
let reason = match (rule.users.is_empty(), rule.networks.is_empty()) {
|
||||||
|
(false, false) => "user_and_network",
|
||||||
|
(false, true) => "user",
|
||||||
|
(true, false) => "network",
|
||||||
|
(true, true) => unreachable!("empty ACL rules are rejected"),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
decision: match rule.action {
|
||||||
|
Action::Allow => Decision::Allow,
|
||||||
|
Action::Deny => Decision::Deny,
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
rule: Some(rule.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default(action: Action) -> Self {
|
||||||
|
Self {
|
||||||
|
decision: match action {
|
||||||
|
Action::Allow => Decision::Allow,
|
||||||
|
Action::Deny => Decision::Deny,
|
||||||
|
},
|
||||||
|
reason: "default",
|
||||||
|
rule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn challenge(reason: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
decision: Decision::Challenge,
|
||||||
|
reason,
|
||||||
|
rule: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Acl {
|
||||||
|
pub fn from_config(config: AclConfig) -> Result<Self, AclError> {
|
||||||
|
let mut users = std::collections::BTreeMap::new();
|
||||||
|
for (username, user) in config.users {
|
||||||
|
validate_password_hash(&username, &user.password_hash)?;
|
||||||
|
users.insert(
|
||||||
|
username,
|
||||||
|
User {
|
||||||
|
password_hash: user.password_hash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
let mut rules = Vec::with_capacity(config.rules.len());
|
||||||
|
for rule in config.rules {
|
||||||
|
let compiled = compile_rule(rule, &users)?;
|
||||||
|
if !names.insert(compiled.name.clone()) {
|
||||||
|
return Err(AclError::DuplicateRule(compiled.name));
|
||||||
|
}
|
||||||
|
rules.push(compiled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_user_rules = rules.iter().any(|rule| !rule.users.is_empty());
|
||||||
|
Ok(Self {
|
||||||
|
default_action: config.default_action,
|
||||||
|
users,
|
||||||
|
rules,
|
||||||
|
has_user_rules,
|
||||||
|
verification_limit: Arc::new(Semaphore::new(MAX_CONCURRENT_VERIFICATIONS)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize(
|
||||||
|
&self,
|
||||||
|
peer: IpAddr,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<AclOutcome, AclError> {
|
||||||
|
let credentials = match parse_basic_credentials(headers) {
|
||||||
|
Credentials::Missing => None,
|
||||||
|
Credentials::Invalid => return Ok(AclOutcome::challenge("invalid_credentials")),
|
||||||
|
Credentials::Present(credentials) => Some(credentials),
|
||||||
|
};
|
||||||
|
|
||||||
|
let authenticated_user = match credentials {
|
||||||
|
Some(credentials) => {
|
||||||
|
let Some(user) = self.users.get(&credentials.username) else {
|
||||||
|
return Ok(AclOutcome::challenge("invalid_credentials"));
|
||||||
|
};
|
||||||
|
if !self
|
||||||
|
.verify_password(user.password_hash.clone(), credentials.password)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(AclOutcome::challenge("invalid_credentials"));
|
||||||
|
}
|
||||||
|
Some(credentials.username)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for rule in &self.rules {
|
||||||
|
if rule_matches(rule, peer, authenticated_user.as_deref()) {
|
||||||
|
return Ok(AclOutcome::rule(rule));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if authenticated_user.is_none()
|
||||||
|
&& self.default_action == Action::Deny
|
||||||
|
&& self.has_user_rules
|
||||||
|
{
|
||||||
|
return Ok(AclOutcome::challenge("authentication_required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AclOutcome::default(self.default_action))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_password(
|
||||||
|
&self,
|
||||||
|
password_hash: String,
|
||||||
|
password: Zeroizing<Vec<u8>>,
|
||||||
|
) -> Result<bool, AclError> {
|
||||||
|
let permit = self
|
||||||
|
.verification_limit
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.map_err(|_| AclError::AuthenticationTask)?;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let _permit = permit;
|
||||||
|
let parsed =
|
||||||
|
PasswordHash::new(&password_hash).map_err(|_| AclError::AuthenticationTask)?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_slice(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AclError::AuthenticationTask)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_rule(
|
||||||
|
rule: RuleConfig,
|
||||||
|
users: &std::collections::BTreeMap<String, User>,
|
||||||
|
) -> Result<Rule, AclError> {
|
||||||
|
if rule.name.trim().is_empty() {
|
||||||
|
return Err(AclError::EmptyRuleName);
|
||||||
|
}
|
||||||
|
if rule.users.is_empty() && rule.networks.is_empty() {
|
||||||
|
return Err(AclError::EmptyRule(rule.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for username in &rule.users {
|
||||||
|
if !users.contains_key(username) {
|
||||||
|
return Err(AclError::UnknownUser {
|
||||||
|
rule: rule.name,
|
||||||
|
username: username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut networks = Vec::with_capacity(rule.networks.len());
|
||||||
|
for network in rule.networks {
|
||||||
|
let parsed = network.parse().map_err(|_| AclError::InvalidNetwork {
|
||||||
|
rule: rule.name.clone(),
|
||||||
|
network,
|
||||||
|
})?;
|
||||||
|
networks.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Rule {
|
||||||
|
name: rule.name,
|
||||||
|
action: rule.action,
|
||||||
|
users: rule.users.into_iter().collect(),
|
||||||
|
networks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_password_hash(username: &str, encoded: &str) -> Result<(), AclError> {
|
||||||
|
let hash = PasswordHash::new(encoded)
|
||||||
|
.map_err(|_| AclError::InvalidPasswordHash(username.to_owned()))?;
|
||||||
|
if hash.algorithm.as_str() != Algorithm::Argon2id.as_str()
|
||||||
|
|| hash.version != Some(u32::from(Version::V0x13 as u8))
|
||||||
|
{
|
||||||
|
return Err(AclError::InvalidPasswordHash(username.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let params =
|
||||||
|
Params::try_from(&hash).map_err(|_| AclError::InvalidPasswordHash(username.to_owned()))?;
|
||||||
|
if !(MIN_ARGON2_MEMORY_KIB..=MAX_ARGON2_MEMORY_KIB).contains(¶ms.m_cost())
|
||||||
|
|| params.t_cost() == 0
|
||||||
|
|| params.t_cost() > MAX_ARGON2_ITERATIONS
|
||||||
|
|| params.p_cost() == 0
|
||||||
|
|| params.p_cost() > MAX_ARGON2_PARALLELISM
|
||||||
|
{
|
||||||
|
return Err(AclError::UnsafePasswordParameters(username.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule_matches(rule: &Rule, peer: IpAddr, authenticated_user: Option<&str>) -> bool {
|
||||||
|
let user_matches = rule.users.is_empty()
|
||||||
|
|| authenticated_user.is_some_and(|username| rule.users.contains(username));
|
||||||
|
let network_matches =
|
||||||
|
rule.networks.is_empty() || rule.networks.iter().any(|network| network.contains(&peer));
|
||||||
|
user_matches && network_matches
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Credentials {
|
||||||
|
Missing,
|
||||||
|
Invalid,
|
||||||
|
Present(BasicCredentials),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BasicCredentials {
|
||||||
|
username: String,
|
||||||
|
password: Zeroizing<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_basic_credentials(headers: &HeaderMap) -> Credentials {
|
||||||
|
let mut values = headers.get_all(PROXY_AUTHORIZATION).iter();
|
||||||
|
let Some(value) = values.next() else {
|
||||||
|
return Credentials::Missing;
|
||||||
|
};
|
||||||
|
if values.next().is_some() || value.len() > MAX_AUTHORIZATION_HEADER_LEN {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(value) = value.to_str() else {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
};
|
||||||
|
let mut parts = value.split_ascii_whitespace();
|
||||||
|
let (Some(scheme), Some(encoded), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
};
|
||||||
|
if !scheme.eq_ignore_ascii_case("basic") {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(decoded) = BASE64_STANDARD.decode(encoded) else {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
};
|
||||||
|
let decoded = Zeroizing::new(decoded);
|
||||||
|
let Some(separator) = decoded.iter().position(|byte| *byte == b':') else {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
};
|
||||||
|
let Ok(username) = std::str::from_utf8(&decoded[..separator]) else {
|
||||||
|
return Credentials::Invalid;
|
||||||
|
};
|
||||||
|
let password = &decoded[separator + 1..];
|
||||||
|
if username.is_empty()
|
||||||
|
|| username.len() > MAX_USERNAME_LEN
|
||||||
|
|| password.is_empty()
|
||||||
|
|| password.len() > MAX_PASSWORD_LEN
|
||||||
|
{
|
||||||
|
return Credentials::Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
Credentials::Present(BasicCredentials {
|
||||||
|
username: username.to_owned(),
|
||||||
|
password: Zeroizing::new(password.to_vec()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_password(password: &[u8]) -> Result<String, BoxError> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("password must not be empty".into());
|
||||||
|
}
|
||||||
|
if password.len() > MAX_PASSWORD_LEN {
|
||||||
|
return Err("password must not exceed 1024 bytes".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Argon2::default().hash_password(password)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AclError {
|
||||||
|
#[error("ACL rule names must not be empty")]
|
||||||
|
EmptyRuleName,
|
||||||
|
#[error("ACL rule {0:?} must have a user or network selector")]
|
||||||
|
EmptyRule(String),
|
||||||
|
#[error("ACL rule name {0:?} is duplicated")]
|
||||||
|
DuplicateRule(String),
|
||||||
|
#[error("ACL rule {rule:?} references unknown user {username:?}")]
|
||||||
|
UnknownUser { rule: String, username: String },
|
||||||
|
#[error("ACL rule {rule:?} has invalid network {network:?}")]
|
||||||
|
InvalidNetwork { rule: String, network: String },
|
||||||
|
#[error("ACL user {0:?} has an invalid or unsupported Argon2id password hash")]
|
||||||
|
InvalidPasswordHash(String),
|
||||||
|
#[error("ACL user {0:?} has unsafe Argon2id parameters")]
|
||||||
|
UnsafePasswordParameters(String),
|
||||||
|
#[error("password verification task failed")]
|
||||||
|
AuthenticationTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use http::HeaderValue;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{RuleConfig, UserConfig};
|
||||||
|
|
||||||
|
const PASSWORD: &[u8] = b"correct horse battery staple";
|
||||||
|
|
||||||
|
fn user_rule(name: &str, username: &str, action: Action) -> RuleConfig {
|
||||||
|
RuleConfig {
|
||||||
|
name: name.to_owned(),
|
||||||
|
action,
|
||||||
|
users: vec![username.to_owned()],
|
||||||
|
networks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_acl(default_action: Action) -> Acl {
|
||||||
|
let password_hash = hash_password(PASSWORD).unwrap();
|
||||||
|
Acl::from_config(AclConfig {
|
||||||
|
default_action,
|
||||||
|
users: BTreeMap::from([("alice".to_owned(), UserConfig { password_hash })]),
|
||||||
|
rules: vec![user_rule("alice", "alice", Action::Allow)],
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers(username: &str, password: &str) -> HeaderMap {
|
||||||
|
let encoded = BASE64_STANDARD.encode(format!("{username}:{password}"));
|
||||||
|
HeaderMap::from_iter([(
|
||||||
|
PROXY_AUTHORIZATION,
|
||||||
|
HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(),
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authenticates_basic_user() {
|
||||||
|
let acl = user_acl(Action::Deny);
|
||||||
|
let outcome = acl
|
||||||
|
.authorize(
|
||||||
|
"127.0.0.1".parse().unwrap(),
|
||||||
|
&headers("alice", "correct horse battery staple"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.decision, Decision::Allow);
|
||||||
|
assert_eq!(outcome.reason, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn challenges_missing_malformed_unknown_and_wrong_credentials() {
|
||||||
|
let acl = user_acl(Action::Deny);
|
||||||
|
let malformed = HeaderMap::from_iter([(
|
||||||
|
PROXY_AUTHORIZATION,
|
||||||
|
HeaderValue::from_static("Basic not-valid-base64!"),
|
||||||
|
)]);
|
||||||
|
for headers in [
|
||||||
|
HeaderMap::new(),
|
||||||
|
headers("bob", "correct horse battery staple"),
|
||||||
|
headers("alice", "wrong"),
|
||||||
|
malformed,
|
||||||
|
] {
|
||||||
|
let outcome = acl
|
||||||
|
.authorize("127.0.0.1".parse().unwrap(), &headers)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.decision, Decision::Challenge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn matches_ip_network_without_credentials() {
|
||||||
|
let acl = Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Deny,
|
||||||
|
users: BTreeMap::new(),
|
||||||
|
rules: vec![RuleConfig {
|
||||||
|
name: "localhost".to_owned(),
|
||||||
|
action: Action::Allow,
|
||||||
|
users: vec![],
|
||||||
|
networks: vec!["127.0.0.0/8".to_owned(), "::1/128".to_owned()],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let allowed = acl
|
||||||
|
.authorize("127.0.0.2".parse().unwrap(), &HeaderMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(allowed.decision, Decision::Allow);
|
||||||
|
|
||||||
|
let denied = acl
|
||||||
|
.authorize("192.0.2.1".parse().unwrap(), &HeaderMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denied.decision, Decision::Deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn applies_first_match_and_combines_selectors() {
|
||||||
|
let password_hash = hash_password(PASSWORD).unwrap();
|
||||||
|
let acl = Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Allow,
|
||||||
|
users: BTreeMap::from([("alice".to_owned(), UserConfig { password_hash })]),
|
||||||
|
rules: vec![
|
||||||
|
RuleConfig {
|
||||||
|
name: "deny-alice-off-network".to_owned(),
|
||||||
|
action: Action::Deny,
|
||||||
|
users: vec!["alice".to_owned()],
|
||||||
|
networks: vec!["192.0.2.0/24".to_owned()],
|
||||||
|
},
|
||||||
|
user_rule("allow-alice", "alice", Action::Allow),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let denied = acl
|
||||||
|
.authorize(
|
||||||
|
"192.0.2.10".parse().unwrap(),
|
||||||
|
&headers("alice", "correct horse battery staple"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(denied.decision, Decision::Deny);
|
||||||
|
assert_eq!(denied.rule.as_deref(), Some("deny-alice-off-network"));
|
||||||
|
|
||||||
|
let allowed = acl
|
||||||
|
.authorize(
|
||||||
|
"198.51.100.10".parse().unwrap(),
|
||||||
|
&headers("alice", "correct horse battery staple"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(allowed.decision, Decision::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_salted_hashes_that_verify() {
|
||||||
|
let first = hash_password(PASSWORD).unwrap();
|
||||||
|
let second = hash_password(PASSWORD).unwrap();
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(first.starts_with("$argon2id$v=19$"));
|
||||||
|
|
||||||
|
for encoded in [first, second] {
|
||||||
|
let parsed = PasswordHash::new(&encoded).unwrap();
|
||||||
|
assert!(Argon2::default().verify_password(PASSWORD, &parsed).is_ok());
|
||||||
|
assert!(
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(b"wrong", &parsed)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_rules_and_hashes() {
|
||||||
|
let empty = Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Deny,
|
||||||
|
users: BTreeMap::new(),
|
||||||
|
rules: vec![RuleConfig {
|
||||||
|
name: "accidental-match-all".to_owned(),
|
||||||
|
action: Action::Allow,
|
||||||
|
users: vec![],
|
||||||
|
networks: vec![],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert!(matches!(empty, Err(AclError::EmptyRule(_))));
|
||||||
|
|
||||||
|
let invalid_hash = Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Deny,
|
||||||
|
users: BTreeMap::from([(
|
||||||
|
"alice".to_owned(),
|
||||||
|
UserConfig {
|
||||||
|
password_hash: "plaintext".to_owned(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
rules: vec![],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
invalid_hash,
|
||||||
|
Err(AclError::InvalidPasswordHash(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
-31
@@ -1,61 +1,153 @@
|
|||||||
use std::{env, net::SocketAddr, num::ParseIntError, time::Duration};
|
use std::{collections::BTreeMap, fs, net::SocketAddr, path::Path, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:8080";
|
use crate::acl::{Acl, AclError};
|
||||||
|
|
||||||
const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||||
const DEFAULT_UPSTREAM_TIMEOUT_MS: u64 = 30_000;
|
const DEFAULT_UPSTREAM_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
pub upstream_timeout: Duration,
|
pub upstream_timeout: Duration,
|
||||||
|
pub(crate) acl: Arc<Acl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
|
||||||
let listen_addr = env::var("HERMES_LISTEN_ADDR")
|
let path = path.as_ref();
|
||||||
.unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned())
|
let yaml = fs::read_to_string(path).map_err(|source| ConfigError::Read {
|
||||||
.parse()?;
|
path: path.display().to_string(),
|
||||||
let connect_timeout =
|
source,
|
||||||
duration_from_env("HERMES_CONNECT_TIMEOUT_MS", DEFAULT_CONNECT_TIMEOUT_MS)?;
|
})?;
|
||||||
let upstream_timeout =
|
Self::from_yaml(&yaml)
|
||||||
duration_from_env("HERMES_UPSTREAM_TIMEOUT_MS", DEFAULT_UPSTREAM_TIMEOUT_MS)?;
|
}
|
||||||
|
|
||||||
|
fn from_yaml(yaml: &str) -> Result<Self, ConfigError> {
|
||||||
|
let file: FileConfig = serde_norway::from_str(yaml)?;
|
||||||
|
if file.connect_timeout_ms == 0 {
|
||||||
|
return Err(ConfigError::ZeroDuration("connect_timeout_ms"));
|
||||||
|
}
|
||||||
|
if file.upstream_timeout_ms == 0 {
|
||||||
|
return Err(ConfigError::ZeroDuration("upstream_timeout_ms"));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen_addr,
|
listen_addr: file.listen_addr,
|
||||||
connect_timeout,
|
connect_timeout: Duration::from_millis(file.connect_timeout_ms),
|
||||||
upstream_timeout,
|
upstream_timeout: Duration::from_millis(file.upstream_timeout_ms),
|
||||||
|
acl: Arc::new(Acl::from_config(file.acl)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn duration_from_env(name: &'static str, default_ms: u64) -> Result<Duration, ConfigError> {
|
#[derive(Deserialize)]
|
||||||
let milliseconds = match env::var(name) {
|
#[serde(deny_unknown_fields)]
|
||||||
Ok(value) => value
|
struct FileConfig {
|
||||||
.parse()
|
#[serde(default = "default_listen_addr")]
|
||||||
.map_err(|source| ConfigError::InvalidDuration { name, source })?,
|
listen_addr: SocketAddr,
|
||||||
Err(_) => default_ms,
|
#[serde(default = "default_connect_timeout_ms")]
|
||||||
};
|
connect_timeout_ms: u64,
|
||||||
|
#[serde(default = "default_upstream_timeout_ms")]
|
||||||
|
upstream_timeout_ms: u64,
|
||||||
|
acl: AclConfig,
|
||||||
|
}
|
||||||
|
|
||||||
if milliseconds == 0 {
|
fn default_listen_addr() -> SocketAddr {
|
||||||
return Err(ConfigError::ZeroDuration(name));
|
SocketAddr::from(([127, 0, 0, 1], 8080))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Duration::from_millis(milliseconds))
|
const fn default_connect_timeout_ms() -> u64 {
|
||||||
|
DEFAULT_CONNECT_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_upstream_timeout_ms() -> u64 {
|
||||||
|
DEFAULT_UPSTREAM_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct AclConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_action: Action,
|
||||||
|
#[serde(default)]
|
||||||
|
pub users: BTreeMap<String, UserConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rules: Vec<RuleConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct UserConfig {
|
||||||
|
pub password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct RuleConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub action: Action,
|
||||||
|
#[serde(default)]
|
||||||
|
pub users: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub networks: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub(crate) enum Action {
|
||||||
|
Allow,
|
||||||
|
#[default]
|
||||||
|
Deny,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error("invalid HERMES_LISTEN_ADDR: {0}")]
|
#[error("failed to read configuration {path}: {source}")]
|
||||||
InvalidListenAddr(#[from] std::net::AddrParseError),
|
Read {
|
||||||
#[error("invalid {name}: {source}")]
|
path: String,
|
||||||
InvalidDuration {
|
|
||||||
name: &'static str,
|
|
||||||
#[source]
|
#[source]
|
||||||
source: ParseIntError,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
#[error("invalid YAML configuration: {0}")]
|
||||||
|
Yaml(#[from] serde_norway::Error),
|
||||||
#[error("{0} must be greater than zero")]
|
#[error("{0} must be greater than zero")]
|
||||||
ZeroDuration(&'static str),
|
ZeroDuration(&'static str),
|
||||||
|
#[error(transparent)]
|
||||||
|
Acl(#[from] AclError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_defaults_and_localhost_acl() {
|
||||||
|
let config = Config::from_yaml(
|
||||||
|
r#"
|
||||||
|
acl:
|
||||||
|
rules:
|
||||||
|
- name: localhost
|
||||||
|
action: allow
|
||||||
|
networks: ["127.0.0.1/32", "::1/128"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.listen_addr, default_listen_addr());
|
||||||
|
assert_eq!(config.connect_timeout, Duration::from_secs(10));
|
||||||
|
assert_eq!(config.upstream_timeout, Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_fields_and_zero_timeouts() {
|
||||||
|
let unknown = Config::from_yaml("acl: {}\nunknown: true\n");
|
||||||
|
assert!(unknown.is_err());
|
||||||
|
|
||||||
|
let zero = Config::from_yaml("connect_timeout_ms: 0\nacl: {}\n");
|
||||||
|
assert!(matches!(zero, Err(ConfigError::ZeroDuration(_))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod acl;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
|
||||||
|
|||||||
+35
-3
@@ -1,11 +1,43 @@
|
|||||||
use hermes::{BoxError, config::Config, telemetry};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use hermes::{BoxError, acl, config::Config, telemetry};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about = "A minimal HTTP forward proxy with OpenTelemetry observability"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long, default_value = "hermes.yaml", value_name = "PATH")]
|
||||||
|
config: PathBuf,
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Generate a salted Argon2id password hash for the YAML configuration.
|
||||||
|
HashPassword,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), BoxError> {
|
async fn main() -> Result<(), BoxError> {
|
||||||
let telemetry = telemetry::init()?;
|
let cli = Cli::parse();
|
||||||
|
if matches!(cli.command, Some(Command::HashPassword)) {
|
||||||
|
let password = Zeroizing::new(rpassword::prompt_password("New password: ")?);
|
||||||
|
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm password: ")?);
|
||||||
|
if password.as_str() != confirmation.as_str() {
|
||||||
|
return Err("passwords do not match".into());
|
||||||
|
}
|
||||||
|
println!("{}", acl::hash_password(password.as_bytes())?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let telemetry = telemetry::init()?;
|
||||||
let result: Result<(), BoxError> = async {
|
let result: Result<(), BoxError> = async {
|
||||||
let config = Config::from_env()?;
|
let config = Config::load(&cli.config)?;
|
||||||
hermes::run(config).await
|
hermes::run(config).await
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub(crate) struct Metrics {
|
|||||||
active_tunnels: UpDownCounter<i64>,
|
active_tunnels: UpDownCounter<i64>,
|
||||||
tunnel_duration: Histogram<f64>,
|
tunnel_duration: Histogram<f64>,
|
||||||
tunnel_bytes: Counter<u64>,
|
tunnel_bytes: Counter<u64>,
|
||||||
|
acl_decisions: Counter<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
@@ -60,6 +61,11 @@ impl Metrics {
|
|||||||
.with_description("Bytes relayed through completed CONNECT tunnels")
|
.with_description("Bytes relayed through completed CONNECT tunnels")
|
||||||
.with_unit("By")
|
.with_unit("By")
|
||||||
.build(),
|
.build(),
|
||||||
|
acl_decisions: meter
|
||||||
|
.u64_counter("hermes.proxy.acl.decisions")
|
||||||
|
.with_description("Number of ACL decisions by outcome and reason")
|
||||||
|
.with_unit("{decision}")
|
||||||
|
.build(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +128,16 @@ impl Metrics {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn acl_decision(&self, decision: &str, reason: &str) {
|
||||||
|
self.acl_decisions.add(
|
||||||
|
1,
|
||||||
|
&[
|
||||||
|
KeyValue::new("acl.decision", decision.to_owned()),
|
||||||
|
KeyValue::new("acl.reason", reason.to_owned()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn tunnel_started(&self) -> ActiveTunnel {
|
pub fn tunnel_started(&self) -> ActiveTunnel {
|
||||||
self.active_tunnels.add(1, &[]);
|
self.active_tunnels.add(1, &[]);
|
||||||
ActiveTunnel {
|
ActiveTunnel {
|
||||||
|
|||||||
+185
-22
@@ -1,7 +1,16 @@
|
|||||||
use std::{convert::Infallible, net::SocketAddr, time::Instant};
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
sync::Arc,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header::HOST, uri::Authority};
|
use http::{
|
||||||
|
HeaderValue, Method, Request, Response, StatusCode, Uri,
|
||||||
|
header::{HOST, PROXY_AUTHENTICATE},
|
||||||
|
uri::Authority,
|
||||||
|
};
|
||||||
use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody};
|
use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody};
|
||||||
use hyper::{body::Incoming, server::conn::http1, service::service_fn};
|
use hyper::{body::Incoming, server::conn::http1, service::service_fn};
|
||||||
use hyper_util::{
|
use hyper_util::{
|
||||||
@@ -14,11 +23,12 @@ use tokio::{
|
|||||||
task::JoinSet,
|
task::JoinSet,
|
||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
use tracing::{Instrument, Span, debug, error, field, info, info_span};
|
use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
|
||||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
BoxError,
|
BoxError,
|
||||||
|
acl::{Acl, Decision},
|
||||||
config::Config,
|
config::Config,
|
||||||
metrics::Metrics,
|
metrics::Metrics,
|
||||||
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
|
telemetry::{HeaderExtractor, HeaderInjector, remove_hop_by_hop_headers},
|
||||||
@@ -31,6 +41,7 @@ type HttpClient = Client<HttpConnector, Incoming>;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ProxyState {
|
struct ProxyState {
|
||||||
client: HttpClient,
|
client: HttpClient,
|
||||||
|
acl: Arc<Acl>,
|
||||||
metrics: Metrics,
|
metrics: Metrics,
|
||||||
connect_timeout: std::time::Duration,
|
connect_timeout: std::time::Duration,
|
||||||
upstream_timeout: std::time::Duration,
|
upstream_timeout: std::time::Duration,
|
||||||
@@ -44,6 +55,7 @@ impl ProxyState {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
client: Client::builder(TokioExecutor::new()).build(connector),
|
client: Client::builder(TokioExecutor::new()).build(connector),
|
||||||
|
acl: config.acl.clone(),
|
||||||
metrics: Metrics::new(global::meter("hermes")),
|
metrics: Metrics::new(global::meter("hermes")),
|
||||||
connect_timeout: config.connect_timeout,
|
connect_timeout: config.connect_timeout,
|
||||||
upstream_timeout: config.upstream_timeout,
|
upstream_timeout: config.upstream_timeout,
|
||||||
@@ -92,9 +104,10 @@ pub async fn run(config: Config) -> Result<(), BoxError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState) {
|
async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState) {
|
||||||
|
let peer_ip = peer.ip();
|
||||||
let service = service_fn(move |request| {
|
let service = service_fn(move |request| {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
async move { Ok::<_, Infallible>(handle_request(request, state).await) }
|
async move { Ok::<_, Infallible>(handle_request(request, peer_ip, state).await) }
|
||||||
});
|
});
|
||||||
|
|
||||||
let connection = http1::Builder::new()
|
let connection = http1::Builder::new()
|
||||||
@@ -110,15 +123,19 @@ async fn serve_connection(stream: TcpStream, peer: SocketAddr, state: ProxyState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
|
async fn handle_request(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
peer: IpAddr,
|
||||||
|
state: ProxyState,
|
||||||
|
) -> ProxyResponse {
|
||||||
if request.method() == Method::CONNECT {
|
if request.method() == Method::CONNECT {
|
||||||
handle_connect(request, state).await
|
handle_connect(request, peer, state).await
|
||||||
} else {
|
} else {
|
||||||
handle_http(request, state).await
|
handle_http(request, peer, state).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
|
async fn handle_http(request: Request<Incoming>, peer: IpAddr, state: ProxyState) -> ProxyResponse {
|
||||||
let method = request.method().to_string();
|
let method = request.method().to_string();
|
||||||
let uri = request.uri().clone();
|
let uri = request.uri().clone();
|
||||||
let parent = global::get_text_map_propagator(|propagator| {
|
let parent = global::get_text_map_propagator(|propagator| {
|
||||||
@@ -134,6 +151,9 @@ async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResp
|
|||||||
server.address = %uri.authority().map(Authority::host).unwrap_or_default(),
|
server.address = %uri.authority().map(Authority::host).unwrap_or_default(),
|
||||||
http.response.status_code = field::Empty,
|
http.response.status_code = field::Empty,
|
||||||
error.type = field::Empty,
|
error.type = field::Empty,
|
||||||
|
acl.decision = field::Empty,
|
||||||
|
acl.reason = field::Empty,
|
||||||
|
acl.rule = field::Empty,
|
||||||
otel.status_code = field::Empty,
|
otel.status_code = field::Empty,
|
||||||
);
|
);
|
||||||
let _ = span.set_parent(parent);
|
let _ = span.set_parent(parent);
|
||||||
@@ -141,13 +161,73 @@ async fn handle_http(request: Request<Incoming>, state: ProxyState) -> ProxyResp
|
|||||||
async move {
|
async move {
|
||||||
let _active = state.metrics.request_started();
|
let _active = state.metrics.request_started();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let result = forward_http(request, &state).await;
|
let result = match authorize_request(&request, peer, &state).await {
|
||||||
|
Ok(Some(response)) => Ok(response),
|
||||||
|
Ok(None) => forward_http(request, &state).await,
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
finish_request(result, &method, started, &state.metrics)
|
finish_request(result, &method, started, &state.metrics)
|
||||||
}
|
}
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_request(
|
||||||
|
request: &Request<Incoming>,
|
||||||
|
peer: IpAddr,
|
||||||
|
state: &ProxyState,
|
||||||
|
) -> Result<Option<ProxyResponse>, ProxyError> {
|
||||||
|
let outcome = state
|
||||||
|
.acl
|
||||||
|
.authorize(peer, request.headers())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
ProxyError::new(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"acl_internal",
|
||||||
|
"acl",
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.acl_decision(outcome.decision.as_str(), outcome.reason);
|
||||||
|
Span::current().record("acl.decision", outcome.decision.as_str());
|
||||||
|
Span::current().record("acl.reason", outcome.reason);
|
||||||
|
if let Some(rule) = &outcome.rule {
|
||||||
|
Span::current().record("acl.rule", rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
match outcome.decision {
|
||||||
|
Decision::Allow => {
|
||||||
|
debug!(
|
||||||
|
acl.reason = outcome.reason,
|
||||||
|
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
|
||||||
|
"ACL allowed request"
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Decision::Deny => {
|
||||||
|
warn!(
|
||||||
|
acl.reason = outcome.reason,
|
||||||
|
acl.rule = outcome.rule.as_deref().unwrap_or_default(),
|
||||||
|
"ACL denied request"
|
||||||
|
);
|
||||||
|
Ok(Some(error_response(StatusCode::FORBIDDEN)))
|
||||||
|
}
|
||||||
|
Decision::Challenge => {
|
||||||
|
info!(acl.reason = outcome.reason, "ACL challenged request");
|
||||||
|
let mut response = error_response(StatusCode::PROXY_AUTHENTICATION_REQUIRED);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
PROXY_AUTHENTICATE,
|
||||||
|
HeaderValue::from_static("Basic realm=\"hermes\", charset=\"UTF-8\""),
|
||||||
|
);
|
||||||
|
Ok(Some(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn forward_http(
|
async fn forward_http(
|
||||||
mut request: Request<Incoming>,
|
mut request: Request<Incoming>,
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
@@ -238,7 +318,11 @@ async fn send_upstream(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyResponse {
|
async fn handle_connect(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
peer: IpAddr,
|
||||||
|
state: ProxyState,
|
||||||
|
) -> ProxyResponse {
|
||||||
let authority = request.uri().authority().cloned();
|
let authority = request.uri().authority().cloned();
|
||||||
let parent = global::get_text_map_propagator(|propagator| {
|
let parent = global::get_text_map_propagator(|propagator| {
|
||||||
propagator.extract(&HeaderExtractor(request.headers()))
|
propagator.extract(&HeaderExtractor(request.headers()))
|
||||||
@@ -254,6 +338,9 @@ async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyR
|
|||||||
error.type = field::Empty,
|
error.type = field::Empty,
|
||||||
tunnel.bytes.client_to_upstream = field::Empty,
|
tunnel.bytes.client_to_upstream = field::Empty,
|
||||||
tunnel.bytes.upstream_to_client = field::Empty,
|
tunnel.bytes.upstream_to_client = field::Empty,
|
||||||
|
acl.decision = field::Empty,
|
||||||
|
acl.reason = field::Empty,
|
||||||
|
acl.rule = field::Empty,
|
||||||
otel.status_code = field::Empty,
|
otel.status_code = field::Empty,
|
||||||
);
|
);
|
||||||
let _ = span.set_parent(parent);
|
let _ = span.set_parent(parent);
|
||||||
@@ -261,7 +348,11 @@ async fn handle_connect(request: Request<Incoming>, state: ProxyState) -> ProxyR
|
|||||||
async move {
|
async move {
|
||||||
let _active = state.metrics.request_started();
|
let _active = state.metrics.request_started();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let result = prepare_connect(request, &state).await;
|
let result = match authorize_request(&request, peer, &state).await {
|
||||||
|
Ok(Some(response)) => Ok(response),
|
||||||
|
Ok(None) => prepare_connect(request, &state).await,
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
finish_request(result, "CONNECT", started, &state.metrics)
|
finish_request(result, "CONNECT", started, &state.metrics)
|
||||||
}
|
}
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
@@ -514,10 +605,13 @@ impl ProxyError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use http::header::PROXY_AUTHORIZATION;
|
||||||
use hyper::client::conn::http1 as client_http1;
|
use hyper::client::conn::http1 as client_http1;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
@@ -525,6 +619,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::{AclConfig, Action, RuleConfig, UserConfig};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_absolute_http_targets() {
|
fn validates_absolute_http_targets() {
|
||||||
@@ -583,13 +678,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let (proxy_addr, proxy_task) = spawn_proxy().await;
|
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await;
|
||||||
let (mut sender, connection_task) = connect_client(proxy_addr).await;
|
let (mut sender, connection_task) = connect_client(proxy_addr).await;
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
.method(Method::GET)
|
.method(Method::GET)
|
||||||
.uri(format!("http://{upstream_addr}/hello?q=1"))
|
.uri(format!("http://{upstream_addr}/hello?q=1"))
|
||||||
.header(HOST, "wrong.invalid")
|
.header(HOST, "wrong.invalid")
|
||||||
.header("proxy-authorization", "must-not-be-forwarded")
|
.header(PROXY_AUTHORIZATION, basic_authorization())
|
||||||
.body(Full::new(Bytes::new()))
|
.body(Full::new(Bytes::new()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -622,7 +717,7 @@ mod tests {
|
|||||||
stream.shutdown().await.unwrap();
|
stream.shutdown().await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let (proxy_addr, proxy_task) = spawn_proxy().await;
|
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(localhost_acl()).await;
|
||||||
let (mut sender, connection_task) = connect_client(proxy_addr).await;
|
let (mut sender, connection_task) = connect_client(proxy_addr).await;
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
.method(Method::CONNECT)
|
.method(Method::CONNECT)
|
||||||
@@ -645,18 +740,86 @@ mod tests {
|
|||||||
connection_task.abort();
|
connection_task.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_state() -> ProxyState {
|
#[tokio::test]
|
||||||
ProxyState::new(&Config {
|
async fn challenges_unauthenticated_http_and_connect_requests() {
|
||||||
listen_addr: "127.0.0.1:0".parse().unwrap(),
|
let (proxy_addr, proxy_task) = spawn_proxy_with_acl(basic_acl()).await;
|
||||||
connect_timeout: Duration::from_secs(2),
|
let (mut sender, connection_task) = connect_client(proxy_addr).await;
|
||||||
upstream_timeout: Duration::from_secs(2),
|
|
||||||
})
|
for request in [
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("http://192.0.2.1/")
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.unwrap(),
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::CONNECT)
|
||||||
|
.uri("192.0.2.1:443")
|
||||||
|
.body(Full::new(Bytes::new()))
|
||||||
|
.unwrap(),
|
||||||
|
] {
|
||||||
|
let response = sender.send_request(request).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::PROXY_AUTHENTICATION_REQUIRED);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()[PROXY_AUTHENTICATE],
|
||||||
|
"Basic realm=\"hermes\", charset=\"UTF-8\""
|
||||||
|
);
|
||||||
|
response.into_body().collect().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_task.abort();
|
||||||
|
connection_task.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_proxy() -> (SocketAddr, JoinHandle<()>) {
|
fn basic_authorization() -> String {
|
||||||
|
format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64_STANDARD.encode("alice:correct horse battery staple")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basic_acl() -> Acl {
|
||||||
|
Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Deny,
|
||||||
|
users: BTreeMap::from([(
|
||||||
|
"alice".to_owned(),
|
||||||
|
UserConfig {
|
||||||
|
password_hash: crate::acl::hash_password(b"correct horse battery staple")
|
||||||
|
.unwrap(),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
rules: vec![RuleConfig {
|
||||||
|
name: "alice".to_owned(),
|
||||||
|
action: Action::Allow,
|
||||||
|
users: vec!["alice".to_owned()],
|
||||||
|
networks: vec![],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localhost_acl() -> Acl {
|
||||||
|
Acl::from_config(AclConfig {
|
||||||
|
default_action: Action::Deny,
|
||||||
|
users: BTreeMap::new(),
|
||||||
|
rules: vec![RuleConfig {
|
||||||
|
name: "localhost".to_owned(),
|
||||||
|
action: Action::Allow,
|
||||||
|
users: vec![],
|
||||||
|
networks: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_proxy_with_acl(acl: Acl) -> (SocketAddr, JoinHandle<()>) {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
let state = test_state();
|
let state = ProxyState::new(&Config {
|
||||||
|
listen_addr: address,
|
||||||
|
connect_timeout: Duration::from_secs(2),
|
||||||
|
upstream_timeout: Duration::from_secs(2),
|
||||||
|
acl: Arc::new(acl),
|
||||||
|
});
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
while let Ok((stream, peer)) = listener.accept().await {
|
while let Ok((stream, peer)) = listener.accept().await {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user