Merge outbound policy transport implementation
# Conflicts: # Cargo.lock # Cargo.toml # Dockerfile.test # Makefile
This commit is contained in:
commit
630a3970f4
17 changed files with 1727 additions and 10 deletions
160
Cargo.lock
generated
160
Cargo.lock
generated
|
|
@ -46,6 +46,45 @@ dependencies = [
|
||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs-derive",
|
||||||
|
"asn1-rs-impl",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-derive"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-impl"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
|
|
@ -245,7 +284,7 @@ version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -254,6 +293,15 @@ version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
|
|
@ -588,6 +636,20 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der-parser"
|
||||||
|
version = "10.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -1504,6 +1566,12 @@ version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -1515,6 +1583,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num"
|
name = "num"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
|
|
@ -1616,6 +1694,15 @@ dependencies = [
|
||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oid-registry"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|
@ -1931,7 +2018,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2011,6 +2098,19 @@ dependencies = [
|
||||||
"rand_core 0.10.1",
|
"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 = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"time",
|
||||||
|
"x509-parser",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "0.32.7"
|
version = "0.32.7"
|
||||||
|
|
@ -2204,6 +2304,21 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "remora-outbound"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"http 1.4.2",
|
||||||
|
"ipnet",
|
||||||
|
"rcgen",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"rustls",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remora-result"
|
name = "remora-result"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
@ -2449,6 +2564,15 @@ dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.42"
|
version = "0.23.42"
|
||||||
|
|
@ -2504,7 +2628,7 @@ dependencies = [
|
||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3527,7 +3651,7 @@ version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3630,6 +3754,34 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-parser"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"data-encoding",
|
||||||
|
"der-parser",
|
||||||
|
"lazy_static",
|
||||||
|
"nom",
|
||||||
|
"oid-registry",
|
||||||
|
"ring",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec 0.9.1",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ members = [
|
||||||
"crates/remora-domain",
|
"crates/remora-domain",
|
||||||
"crates/remora-durable",
|
"crates/remora-durable",
|
||||||
"crates/remora-identity",
|
"crates/remora-identity",
|
||||||
|
"crates/remora-outbound",
|
||||||
"crates/remora-result",
|
"crates/remora-result",
|
||||||
"crates/remora-stream",
|
"crates/remora-stream",
|
||||||
"crates/remora-target",
|
"crates/remora-target",
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,26 @@ COPY crates crates
|
||||||
COPY --from=kind-result-verified /tmp/remora-result-tests.tar.zst /tests/remora-result-tests.tar.zst
|
COPY --from=kind-result-verified /tmp/remora-result-tests.tar.zst /tests/remora-result-tests.tar.zst
|
||||||
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_TARGET_ENDPOINT\" && case $REMORA_TARGET_PROVIDER in garage|versitygw) ;; *) exit 1 ;; esac && export REMORA_RESULT_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-result-tests.tar.zst --workspace-remap /workspace --test-threads=1"]
|
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_TARGET_ENDPOINT\" && case $REMORA_TARGET_PROVIDER in garage|versitygw) ;; *) exit 1 ;; esac && export REMORA_RESULT_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-result-tests.tar.zst --workspace-remap /workspace --test-threads=1"]
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-outbound-build
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
COPY .config .config
|
||||||
|
COPY contracts contracts
|
||||||
|
COPY crates crates
|
||||||
|
COPY docs/api docs/api
|
||||||
|
RUN cargo nextest archive \
|
||||||
|
--package remora-outbound \
|
||||||
|
--build-jobs 4 \
|
||||||
|
--locked \
|
||||||
|
--archive-file /tmp/remora-outbound-tests.tar.zst
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-outbound-runner
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml ./
|
||||||
|
COPY crates crates
|
||||||
|
COPY --from=kind-outbound-build /tmp/remora-outbound-tests.tar.zst /tests/remora-outbound-tests.tar.zst
|
||||||
|
CMD ["cargo-nextest", "nextest", "run", "--archive-file", "/tests/remora-outbound-tests.tar.zst", "--workspace-remap", "/workspace"]
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS node-runtime
|
FROM ${NODE_IMAGE} AS node-runtime
|
||||||
|
|
||||||
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-identity check-credentials check-stream check-target check-result check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-identity check-credentials check-stream check-target check-result check-outbound check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
||||||
|
|
||||||
CONTAINER_ENGINE ?= nerdctl
|
CONTAINER_ENGINE ?= nerdctl
|
||||||
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
||||||
|
|
@ -79,3 +79,6 @@ check-target:
|
||||||
|
|
||||||
check-result:
|
check-result:
|
||||||
./tests/kind/run-result-job.sh
|
./tests/kind/run-result-job.sh
|
||||||
|
|
||||||
|
check-outbound:
|
||||||
|
./tests/kind/run-outbound-job.sh
|
||||||
|
|
|
||||||
22
crates/remora-outbound/Cargo.toml
Normal file
22
crates/remora-outbound/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "remora-outbound"
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
http = "1.4.0"
|
||||||
|
ipnet = "2.12.0"
|
||||||
|
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||||
|
"rustls-tls-webpki-roots",
|
||||||
|
] }
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
url = "2.5.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rcgen = { version = "0.14.8", default-features = false, features = ["ring"] }
|
||||||
|
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
|
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
|
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
|
||||||
35
crates/remora-outbound/src/lib.rs
Normal file
35
crates/remora-outbound/src/lib.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
mod policy;
|
||||||
|
mod resolver;
|
||||||
|
mod transport;
|
||||||
|
mod url;
|
||||||
|
|
||||||
|
pub use policy::{AllowedEndpoint, OutboundPolicy, OutboundPolicyBuilder};
|
||||||
|
pub use resolver::{Resolver, SystemResolver};
|
||||||
|
pub use transport::{OutboundClient, OutboundClientBuilder, OutboundRequest, OutboundResponse};
|
||||||
|
pub use url::{CanonicalUrl, Origin};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
|
||||||
|
pub enum OutboundError {
|
||||||
|
#[error("outbound URL is invalid")]
|
||||||
|
InvalidUrl,
|
||||||
|
#[error("outbound scheme and port are denied")]
|
||||||
|
SchemePortDenied,
|
||||||
|
#[error("outbound name resolution failed")]
|
||||||
|
ResolutionFailed,
|
||||||
|
#[error("outbound name resolution returned no addresses")]
|
||||||
|
EmptyResolution,
|
||||||
|
#[error("outbound name resolution contains a denied address")]
|
||||||
|
AddressDenied,
|
||||||
|
#[error("outbound request headers are invalid")]
|
||||||
|
InvalidHeaders,
|
||||||
|
#[error("outbound redirect is invalid")]
|
||||||
|
InvalidRedirect,
|
||||||
|
#[error("outbound redirect limit exceeded")]
|
||||||
|
RedirectLimit,
|
||||||
|
#[error("outbound HTTPS downgrade is denied")]
|
||||||
|
HttpsDowngrade,
|
||||||
|
#[error("outbound transport failed")]
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
199
crates/remora-outbound/src/policy.rs
Normal file
199
crates/remora-outbound/src/policy.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||||
|
};
|
||||||
|
|
||||||
|
use ipnet::IpNet;
|
||||||
|
|
||||||
|
use crate::{CanonicalUrl, OutboundError, Resolver};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OutboundPolicy {
|
||||||
|
allowed_scheme_ports: HashSet<(String, u16)>,
|
||||||
|
allowed_non_global: Vec<IpNet>,
|
||||||
|
max_redirects: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OutboundPolicyBuilder {
|
||||||
|
policy: OutboundPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AllowedEndpoint {
|
||||||
|
address: IpAddr,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundPolicy {
|
||||||
|
#[must_use]
|
||||||
|
pub fn https_only() -> Self {
|
||||||
|
OutboundPolicyBuilder::new().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize<R: Resolver>(
|
||||||
|
&self,
|
||||||
|
url: &CanonicalUrl,
|
||||||
|
resolver: &R,
|
||||||
|
) -> Result<AllowedEndpoint, OutboundError> {
|
||||||
|
let port = url.port().ok_or(OutboundError::SchemePortDenied)?;
|
||||||
|
if !self
|
||||||
|
.allowed_scheme_ports
|
||||||
|
.iter()
|
||||||
|
.any(|(scheme, allowed_port)| scheme == url.scheme() && *allowed_port == port)
|
||||||
|
{
|
||||||
|
return Err(OutboundError::SchemePortDenied);
|
||||||
|
}
|
||||||
|
let addresses = if let Some(address) = url.literal_address() {
|
||||||
|
vec![address]
|
||||||
|
} else {
|
||||||
|
resolver
|
||||||
|
.resolve(url.host_str(), port)
|
||||||
|
.await
|
||||||
|
.map_err(|_| OutboundError::ResolutionFailed)?
|
||||||
|
};
|
||||||
|
let address = *addresses.first().ok_or(OutboundError::EmptyResolution)?;
|
||||||
|
if addresses
|
||||||
|
.iter()
|
||||||
|
.any(|address| !self.address_allowed(*address))
|
||||||
|
{
|
||||||
|
return Err(OutboundError::AddressDenied);
|
||||||
|
}
|
||||||
|
Ok(AllowedEndpoint { address, port })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_redirects(&self) -> usize {
|
||||||
|
self.max_redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
fn address_allowed(&self, address: IpAddr) -> bool {
|
||||||
|
let effective = match address {
|
||||||
|
IpAddr::V6(address) => address.to_ipv4_mapped().map_or(address.into(), IpAddr::V4),
|
||||||
|
address => address,
|
||||||
|
};
|
||||||
|
if is_metadata_capable_space(effective) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.allowed_non_global
|
||||||
|
.iter()
|
||||||
|
.any(|network| network.contains(&effective))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
is_global_unicast(effective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OutboundPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::https_only()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundPolicyBuilder {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
policy: OutboundPolicy {
|
||||||
|
allowed_scheme_ports: HashSet::from([("https".to_owned(), 443)]),
|
||||||
|
allowed_non_global: Vec::new(),
|
||||||
|
max_redirects: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn allow_scheme_port(mut self, scheme: impl Into<String>, port: u16) -> Self {
|
||||||
|
self.policy
|
||||||
|
.allowed_scheme_ports
|
||||||
|
.insert((scheme.into().to_ascii_lowercase(), port));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn allow_non_global(mut self, network: IpNet) -> Self {
|
||||||
|
self.policy.allowed_non_global.push(network);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_redirects(mut self, max_redirects: usize) -> Self {
|
||||||
|
self.policy.max_redirects = max_redirects;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(self) -> OutboundPolicy {
|
||||||
|
self.policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OutboundPolicyBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AllowedEndpoint {
|
||||||
|
#[must_use]
|
||||||
|
pub fn address(&self) -> IpAddr {
|
||||||
|
self.address
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_global_unicast(address: IpAddr) -> bool {
|
||||||
|
match address {
|
||||||
|
IpAddr::V4(address) => is_global_ipv4(address),
|
||||||
|
IpAddr::V6(address) => is_global_ipv6(address),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_metadata_capable_space(address: IpAddr) -> bool {
|
||||||
|
match address {
|
||||||
|
IpAddr::V4(address) => {
|
||||||
|
let [first, second, third, _] = address.octets();
|
||||||
|
(first == 100 && (64..=127).contains(&second))
|
||||||
|
|| (first == 169 && second == 254)
|
||||||
|
|| (first == 192 && second == 0 && third == 0)
|
||||||
|
}
|
||||||
|
IpAddr::V6(address) => {
|
||||||
|
let segments = address.segments();
|
||||||
|
(segments[0] == 0xfd00 && segments[1] == 0x0ec2)
|
||||||
|
|| (segments[0] == 0xfd20 && segments[1] == 0x00ce)
|
||||||
|
|| (segments[..4] == [0xfd00, 0x0db8, 0xdeca, 0xdeed])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_global_ipv4(address: Ipv4Addr) -> bool {
|
||||||
|
if address.is_unspecified()
|
||||||
|
|| address.is_loopback()
|
||||||
|
|| address.is_private()
|
||||||
|
|| address.is_link_local()
|
||||||
|
|| address.is_documentation()
|
||||||
|
|| address.is_broadcast()
|
||||||
|
|| address.is_multicast()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let [first, second, third, _] = address.octets();
|
||||||
|
!((first == 100 && (64..=127).contains(&second))
|
||||||
|
|| (first == 192 && second == 0 && third == 0)
|
||||||
|
|| (first == 192 && second == 88 && third == 99)
|
||||||
|
|| (first == 198 && (second == 18 || second == 19))
|
||||||
|
|| first >= 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_global_ipv6(address: Ipv6Addr) -> bool {
|
||||||
|
let segments = address.segments();
|
||||||
|
segments[0] & 0xe000 == 0x2000
|
||||||
|
&& !((segments[0] == 0x2001 && (segments[1] <= 0x01ff || segments[1] == 0x0db8))
|
||||||
|
|| segments[0] & 0xfff0 == 0x3ff0)
|
||||||
|
}
|
||||||
25
crates/remora-outbound/src/resolver.rs
Normal file
25
crates/remora-outbound/src/resolver.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
use std::{future::Future, io, net::IpAddr};
|
||||||
|
|
||||||
|
pub trait Resolver: Send + Sync {
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
) -> impl Future<Output = io::Result<Vec<IpAddr>>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct SystemResolver;
|
||||||
|
|
||||||
|
impl Resolver for SystemResolver {
|
||||||
|
async fn resolve(&self, host: &str, port: u16) -> io::Result<Vec<IpAddr>> {
|
||||||
|
let addresses = tokio::net::lookup_host((host, port)).await?;
|
||||||
|
let mut unique = Vec::new();
|
||||||
|
for address in addresses {
|
||||||
|
if !unique.contains(&address.ip()) {
|
||||||
|
unique.push(address.ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(unique)
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/remora-outbound/src/transport.rs
Normal file
241
crates/remora-outbound/src/transport.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
use std::{fmt, net::SocketAddr};
|
||||||
|
|
||||||
|
use http::{HeaderMap, HeaderName, Method, StatusCode};
|
||||||
|
use reqwest::{Certificate, redirect::Policy};
|
||||||
|
|
||||||
|
use crate::{CanonicalUrl, OutboundError, OutboundPolicy, Resolver};
|
||||||
|
|
||||||
|
const REDIRECTS: [StatusCode; 5] = [
|
||||||
|
StatusCode::MOVED_PERMANENTLY,
|
||||||
|
StatusCode::FOUND,
|
||||||
|
StatusCode::SEE_OTHER,
|
||||||
|
StatusCode::TEMPORARY_REDIRECT,
|
||||||
|
StatusCode::PERMANENT_REDIRECT,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OutboundClient<R> {
|
||||||
|
resolver: R,
|
||||||
|
policy: OutboundPolicy,
|
||||||
|
roots: Vec<Certificate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct OutboundClientBuilder<R> {
|
||||||
|
client: OutboundClient<R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OutboundRequest {
|
||||||
|
method: Method,
|
||||||
|
url: CanonicalUrl,
|
||||||
|
headers: HeaderMap,
|
||||||
|
credential_headers: HeaderMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OutboundResponse {
|
||||||
|
final_url: CanonicalUrl,
|
||||||
|
response: reqwest::Response,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OutboundResponse {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("OutboundResponse")
|
||||||
|
.field("status", &self.response.status())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Resolver> OutboundClient<R> {
|
||||||
|
#[must_use]
|
||||||
|
pub fn builder(resolver: R, policy: OutboundPolicy) -> OutboundClientBuilder<R> {
|
||||||
|
OutboundClientBuilder {
|
||||||
|
client: Self {
|
||||||
|
resolver,
|
||||||
|
policy,
|
||||||
|
roots: Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
mut request: OutboundRequest,
|
||||||
|
) -> Result<OutboundResponse, OutboundError> {
|
||||||
|
let mut redirects = 0;
|
||||||
|
loop {
|
||||||
|
let endpoint = self.policy.authorize(&request.url, &self.resolver).await?;
|
||||||
|
let client = self.client_for(&request.url, &endpoint)?;
|
||||||
|
let mut headers = request.headers.clone();
|
||||||
|
headers.extend(request.credential_headers.clone());
|
||||||
|
let response = client
|
||||||
|
.request(request.method.clone(), request.url.as_url().clone())
|
||||||
|
.headers(headers)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| OutboundError::Transport)?;
|
||||||
|
if !REDIRECTS.contains(&response.status()) {
|
||||||
|
return Ok(OutboundResponse {
|
||||||
|
final_url: request.url,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if redirects == self.policy.max_redirects() {
|
||||||
|
return Err(OutboundError::RedirectLimit);
|
||||||
|
}
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::LOCATION)
|
||||||
|
.ok_or(OutboundError::InvalidRedirect)?
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| OutboundError::InvalidRedirect)?;
|
||||||
|
let next = request.url.join(location)?;
|
||||||
|
if request.url.scheme() == "https" && next.scheme() == "http" {
|
||||||
|
return Err(OutboundError::HttpsDowngrade);
|
||||||
|
}
|
||||||
|
if request.url.origin()? != next.origin()? {
|
||||||
|
request.credential_headers.clear();
|
||||||
|
}
|
||||||
|
request.url = next;
|
||||||
|
redirects += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_for(
|
||||||
|
&self,
|
||||||
|
url: &CanonicalUrl,
|
||||||
|
endpoint: &crate::AllowedEndpoint,
|
||||||
|
) -> Result<reqwest::Client, OutboundError> {
|
||||||
|
let mut builder = reqwest::Client::builder()
|
||||||
|
.redirect(Policy::none())
|
||||||
|
.no_proxy();
|
||||||
|
if url.literal_address().is_none() {
|
||||||
|
builder = builder.resolve(
|
||||||
|
url.host_str(),
|
||||||
|
SocketAddr::new(endpoint.address(), endpoint.port()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for root in &self.roots {
|
||||||
|
builder = builder.add_root_certificate(root.clone());
|
||||||
|
}
|
||||||
|
builder.build().map_err(|_| OutboundError::Transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> OutboundClientBuilder<R> {
|
||||||
|
#[must_use]
|
||||||
|
pub fn add_root_certificate(mut self, certificate: Certificate) -> Self {
|
||||||
|
self.client.roots.push(certificate);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(self) -> OutboundClient<R> {
|
||||||
|
self.client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundRequest {
|
||||||
|
pub fn get(url: &str) -> Result<Self, OutboundError> {
|
||||||
|
Ok(Self {
|
||||||
|
method: Method::GET,
|
||||||
|
url: CanonicalUrl::parse(url)?,
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
credential_headers: HeaderMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn head(url: &str) -> Result<Self, OutboundError> {
|
||||||
|
Ok(Self {
|
||||||
|
method: Method::HEAD,
|
||||||
|
url: CanonicalUrl::parse(url)?,
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
credential_headers: HeaderMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_public_headers(mut self, headers: HeaderMap) -> Result<Self, OutboundError> {
|
||||||
|
if headers
|
||||||
|
.keys()
|
||||||
|
.any(|name| !is_public_header(name) || self.credential_headers.contains_key(name))
|
||||||
|
{
|
||||||
|
return Err(OutboundError::InvalidHeaders);
|
||||||
|
}
|
||||||
|
self.headers = headers;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_credential_headers(mut self, headers: HeaderMap) -> Result<Self, OutboundError> {
|
||||||
|
if headers
|
||||||
|
.keys()
|
||||||
|
.any(|name| is_connection_controlled_header(name) || self.headers.contains_key(name))
|
||||||
|
{
|
||||||
|
return Err(OutboundError::InvalidHeaders);
|
||||||
|
}
|
||||||
|
self.credential_headers = headers;
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OutboundRequest {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("OutboundRequest")
|
||||||
|
.field("method", &self.method)
|
||||||
|
.field("header_count", &self.headers.len())
|
||||||
|
.field("credential_header_count", &self.credential_headers.len())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundResponse {
|
||||||
|
#[must_use]
|
||||||
|
pub fn final_url(&self) -> &CanonicalUrl {
|
||||||
|
&self.final_url
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn response(&self) -> &reqwest::Response {
|
||||||
|
&self.response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_response(self) -> reqwest::Response {
|
||||||
|
self.response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_public_header(name: &HeaderName) -> bool {
|
||||||
|
matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"accept"
|
||||||
|
| "accept-charset"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "accept-language"
|
||||||
|
| "cache-control"
|
||||||
|
| "if-match"
|
||||||
|
| "if-modified-since"
|
||||||
|
| "if-none-match"
|
||||||
|
| "if-range"
|
||||||
|
| "if-unmodified-since"
|
||||||
|
| "pragma"
|
||||||
|
| "range"
|
||||||
|
| "user-agent"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_connection_controlled_header(name: &HeaderName) -> bool {
|
||||||
|
matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"connection"
|
||||||
|
| "content-length"
|
||||||
|
| "host"
|
||||||
|
| "keep-alive"
|
||||||
|
| "proxy-authenticate"
|
||||||
|
| "proxy-authorization"
|
||||||
|
| "te"
|
||||||
|
| "trailer"
|
||||||
|
| "transfer-encoding"
|
||||||
|
| "upgrade"
|
||||||
|
)
|
||||||
|
}
|
||||||
156
crates/remora-outbound/src/url.rs
Normal file
156
crates/remora-outbound/src/url.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
use std::{fmt, net::IpAddr};
|
||||||
|
|
||||||
|
use url::{Host, Url};
|
||||||
|
|
||||||
|
use crate::OutboundError;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct CanonicalUrl(Url);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct Origin {
|
||||||
|
scheme: String,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalUrl {
|
||||||
|
pub fn parse(input: &str) -> Result<Self, OutboundError> {
|
||||||
|
let mut parsed = Url::parse(input).map_err(|_| OutboundError::InvalidUrl)?;
|
||||||
|
if parsed.cannot_be_a_base()
|
||||||
|
|| !matches!(parsed.scheme(), "http" | "https")
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(OutboundError::InvalidUrl);
|
||||||
|
}
|
||||||
|
let without_trailing_dot = {
|
||||||
|
let host = parsed.host().ok_or(OutboundError::InvalidUrl)?;
|
||||||
|
validate_numeric_spelling(input, &parsed, &host)?;
|
||||||
|
validate_dns_name(&host)?;
|
||||||
|
match host {
|
||||||
|
Host::Domain(domain) => domain.strip_suffix('.').map(str::to_owned),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(without_dot) = without_trailing_dot {
|
||||||
|
parsed
|
||||||
|
.set_host(Some(&without_dot))
|
||||||
|
.map_err(|_| OutboundError::InvalidUrl)?;
|
||||||
|
}
|
||||||
|
Ok(Self(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(&self, location: &str) -> Result<Self, OutboundError> {
|
||||||
|
let joined = self
|
||||||
|
.0
|
||||||
|
.join(location)
|
||||||
|
.map_err(|_| OutboundError::InvalidRedirect)?;
|
||||||
|
Self::parse(joined.as_str()).map_err(|_| OutboundError::InvalidRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_url(&self) -> &Url {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn scheme(&self) -> &str {
|
||||||
|
self.0.scheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn host_str(&self) -> &str {
|
||||||
|
self.0.host_str().expect("canonical URL always has a host")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn literal_address(&self) -> Option<IpAddr> {
|
||||||
|
match self.0.host()? {
|
||||||
|
Host::Ipv4(address) => Some(address.into()),
|
||||||
|
Host::Ipv6(address) => Some(address.into()),
|
||||||
|
Host::Domain(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn port(&self) -> Option<u16> {
|
||||||
|
self.0.port_or_known_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin(&self) -> Result<Origin, OutboundError> {
|
||||||
|
Ok(Origin {
|
||||||
|
scheme: self.scheme().to_owned(),
|
||||||
|
host: self.host_str().to_owned(),
|
||||||
|
port: self.port().ok_or(OutboundError::InvalidUrl)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CanonicalUrl {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_numeric_spelling(
|
||||||
|
input: &str,
|
||||||
|
parsed: &Url,
|
||||||
|
host: &Host<&str>,
|
||||||
|
) -> Result<(), OutboundError> {
|
||||||
|
let authority = input
|
||||||
|
.split_once("://")
|
||||||
|
.map(|(_, tail)| tail.split(['/', '?', '#']).next().unwrap_or_default())
|
||||||
|
.ok_or(OutboundError::InvalidUrl)?;
|
||||||
|
if authority.contains('@') {
|
||||||
|
return Err(OutboundError::InvalidUrl);
|
||||||
|
}
|
||||||
|
let raw_host = if let Some(bracketed) = authority.strip_prefix('[') {
|
||||||
|
bracketed
|
||||||
|
.split_once(']')
|
||||||
|
.map(|(host, _)| host)
|
||||||
|
.ok_or(OutboundError::InvalidUrl)?
|
||||||
|
} else {
|
||||||
|
authority
|
||||||
|
.split_once(':')
|
||||||
|
.map_or(authority, |(host, _)| host)
|
||||||
|
};
|
||||||
|
match host {
|
||||||
|
Host::Ipv4(address) if raw_host != address.to_string() => Err(OutboundError::InvalidUrl),
|
||||||
|
Host::Ipv6(address) if raw_host != address.to_string() => Err(OutboundError::InvalidUrl),
|
||||||
|
Host::Domain(_) if resembles_numeric_host(raw_host) => Err(OutboundError::InvalidUrl),
|
||||||
|
_ if parsed.host_str().is_none() => Err(OutboundError::InvalidUrl),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resembles_numeric_host(host: &str) -> bool {
|
||||||
|
host.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || byte == b'.')
|
||||||
|
|| host
|
||||||
|
.split('.')
|
||||||
|
.any(|label| label.starts_with("0x") || label.starts_with("0X"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_dns_name(host: &Host<&str>) -> Result<(), OutboundError> {
|
||||||
|
let Host::Domain(domain) = host else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let domain = domain.strip_suffix('.').unwrap_or(domain);
|
||||||
|
if domain.is_empty()
|
||||||
|
|| domain.len() > 253
|
||||||
|
|| domain.split('.').any(|label| {
|
||||||
|
label.is_empty()
|
||||||
|
|| label.len() > 63
|
||||||
|
|| label.starts_with('-')
|
||||||
|
|| label.ends_with('-')
|
||||||
|
|| !label
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(OutboundError::InvalidUrl);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
232
crates/remora-outbound/tests/policy.rs
Normal file
232
crates/remora-outbound/tests/policy.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
io,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use remora_outbound::{
|
||||||
|
CanonicalUrl, OutboundError, OutboundPolicy, OutboundPolicyBuilder, Resolver, SystemResolver,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct ScriptedResolver {
|
||||||
|
state: Arc<Mutex<ResolverState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ResolverState {
|
||||||
|
answers: HashMap<String, Vec<Vec<IpAddr>>>,
|
||||||
|
calls: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedResolver {
|
||||||
|
fn with(host: &str, answers: Vec<Vec<IpAddr>>) -> Self {
|
||||||
|
let resolver = Self::default();
|
||||||
|
resolver
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.expect("resolver state")
|
||||||
|
.answers
|
||||||
|
.insert(host.to_owned(), answers);
|
||||||
|
resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self, host: &str) -> usize {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.expect("resolver state")
|
||||||
|
.calls
|
||||||
|
.get(host)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolver for ScriptedResolver {
|
||||||
|
async fn resolve(&self, host: &str, _port: u16) -> io::Result<Vec<IpAddr>> {
|
||||||
|
let mut state = self.state.lock().expect("resolver state");
|
||||||
|
let call = state.calls.entry(host.to_owned()).or_default();
|
||||||
|
let index = *call;
|
||||||
|
*call += 1;
|
||||||
|
let sequence = state.answers.get(host).cloned().unwrap_or_default();
|
||||||
|
Ok(sequence
|
||||||
|
.get(index)
|
||||||
|
.or_else(|| sequence.last())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip(input: &str) -> IpAddr {
|
||||||
|
input.parse().expect("valid test address")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorize_literal(policy: &OutboundPolicy, address: &str) -> Result<(), OutboundError> {
|
||||||
|
let brackets = if address.contains(':') {
|
||||||
|
("[", "]")
|
||||||
|
} else {
|
||||||
|
("", "")
|
||||||
|
};
|
||||||
|
let url = CanonicalUrl::parse(&format!("https://{}{address}{}/", brackets.0, brackets.1))?;
|
||||||
|
policy
|
||||||
|
.authorize(&url, &ScriptedResolver::default())
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_url_rejects_ambiguous_or_sensitive_forms() {
|
||||||
|
let canonical = CanonicalUrl::parse("https://EXAMPLE.com.:443/a?q=1").expect("canonical URL");
|
||||||
|
assert_eq!(canonical.to_string(), "https://example.com/a?q=1");
|
||||||
|
|
||||||
|
for invalid in [
|
||||||
|
"ftp://example.com/object",
|
||||||
|
"https://user:[email protected]/object",
|
||||||
|
"https://example.com/object#fragment",
|
||||||
|
"https://%31%32%37.0.0.1/object",
|
||||||
|
"https://2130706433/object",
|
||||||
|
"https://0x7f000001/object",
|
||||||
|
"https://0177.0.0.1/object",
|
||||||
|
"https://127.1/object",
|
||||||
|
"https://[2001:0DB8::1]/object",
|
||||||
|
] {
|
||||||
|
assert_eq!(CanonicalUrl::parse(invalid), Err(OutboundError::InvalidUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn complete_answer_must_be_nonempty_and_fully_allowed() {
|
||||||
|
let policy = OutboundPolicy::https_only();
|
||||||
|
let mixed = ScriptedResolver::with(
|
||||||
|
"mixed.test",
|
||||||
|
vec![vec![ip("93.184.216.34"), ip("10.0.0.1")]],
|
||||||
|
);
|
||||||
|
let url = CanonicalUrl::parse("https://mixed.test/object").expect("URL");
|
||||||
|
assert_eq!(
|
||||||
|
policy.authorize(&url, &mixed).await,
|
||||||
|
Err(OutboundError::AddressDenied)
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty = ScriptedResolver::with("empty.test", vec![vec![]]);
|
||||||
|
let url = CanonicalUrl::parse("https://empty.test/object").expect("URL");
|
||||||
|
assert_eq!(
|
||||||
|
policy.authorize(&url, &empty).await,
|
||||||
|
Err(OutboundError::EmptyResolution)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_policy_denies_special_ipv4_and_ipv6_classes() {
|
||||||
|
let policy = OutboundPolicy::https_only();
|
||||||
|
for address in [
|
||||||
|
"0.0.0.0",
|
||||||
|
"10.0.0.1",
|
||||||
|
"100.64.0.1",
|
||||||
|
"127.0.0.1",
|
||||||
|
"169.254.1.1",
|
||||||
|
"172.16.0.1",
|
||||||
|
"192.168.0.1",
|
||||||
|
"192.0.2.1",
|
||||||
|
"198.18.0.1",
|
||||||
|
"224.0.0.1",
|
||||||
|
"240.0.0.1",
|
||||||
|
"::",
|
||||||
|
"::1",
|
||||||
|
"fc00::1",
|
||||||
|
"fe80::1",
|
||||||
|
"ff00::1",
|
||||||
|
"2001:db8::1",
|
||||||
|
"::ffff:127.0.0.1",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
authorize_literal(&policy, address).await,
|
||||||
|
Err(OutboundError::AddressDenied),
|
||||||
|
"{address}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for address in ["93.184.216.34", "2606:4700:4700::1111"] {
|
||||||
|
authorize_literal(&policy, address)
|
||||||
|
.await
|
||||||
|
.expect("global address");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allowlists_never_override_metadata_denial() {
|
||||||
|
let policy = OutboundPolicyBuilder::new()
|
||||||
|
.allow_non_global("10.0.0.0/8".parse::<IpNet>().expect("network"))
|
||||||
|
.allow_non_global("169.254.0.0/16".parse::<IpNet>().expect("network"))
|
||||||
|
.allow_non_global("fc00::/7".parse::<IpNet>().expect("network"))
|
||||||
|
.build();
|
||||||
|
authorize_literal(&policy, "10.1.2.3")
|
||||||
|
.await
|
||||||
|
.expect("allowlisted private address");
|
||||||
|
authorize_literal(&policy, "fd12:3456:789a::1")
|
||||||
|
.await
|
||||||
|
.expect("allowlisted ULA address");
|
||||||
|
for address in [
|
||||||
|
"169.254.169.254",
|
||||||
|
"169.254.170.2",
|
||||||
|
"169.254.0.23",
|
||||||
|
"::ffff:169.254.170.2",
|
||||||
|
"100.100.100.200",
|
||||||
|
"::ffff:100.100.100.200",
|
||||||
|
"192.0.0.192",
|
||||||
|
"fd00:ec2::254",
|
||||||
|
"fd20:ce::254",
|
||||||
|
"fd00:db8:deca:deed::fe",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
authorize_literal(&policy, address).await,
|
||||||
|
Err(OutboundError::AddressDenied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn system_resolver_returns_complete_deduplicated_localhost_answers() {
|
||||||
|
let answers = SystemResolver
|
||||||
|
.resolve("localhost", 80)
|
||||||
|
.await
|
||||||
|
.expect("localhost resolution");
|
||||||
|
assert!(!answers.is_empty());
|
||||||
|
assert!(answers.iter().all(IpAddr::is_loopback));
|
||||||
|
for (index, address) in answers.iter().enumerate() {
|
||||||
|
assert!(!answers[..index].contains(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scheme_port_policy_is_exact() {
|
||||||
|
let resolver = ScriptedResolver::with("example.test", vec![vec![ip("93.184.216.34")]]);
|
||||||
|
let policy = OutboundPolicy::https_only();
|
||||||
|
for denied in ["http://example.test/", "https://example.test:444/"] {
|
||||||
|
assert_eq!(
|
||||||
|
policy
|
||||||
|
.authorize(&CanonicalUrl::parse(denied).expect("URL"), &resolver)
|
||||||
|
.await,
|
||||||
|
Err(OutboundError::SchemePortDenied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn each_authorization_performs_one_fresh_resolution() {
|
||||||
|
let resolver = ScriptedResolver::with(
|
||||||
|
"rebind.test",
|
||||||
|
vec![vec![ip("93.184.216.34")], vec![ip("10.0.0.1")]],
|
||||||
|
);
|
||||||
|
let policy = OutboundPolicy::https_only();
|
||||||
|
let url = CanonicalUrl::parse("https://rebind.test/").expect("URL");
|
||||||
|
policy
|
||||||
|
.authorize(&url, &resolver)
|
||||||
|
.await
|
||||||
|
.expect("first authorization");
|
||||||
|
assert_eq!(
|
||||||
|
policy.authorize(&url, &resolver).await,
|
||||||
|
Err(OutboundError::AddressDenied)
|
||||||
|
);
|
||||||
|
assert_eq!(resolver.calls("rebind.test"), 2);
|
||||||
|
}
|
||||||
545
crates/remora-outbound/tests/transport.rs
Normal file
545
crates/remora-outbound/tests/transport.rs
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
io,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use http::{HeaderMap, HeaderValue};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use remora_outbound::{
|
||||||
|
OutboundClient, OutboundError, OutboundPolicyBuilder, OutboundRequest, Resolver,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::TcpListener,
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ScriptedResolver {
|
||||||
|
state: Arc<Mutex<ResolverState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResolverState {
|
||||||
|
answers: HashMap<String, Vec<Vec<IpAddr>>>,
|
||||||
|
calls: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ObservedRequest {
|
||||||
|
path: String,
|
||||||
|
host: Option<String>,
|
||||||
|
authorization: Option<String>,
|
||||||
|
cookie: Option<String>,
|
||||||
|
api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedResolver {
|
||||||
|
fn new(answers: impl IntoIterator<Item = (&'static str, Vec<Vec<IpAddr>>)>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Arc::new(Mutex::new(ResolverState {
|
||||||
|
answers: answers
|
||||||
|
.into_iter()
|
||||||
|
.map(|(host, answers)| (host.to_owned(), answers))
|
||||||
|
.collect(),
|
||||||
|
calls: HashMap::new(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self, host: &str) -> usize {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.expect("resolver state")
|
||||||
|
.calls
|
||||||
|
.get(host)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolver for ScriptedResolver {
|
||||||
|
async fn resolve(&self, host: &str, _port: u16) -> io::Result<Vec<IpAddr>> {
|
||||||
|
let mut state = self.state.lock().expect("resolver state");
|
||||||
|
let call = state.calls.entry(host.to_owned()).or_default();
|
||||||
|
let index = *call;
|
||||||
|
*call += 1;
|
||||||
|
let sequence = state.answers.get(host).cloned().unwrap_or_default();
|
||||||
|
Ok(sequence
|
||||||
|
.get(index)
|
||||||
|
.or_else(|| sequence.last())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip(input: &str) -> IpAddr {
|
||||||
|
input.parse().expect("valid test address")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_policy(port: u16, max_redirects: usize) -> remora_outbound::OutboundPolicy {
|
||||||
|
OutboundPolicyBuilder::new()
|
||||||
|
.allow_scheme_port("http", port)
|
||||||
|
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
|
||||||
|
.max_redirects(max_redirects)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_http(
|
||||||
|
connections: usize,
|
||||||
|
handler: impl Fn(&ObservedRequest) -> String + Send + Sync + 'static,
|
||||||
|
) -> (u16, Arc<Mutex<Vec<ObservedRequest>>>, JoinHandle<()>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let port = listener.local_addr().expect("address").port();
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let task_observed = Arc::clone(&observed);
|
||||||
|
let handler = Arc::new(handler);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
for _ in 0..connections {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
let request = read_request(&mut stream).await;
|
||||||
|
let response = handler(&request);
|
||||||
|
task_observed.lock().expect("observations").push(request);
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(port, observed, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> ObservedRequest {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
let read = stream.read(&mut chunk).await.expect("request");
|
||||||
|
assert_ne!(read, 0, "request ended before headers");
|
||||||
|
bytes.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
let request = String::from_utf8(bytes).expect("ASCII request");
|
||||||
|
let mut lines = request.split("\r\n");
|
||||||
|
let path = lines
|
||||||
|
.next()
|
||||||
|
.and_then(|line| line.split_whitespace().nth(1))
|
||||||
|
.expect("request path")
|
||||||
|
.to_owned();
|
||||||
|
let headers: HashMap<String, String> = lines
|
||||||
|
.take_while(|line| !line.is_empty())
|
||||||
|
.filter_map(|line| line.split_once(':'))
|
||||||
|
.map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned()))
|
||||||
|
.collect();
|
||||||
|
ObservedRequest {
|
||||||
|
path,
|
||||||
|
host: headers.get("host").cloned(),
|
||||||
|
authorization: headers.get("authorization").cloned(),
|
||||||
|
cookie: headers.get("cookie").cloned(),
|
||||||
|
api_key: headers.get("x-api-key").cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(status: &str, headers: &[(&str, &str)], body: &str) -> String {
|
||||||
|
let fields = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| format!("{name}: {value}\r\n"))
|
||||||
|
.collect::<String>();
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 {status}\r\n{fields}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pins_address_and_preserves_http_authority() {
|
||||||
|
let (port, observed, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
|
||||||
|
let result = client
|
||||||
|
.execute(
|
||||||
|
OutboundRequest::get(&format!("http://source.test:{port}/object")).expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(result.response().status(), 200);
|
||||||
|
server.await.expect("server");
|
||||||
|
let observed = observed.lock().expect("observations");
|
||||||
|
assert_eq!(
|
||||||
|
observed[0].host.as_deref(),
|
||||||
|
Some(format!("source.test:{port}").as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(observed[0].path, "/object");
|
||||||
|
assert_eq!(resolver.calls("source.test"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redirects_reauthorize_and_strip_cross_origin_credentials() {
|
||||||
|
let (port, observed, server) = serve_http(2, |request| {
|
||||||
|
if request.path == "/redirect" {
|
||||||
|
let port = request
|
||||||
|
.host
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|host| host.rsplit_once(':'))
|
||||||
|
.map(|(_, port)| port)
|
||||||
|
.expect("authority port");
|
||||||
|
response(
|
||||||
|
"302 Found",
|
||||||
|
&[("Location", &format!("http://other.test:{port}/ok"))],
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
response("200 OK", &[], "ok")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let resolver = ScriptedResolver::new([
|
||||||
|
("source.test", vec![vec![ip("127.0.0.1")]]),
|
||||||
|
("other.test", vec![vec![ip("127.0.0.1")]]),
|
||||||
|
]);
|
||||||
|
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"authorization",
|
||||||
|
HeaderValue::from_static("Bearer synthetic"),
|
||||||
|
);
|
||||||
|
headers.insert("cookie", HeaderValue::from_static("session=synthetic"));
|
||||||
|
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||||
|
let request = OutboundRequest::get(&format!("http://source.test:{port}/redirect"))
|
||||||
|
.expect("request")
|
||||||
|
.with_credential_headers(headers)
|
||||||
|
.expect("headers");
|
||||||
|
let result = client.execute(request).await.expect("response");
|
||||||
|
assert_eq!(result.final_url().host_str(), "other.test");
|
||||||
|
server.await.expect("server");
|
||||||
|
let observed = observed.lock().expect("observations");
|
||||||
|
assert_eq!(
|
||||||
|
observed[0].authorization.as_deref(),
|
||||||
|
Some("Bearer synthetic")
|
||||||
|
);
|
||||||
|
assert_eq!(observed[0].cookie.as_deref(), Some("session=synthetic"));
|
||||||
|
assert_eq!(observed[0].api_key.as_deref(), Some("synthetic-api-key"));
|
||||||
|
assert_eq!(observed[1].authorization, None);
|
||||||
|
assert_eq!(observed[1].cookie, None);
|
||||||
|
assert_eq!(observed[1].api_key, None);
|
||||||
|
assert_eq!(resolver.calls("source.test"), 1);
|
||||||
|
assert_eq!(resolver.calls("other.test"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_headers_must_be_classified_as_credentials() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||||
|
assert_eq!(
|
||||||
|
OutboundRequest::get("https://example.com/")
|
||||||
|
.expect("request")
|
||||||
|
.with_public_headers(headers)
|
||||||
|
.expect_err("unclassified header"),
|
||||||
|
OutboundError::InvalidHeaders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_debug_redacts_url_and_credential_values() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-api-key", HeaderValue::from_static("synthetic-api-key"));
|
||||||
|
let request = OutboundRequest::get("https://example.com/sensitive?token=synthetic")
|
||||||
|
.expect("request")
|
||||||
|
.with_credential_headers(headers)
|
||||||
|
.expect("credential headers");
|
||||||
|
let debug = format!("{request:?}");
|
||||||
|
assert!(!debug.contains("sensitive"));
|
||||||
|
assert!(!debug.contains("synthetic-api-key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn response_debug_redacts_url_and_response_headers() {
|
||||||
|
let (port, _, server) = serve_http(1, |_| {
|
||||||
|
response(
|
||||||
|
"200 OK",
|
||||||
|
&[("Set-Cookie", "session=synthetic-secret")],
|
||||||
|
"ok",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let client = OutboundClient::builder(resolver, local_policy(port, 5)).build();
|
||||||
|
let result = client
|
||||||
|
.execute(
|
||||||
|
OutboundRequest::get(&format!(
|
||||||
|
"http://source.test:{port}/sensitive?token=synthetic"
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
let debug = format!("{result:?}");
|
||||||
|
assert!(debug.contains("200"));
|
||||||
|
assert!(!debug.contains("sensitive"));
|
||||||
|
assert!(!debug.contains("synthetic-secret"));
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn denied_redirect_is_rejected_before_dial() {
|
||||||
|
let (port, _, server) = serve_http(1, |request| {
|
||||||
|
let port = request
|
||||||
|
.host
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|host| host.rsplit_once(':'))
|
||||||
|
.map(|(_, port)| port)
|
||||||
|
.expect("authority port");
|
||||||
|
response(
|
||||||
|
"302 Found",
|
||||||
|
&[(
|
||||||
|
"Location",
|
||||||
|
&format!("http://metadata.test:{port}/latest/meta-data"),
|
||||||
|
)],
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let resolver = ScriptedResolver::new([
|
||||||
|
("source.test", vec![vec![ip("127.0.0.1")]]),
|
||||||
|
("metadata.test", vec![vec![ip("169.254.169.254")]]),
|
||||||
|
]);
|
||||||
|
let client = OutboundClient::builder(resolver, local_policy(port, 5)).build();
|
||||||
|
let result = client
|
||||||
|
.execute(OutboundRequest::get(&format!("http://source.test:{port}/")).expect("request"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
result.expect_err("denied redirect"),
|
||||||
|
OutboundError::AddressDenied
|
||||||
|
);
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redirect_count_is_bounded() {
|
||||||
|
let (port, _, server) =
|
||||||
|
serve_http(3, |_| response("302 Found", &[("Location", "/loop")], "")).await;
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 2)).build();
|
||||||
|
let result = client
|
||||||
|
.execute(OutboundRequest::get(&format!("http://source.test:{port}/loop")).expect("request"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
result.expect_err("redirect bound"),
|
||||||
|
OutboundError::RedirectLimit
|
||||||
|
);
|
||||||
|
server.await.expect("server");
|
||||||
|
assert_eq!(resolver.calls("source.test"), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_new_request_cannot_reuse_a_pool_after_dns_changes() {
|
||||||
|
let (port, _, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
|
||||||
|
let resolver = ScriptedResolver::new([(
|
||||||
|
"rebind.test",
|
||||||
|
vec![vec![ip("127.0.0.1")], vec![ip("169.254.169.254")]],
|
||||||
|
)]);
|
||||||
|
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
|
||||||
|
let url = format!("http://rebind.test:{port}/ok");
|
||||||
|
client
|
||||||
|
.execute(OutboundRequest::get(&url).expect("first request"))
|
||||||
|
.await
|
||||||
|
.expect("first response");
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.execute(OutboundRequest::get(&url).expect("second request"))
|
||||||
|
.await
|
||||||
|
.expect_err("rebound address"),
|
||||||
|
OutboundError::AddressDenied
|
||||||
|
);
|
||||||
|
server.await.expect("server");
|
||||||
|
assert_eq!(resolver.calls("rebind.test"), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn ignores_ambient_proxy_configuration() {
|
||||||
|
let (port, _, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
|
||||||
|
let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||||
|
.args(["--exact", "ambient_proxy_child", "--nocapture"])
|
||||||
|
.env("REMORA_PROXY_CHILD_PORT", port.to_string())
|
||||||
|
.env("HTTP_PROXY", "http://169.254.169.254:8080")
|
||||||
|
.status()
|
||||||
|
.expect("proxy child");
|
||||||
|
assert!(status.success());
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_proxy_child() {
|
||||||
|
let Ok(port) = std::env::var("REMORA_PROXY_CHILD_PORT") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let port = port.parse::<u16>().expect("child port");
|
||||||
|
tokio::runtime::Runtime::new()
|
||||||
|
.expect("runtime")
|
||||||
|
.block_on(async move {
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let client = OutboundClient::builder(resolver, local_policy(port, 5)).build();
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
OutboundRequest::get(&format!("http://source.test:{port}/")).expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("direct response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_verification_uses_canonical_hostname_not_pinned_address() {
|
||||||
|
let certified =
|
||||||
|
rcgen::generate_simple_self_signed(vec!["source.test".to_owned()]).expect("certificate");
|
||||||
|
let certificate_der = certified.cert.der().clone();
|
||||||
|
let key_der = certified.signing_key.serialize_der();
|
||||||
|
let tls = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(
|
||||||
|
vec![certificate_der.clone()],
|
||||||
|
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
|
||||||
|
)
|
||||||
|
.expect("TLS config");
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let port = listener.local_addr().expect("address").port();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
|
||||||
|
.accept(stream)
|
||||||
|
.await
|
||||||
|
.expect("TLS handshake");
|
||||||
|
let request = read_request(&mut stream).await;
|
||||||
|
assert_eq!(
|
||||||
|
request.host.as_deref(),
|
||||||
|
Some(format!("source.test:{port}").as_str())
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response("200 OK", &[], "ok").as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
});
|
||||||
|
let policy = OutboundPolicyBuilder::new()
|
||||||
|
.allow_scheme_port("https", port)
|
||||||
|
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
|
||||||
|
.build();
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
|
||||||
|
let client = OutboundClient::builder(resolver, policy)
|
||||||
|
.add_root_certificate(root)
|
||||||
|
.build();
|
||||||
|
client
|
||||||
|
.execute(OutboundRequest::get(&format!("https://source.test:{port}/")).expect("request"))
|
||||||
|
.await
|
||||||
|
.expect("TLS response");
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipv6_pinning_preserves_http_authority_and_tls_server_name() {
|
||||||
|
let certified =
|
||||||
|
rcgen::generate_simple_self_signed(vec!["source6.test".to_owned()]).expect("certificate");
|
||||||
|
let certificate_der = certified.cert.der().clone();
|
||||||
|
let key_der = certified.signing_key.serialize_der();
|
||||||
|
let tls = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(
|
||||||
|
vec![certificate_der.clone()],
|
||||||
|
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
|
||||||
|
)
|
||||||
|
.expect("TLS config");
|
||||||
|
let listener = match TcpListener::bind("[::1]:0").await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error)
|
||||||
|
if error.kind() == io::ErrorKind::AddrNotAvailable
|
||||||
|
&& std::env::var_os("REMORA_OUTBOUND_REQUIRE_IPV6").is_none() =>
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => panic!("IPv6 bind: {error}"),
|
||||||
|
};
|
||||||
|
let port = listener.local_addr().expect("address").port();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
|
||||||
|
.accept(stream)
|
||||||
|
.await
|
||||||
|
.expect("TLS handshake");
|
||||||
|
let request = read_request(&mut stream).await;
|
||||||
|
assert_eq!(
|
||||||
|
request.host.as_deref(),
|
||||||
|
Some(format!("source6.test:{port}").as_str())
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response("200 OK", &[], "ok").as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
});
|
||||||
|
let policy = OutboundPolicyBuilder::new()
|
||||||
|
.allow_scheme_port("https", port)
|
||||||
|
.allow_non_global("::1/128".parse::<IpNet>().expect("network"))
|
||||||
|
.build();
|
||||||
|
let resolver = ScriptedResolver::new([("source6.test", vec![vec![ip("::1")]])]);
|
||||||
|
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
|
||||||
|
let client = OutboundClient::builder(resolver, policy)
|
||||||
|
.add_root_certificate(root)
|
||||||
|
.build();
|
||||||
|
client
|
||||||
|
.execute(OutboundRequest::get(&format!("https://source6.test:{port}/")).expect("request"))
|
||||||
|
.await
|
||||||
|
.expect("IPv6 TLS response");
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn https_redirect_cannot_downgrade_to_http() {
|
||||||
|
let certified =
|
||||||
|
rcgen::generate_simple_self_signed(vec!["source.test".to_owned()]).expect("certificate");
|
||||||
|
let certificate_der = certified.cert.der().clone();
|
||||||
|
let key_der = certified.signing_key.serialize_der();
|
||||||
|
let tls = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(
|
||||||
|
vec![certificate_der.clone()],
|
||||||
|
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
|
||||||
|
)
|
||||||
|
.expect("TLS config");
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let port = listener.local_addr().expect("address").port();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
|
||||||
|
.accept(stream)
|
||||||
|
.await
|
||||||
|
.expect("TLS handshake");
|
||||||
|
read_request(&mut stream).await;
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
response(
|
||||||
|
"302 Found",
|
||||||
|
&[("Location", "http://source.test/object")],
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
});
|
||||||
|
let policy = OutboundPolicyBuilder::new()
|
||||||
|
.allow_scheme_port("https", port)
|
||||||
|
.allow_scheme_port("http", 80)
|
||||||
|
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
|
||||||
|
.build();
|
||||||
|
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
|
||||||
|
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
|
||||||
|
let client = OutboundClient::builder(resolver, policy)
|
||||||
|
.add_root_certificate(root)
|
||||||
|
.build();
|
||||||
|
let result = client
|
||||||
|
.execute(OutboundRequest::get(&format!("https://source.test:{port}/")).expect("request"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
result.expect_err("HTTPS downgrade"),
|
||||||
|
OutboundError::HttpsDowngrade
|
||||||
|
);
|
||||||
|
server.await.expect("server");
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ Backend integration and fault tests execute as Kubernetes `Job` resources inside
|
||||||
|
|
||||||
`make check-dependencies` remains the dependency compatibility gate: it creates the full fixture, runs the released-image probes, and tears the fixture down. `make check-kind-sample` builds and loads a Remora-owned cargo-nextest runner, then exercises the full provider matrix around [`fixture-consumer-sample`](../../tests/kind/sample-job.yaml.in). Its init containers access the lane database, coordination namespace, OIDC discovery and signing keys, target bucket, and Vault path before cargo-nextest runs. That Job is the reference structure for a work-package test Job.
|
`make check-dependencies` remains the dependency compatibility gate: it creates the full fixture, runs the released-image probes, and tears the fixture down. `make check-kind-sample` builds and loads a Remora-owned cargo-nextest runner, then exercises the full provider matrix around [`fixture-consumer-sample`](../../tests/kind/sample-job.yaml.in). Its init containers access the lane database, coordination namespace, OIDC discovery and signing keys, target bucket, and Vault path before cargo-nextest runs. That Job is the reference structure for a work-package test Job.
|
||||||
|
|
||||||
For interactive work, `tests/kind/setup.sh` creates a ready fixture and leaves it running. `tests/kind/destroy.sh` removes it. Setup replaces any fixture left by an earlier run, teardown is idempotent, and the harness serializes lifecycle mutations around the single `remora-testbed` cluster. `REMORA_TESTBED_RETAIN=1` retains a failed all-in-one run for inspection.
|
For interactive work, `tests/kind/setup.sh` creates a ready fixture and leaves it running. `tests/kind/destroy.sh` removes it. Setup replaces any fixture left by an earlier run, teardown is idempotent, and a host-runtime lock shared across worktrees serializes lifecycle mutations around the single `remora-testbed` cluster and `remora-wp02` containerd namespace. `REMORA_TESTBED_RETAIN=1` retains a failed all-in-one run for inspection.
|
||||||
|
|
||||||
The fixture owns an isolated kubeconfig at `tests/kind/.runtime/kubeconfig` and the `kind-remora-testbed` context. Every Kubernetes operation uses both explicitly. Package runners use the `kube` helper from `tests/kind/lib.sh`; they do not read or change the user's kubeconfig or current context.
|
The fixture owns an isolated kubeconfig at `tests/kind/.runtime/kubeconfig` and the `kind-remora-testbed` context. Every Kubernetes operation uses both explicitly. Package runners use the `kube` helper from `tests/kind/lib.sh`; they do not read or change the user's kubeconfig or current context.
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ The fixture owns an isolated kubeconfig at `tests/kind/.runtime/kubeconfig` and
|
||||||
|
|
||||||
Each work package owns its test code, test-runner image, Job manifest, acceptance command, and required compatibility families. Its all-in-one runner follows [`run-sample-job.sh`](../../tests/kind/run-sample-job.sh): acquire the fixture lock, install a cleanup trap, call `setup_fixture <families...>`, load any package-owned image with `load_local_image <image> [containerd-namespace]`, and call `run_job_matrix <job-name> <manifest> <image> <families...>`. The image namespace defaults to `default`. A retained failure prints the isolated kubeconfig and context.
|
Each work package owns its test code, test-runner image, Job manifest, acceptance command, and required compatibility families. Its all-in-one runner follows [`run-sample-job.sh`](../../tests/kind/run-sample-job.sh): acquire the fixture lock, install a cleanup trap, call `setup_fixture <families...>`, load any package-owned image with `load_local_image <image> [containerd-namespace]`, and call `run_job_matrix <job-name> <manifest> <image> <families...>`. The image namespace defaults to `default`. A retained failure prints the isolated kubeconfig and context.
|
||||||
|
|
||||||
The selectable families are `durable`, `coordination`, `oidc`, `target`, `secret`, and `all`. Individual implementations are not selectors. A suite that requests `coordination`, `oidc`, and `target` runs the unchanged cargo-nextest archive against the full Redis/DragonflyDB × Keycloak/ZITADEL × Garage/VersityGW Cartesian product. Lanes run sequentially and stop on the first failure.
|
The selectable families are `base`, `durable`, `coordination`, `oidc`, `target`, `secret`, and `all`. `base` provisions only the cluster and common namespace for suites whose controlled fixtures run inside their test pod. Individual implementations are not selectors. A suite that requests `coordination`, `oidc`, and `target` runs the unchanged cargo-nextest archive against the full Redis/DragonflyDB × Keycloak/ZITADEL × Garage/VersityGW Cartesian product. Lanes run sequentially and stop on the first failure.
|
||||||
|
|
||||||
Before each lane, the runner recreates a lane database, allocates unique coordination keys, provisions an OIDC tenant/client/user/role grant, creates a target bucket and prefix, and allocates a Vault path as required by the selected families. The resulting environment is exposed both through environment variables and a ConfigMap-mounted `/run/remora-fixture/profile.env`. Package manifests use `@@JOB_NAME@@`, `@@PROFILE_NAME@@`, and `@@TEST_IMAGE@@` placeholders so only the profile and lane identity vary between Jobs.
|
Before each lane, the runner recreates a lane database, allocates unique coordination keys, provisions an OIDC tenant/client/user/role grant, creates a target bucket and prefix, and allocates a Vault path as required by the selected families. The resulting environment is exposed both through environment variables and a ConfigMap-mounted `/run/remora-fixture/profile.env`. Package manifests use `@@JOB_NAME@@`, `@@PROFILE_NAME@@`, and `@@TEST_IMAGE@@` placeholders so only the profile and lane identity vary between Jobs.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,14 @@ if [[ "${cleanup_calls[*]}" != "durable coordination oidc target secret" ]]; the
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
FIXTURE_LOCK_DIR="$RUNTIME/lock-test"
|
||||||
|
FIXTURE_LOCK_PATH="$FIXTURE_LOCK_DIR/remora-testbed-kind.lock"
|
||||||
|
acquire_fixture_lock
|
||||||
|
if XDG_RUNTIME_DIR="$FIXTURE_LOCK_DIR" bash -c \
|
||||||
|
'source "$1"; acquire_fixture_lock' _ "$ROOT/tests/kind/lib.sh" 9>&-; then
|
||||||
|
printf 'singleton fixture lock admitted a second worktree process\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec 9>&-
|
||||||
|
|
||||||
printf 'fixture family matrix tests passed\n'
|
printf 'fixture family matrix tests passed\n'
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ TESTBED="$ROOT/tests/kind"
|
||||||
RUNTIME="$TESTBED/.runtime"
|
RUNTIME="$TESTBED/.runtime"
|
||||||
BIN="$RUNTIME/bin"
|
BIN="$RUNTIME/bin"
|
||||||
KUBECONFIG_PATH="$RUNTIME/kubeconfig"
|
KUBECONFIG_PATH="$RUNTIME/kubeconfig"
|
||||||
|
FIXTURE_LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp/remora-$(id -u)}"
|
||||||
|
FIXTURE_LOCK_PATH="$FIXTURE_LOCK_DIR/remora-testbed-kind.lock"
|
||||||
CONTEXT=kind-remora-testbed
|
CONTEXT=kind-remora-testbed
|
||||||
CLUSTER=remora-testbed
|
CLUSTER=remora-testbed
|
||||||
NERDCTL_NAMESPACE=remora-wp02
|
NERDCTL_NAMESPACE=remora-wp02
|
||||||
|
|
@ -69,8 +71,8 @@ prepare_provider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
acquire_fixture_lock() {
|
acquire_fixture_lock() {
|
||||||
mkdir -p "$RUNTIME"
|
mkdir -p "$RUNTIME" "$FIXTURE_LOCK_DIR"
|
||||||
exec 9>"$RUNTIME/fixture.lock"
|
exec 9>"$FIXTURE_LOCK_PATH"
|
||||||
if ! flock --nonblock 9; then
|
if ! flock --nonblock 9; then
|
||||||
printf 'kind test fixture is already in use\n' >&2
|
printf 'kind test fixture is already in use\n' >&2
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -248,7 +250,7 @@ select_fixture_families() {
|
||||||
all)
|
all)
|
||||||
use_all=true
|
use_all=true
|
||||||
;;
|
;;
|
||||||
durable | coordination | oidc | target | secret) ;;
|
base | durable | coordination | oidc | target | secret) ;;
|
||||||
*)
|
*)
|
||||||
printf 'unknown fixture family: %s\n' "$family" >&2
|
printf 'unknown fixture family: %s\n' "$family" >&2
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
24
tests/kind/outbound-job.yaml.in
Normal file
24
tests/kind/outbound-job.yaml.in
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: @@JOB_NAME@@
|
||||||
|
namespace: remora-testbed
|
||||||
|
spec:
|
||||||
|
activeDeadlineSeconds: 300
|
||||||
|
backoffLimit: 0
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: tests
|
||||||
|
image: @@TEST_IMAGE@@
|
||||||
|
imagePullPolicy: Never
|
||||||
|
env:
|
||||||
|
- name: REMORA_OUTBOUND_REQUIRE_IPV6
|
||||||
|
value: "1"
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
limits: {cpu: "1", memory: 512Mi}
|
||||||
50
tests/kind/run-outbound-job.sh
Executable file
50
tests/kind/run-outbound-job.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
source "$ROOT/tests/kind/lib.sh"
|
||||||
|
|
||||||
|
USER_KUBECONFIG_BEFORE="$(user_kubeconfig_digest)"
|
||||||
|
SUCCEEDED=false
|
||||||
|
OWNS_FIXTURE=false
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
if [[ "${REMORA_TESTBED_RETAIN:-0}" = 1 ]] && [[ "$SUCCEEDED" != true ]]; then
|
||||||
|
printf 'retained kubeconfig: %s\n' "$KUBECONFIG_PATH"
|
||||||
|
printf 'retained context: %s\n' "$CONTEXT"
|
||||||
|
exit "$status"
|
||||||
|
fi
|
||||||
|
if [[ "$OWNS_FIXTURE" = true ]]; then
|
||||||
|
destroy_fixture || status=1
|
||||||
|
fi
|
||||||
|
if [[ "$(user_kubeconfig_digest)" != "$USER_KUBECONFIG_BEFORE" ]]; then
|
||||||
|
printf 'user kubeconfig changed unexpectedly\n' >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
acquire_fixture_lock
|
||||||
|
REMORA_REAL_NERDCTL="$(command -v nerdctl)"
|
||||||
|
export REMORA_REAL_NERDCTL
|
||||||
|
"$REMORA_REAL_NERDCTL" --namespace default build \
|
||||||
|
--file "$ROOT/Dockerfile.test" \
|
||||||
|
--target kind-outbound-runner \
|
||||||
|
--tag remora-kind-outbound:local \
|
||||||
|
"$ROOT"
|
||||||
|
OWNS_FIXTURE=true
|
||||||
|
setup_fixture base
|
||||||
|
load_local_image remora-kind-outbound:local
|
||||||
|
run_job_matrix \
|
||||||
|
outbound-policy \
|
||||||
|
"$TESTBED/outbound-job.yaml.in" \
|
||||||
|
remora-kind-outbound:local \
|
||||||
|
base
|
||||||
|
SUCCEEDED=true
|
||||||
|
printf 'outbound policy transport package test Job passed\n'
|
||||||
Loading…
Reference in a new issue