mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Drop the token, host lists and NAT64 prefixes; nothing uses them yet
This commit is contained in:
@@ -35,11 +35,7 @@ arguments; `--help` lists them all.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. |
|
| `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. |
|
||||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
|
| `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
|
||||||
| `--token` | unset | Require `Authorization: Bearer <token>`. Unset means anonymous, which is what the hosted funnel wants alongside the rate limit. |
|
|
||||||
| `--allow-private-networks` | off | Let sends reach private, loopback and link-local addresses. **Off by default; see below.** |
|
| `--allow-private-networks` | off | Let sends reach private, loopback and link-local addresses. **Off by default; see below.** |
|
||||||
| `--allow-hosts` | empty | Only these hosts (`api.example.com`, `*.example.com`). Empty means any host not denied. |
|
|
||||||
| `--deny-hosts` | empty | Never these hosts. Checked before the allow list. |
|
|
||||||
| `--nat64-prefixes` | empty | Network-specific NAT64 prefixes (`/96`) whose embedded IPv4 should be judged; the well-known and local-use prefixes always are. |
|
|
||||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||||
@@ -57,8 +53,7 @@ sits on. So by default it refuses to connect to:
|
|||||||
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
|
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
|
||||||
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
|
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
|
||||||
and IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the
|
and IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the
|
||||||
well-known and local-use NAT64 prefixes, 6to4 — plus any NAT64 prefix you
|
well-known and local-use NAT64 prefixes, 6to4);
|
||||||
name with `--nat64-prefixes`);
|
|
||||||
- anything not `http://` or `https://`.
|
- anything not `http://` or `https://`.
|
||||||
|
|
||||||
The check runs **on the resolved addresses, after DNS**, for every hop of a
|
The check runs **on the resolved addresses, after DNS**, for every hop of a
|
||||||
@@ -69,7 +64,7 @@ fields), since no browser tab could legitimately mean those.
|
|||||||
|
|
||||||
Refusals are logged with the reason. A self-hosted instance on a private network
|
Refusals are logged with the reason. A self-hosted instance on a private network
|
||||||
that legitimately needs to reach the services next to it turns the range check
|
that legitimately needs to reach the services next to it turns the range check
|
||||||
off with `--allow-private-networks`, and can narrow that with `--allow-hosts`.
|
off with `--allow-private-networks`.
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
@@ -79,13 +74,16 @@ One binary, no dependencies. Build it and run it wherever you like:
|
|||||||
cargo build --release -p yaak-send-proxy
|
cargo build --release -p yaak-send-proxy
|
||||||
YAAK_PROXY_BIND=0.0.0.0:9227 \
|
YAAK_PROXY_BIND=0.0.0.0:9227 \
|
||||||
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
|
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
|
||||||
YAAK_PROXY_TOKEN=change-me \
|
|
||||||
./target/release/yaak-send-proxy
|
./target/release/yaak-send-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
Put TLS in front of it (a reverse proxy) — the token travels as a header. If the
|
There is no authentication: an instance is anonymous and protected by the
|
||||||
reverse proxy buffers responses, tell it not to: the reply is a stream and the
|
per-client rate limit and the destination policy, which is what the hosted
|
||||||
`X-Accel-Buffering: no` header it sets is honoured by nginx-shaped ones.
|
funnel wants. Anything more (a shared token, per-user quotas) is a later slice
|
||||||
|
and would sit in front of `send_http` in `main.rs`. Put TLS in front of it (a
|
||||||
|
reverse proxy). If the reverse proxy buffers responses, tell it not to: the reply
|
||||||
|
is a stream and the `X-Accel-Buffering: no` header it sets is honoured by
|
||||||
|
nginx-shaped ones.
|
||||||
|
|
||||||
## The wire
|
## The wire
|
||||||
|
|
||||||
@@ -116,8 +114,8 @@ happened:
|
|||||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||||
|
|
||||||
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
||||||
rate limit, missing token) are plain HTTP errors (`403`, `400`, `429`, `401`)
|
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
|
||||||
with `{"error": "…"}`, not streams.
|
`{"error": "…"}`, not streams.
|
||||||
|
|
||||||
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
|
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
|
||||||
construction, cancellable by closing the connection, readable with `curl`, and
|
construction, cancellable by closing the connection, readable with `curl`, and
|
||||||
@@ -135,8 +133,8 @@ wire on one side is a type error on the other.
|
|||||||
|
|
||||||
Not built, by design, but the router is shaped for it: a WebSocket relay
|
Not built, by design, but the router is shaped for it: a WebSocket relay
|
||||||
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
|
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
|
||||||
bidirectional endpoints on the same binary, behind the same destination policy,
|
bidirectional endpoints on the same binary, behind the same destination policy
|
||||||
limits and token. They differ from this endpoint in holding per-connection
|
and limits. They differ from this endpoint in holding per-connection
|
||||||
in-memory state while a connection is open (never persisted), which brings
|
in-memory state while a connection is open (never persisted), which brings
|
||||||
connection limits and a larger abuse surface — the reason they are separate
|
connection limits and a larger abuse surface — the reason they are separate
|
||||||
work.
|
work.
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ pub struct Config {
|
|||||||
)]
|
)]
|
||||||
pub allowed_origins: Vec<String>,
|
pub allowed_origins: Vec<String>,
|
||||||
|
|
||||||
/// Require `Authorization: Bearer <token>` on every send. Unset means anonymous access,
|
|
||||||
/// which is what the hosted funnel wants alongside the rate limit.
|
|
||||||
#[arg(long, env = "YAAK_PROXY_TOKEN")]
|
|
||||||
pub token: Option<String>,
|
|
||||||
|
|
||||||
/// Allow sends to private, loopback, link-local and other non-public addresses.
|
/// Allow sends to private, loopback, link-local and other non-public addresses.
|
||||||
///
|
///
|
||||||
/// Off by default: a hosted instance must not become a relay into its own network. A
|
/// Off by default: a hosted instance must not become a relay into its own network. A
|
||||||
@@ -39,23 +34,6 @@ pub struct Config {
|
|||||||
)]
|
)]
|
||||||
pub allow_private_networks: bool,
|
pub allow_private_networks: bool,
|
||||||
|
|
||||||
/// Only allow sends to these hosts (exact host, or `*.suffix`), comma-separated. Empty means
|
|
||||||
/// every host not on the deny list.
|
|
||||||
#[arg(long, env = "YAAK_PROXY_ALLOW_HOSTS", value_delimiter = ',')]
|
|
||||||
pub allow_hosts: Vec<String>,
|
|
||||||
|
|
||||||
/// Never send to these hosts (exact host, or `*.suffix`), comma-separated. Checked before the
|
|
||||||
/// allow list.
|
|
||||||
#[arg(long, env = "YAAK_PROXY_DENY_HOSTS", value_delimiter = ',')]
|
|
||||||
pub deny_hosts: Vec<String>,
|
|
||||||
|
|
||||||
/// NAT64 prefixes (/96) in use on this network, comma-separated, e.g. `2001:db8:64::`. The
|
|
||||||
/// well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48) prefixes are always recognised;
|
|
||||||
/// a network-specific prefix has to be named here or an IPv6 address under it could reach
|
|
||||||
/// an internal IPv4 host through the translator.
|
|
||||||
#[arg(long, env = "YAAK_PROXY_NAT64_PREFIXES", value_delimiter = ',')]
|
|
||||||
pub nat64_prefixes: Vec<std::net::Ipv6Addr>,
|
|
||||||
|
|
||||||
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included).
|
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included).
|
||||||
#[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
|
#[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
|
||||||
pub max_request_bytes: usize,
|
pub max_request_bytes: usize,
|
||||||
@@ -69,7 +47,8 @@ pub struct Config {
|
|||||||
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
|
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
|
||||||
pub max_timeout_secs: u64,
|
pub max_timeout_secs: u64,
|
||||||
|
|
||||||
/// Sends allowed per client IP per minute. 0 disables the limit.
|
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
|
||||||
|
/// cap are the whole of what protects an instance: there is no authentication.
|
||||||
#[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
|
#[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
|
||||||
pub rate_limit_per_minute: u32,
|
pub rate_limit_per_minute: u32,
|
||||||
|
|
||||||
|
|||||||
@@ -24,57 +24,15 @@ use yaak_http::types::SendableHttpRequest;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DestinationPolicy {
|
pub struct DestinationPolicy {
|
||||||
allow_private_networks: bool,
|
allow_private_networks: bool,
|
||||||
allow_hosts: Vec<HostPattern>,
|
|
||||||
deny_hosts: Vec<HostPattern>,
|
|
||||||
/// NAT64 prefixes (/96) in use on this network beyond the well-known ones. An IPv6
|
|
||||||
/// address under one of these is really an IPv4 destination, and is judged as that.
|
|
||||||
nat64_prefixes: Vec<Ipv6Addr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
enum HostPattern {
|
|
||||||
Exact(String),
|
|
||||||
/// `*.example.com`: any subdomain, and the bare domain too.
|
|
||||||
Suffix(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostPattern {
|
|
||||||
fn parse(raw: &str) -> Option<Self> {
|
|
||||||
let raw = raw.trim().trim_end_matches('.').to_ascii_lowercase();
|
|
||||||
if raw.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(match raw.strip_prefix("*.") {
|
|
||||||
Some(suffix) => Self::Suffix(suffix.to_string()),
|
|
||||||
None => Self::Exact(raw),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches(&self, host: &str) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Exact(h) => host == h,
|
|
||||||
Self::Suffix(s) => host == s || host.strip_suffix(s).is_some_and(|p| p.ends_with('.')),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DestinationPolicy {
|
impl DestinationPolicy {
|
||||||
pub fn new(
|
pub fn new(allow_private_networks: bool) -> Self {
|
||||||
allow_private_networks: bool,
|
Self { allow_private_networks }
|
||||||
allow_hosts: &[String],
|
|
||||||
deny_hosts: &[String],
|
|
||||||
nat64_prefixes: &[Ipv6Addr],
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
allow_private_networks,
|
|
||||||
allow_hosts: allow_hosts.iter().filter_map(|h| HostPattern::parse(h)).collect(),
|
|
||||||
deny_hosts: deny_hosts.iter().filter_map(|h| HostPattern::parse(h)).collect(),
|
|
||||||
nat64_prefixes: nat64_prefixes.to_vec(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check a URL before a hop is attempted: scheme, host lists, and literal IPs. A hostname
|
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
|
||||||
/// that passes here still has its resolved addresses checked by [`Self::address_filter`].
|
/// here still has its resolved addresses checked by [`Self::address_filter`].
|
||||||
pub fn check_url(&self, raw: &str) -> Result<(), String> {
|
pub fn check_url(&self, raw: &str) -> Result<(), String> {
|
||||||
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
|
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
@@ -82,15 +40,7 @@ impl DestinationPolicy {
|
|||||||
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
|
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
|
||||||
}
|
}
|
||||||
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
|
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
|
||||||
let host =
|
let host = host.trim_matches(|c| c == '[' || c == ']');
|
||||||
host.trim_matches(|c| c == '[' || c == ']').trim_end_matches('.').to_ascii_lowercase();
|
|
||||||
|
|
||||||
if self.deny_hosts.iter().any(|p| p.matches(&host)) {
|
|
||||||
return Err(format!("Host {host:?} is on this proxy's deny list"));
|
|
||||||
}
|
|
||||||
if !self.allow_hosts.is_empty() && !self.allow_hosts.iter().any(|p| p.matches(&host)) {
|
|
||||||
return Err(format!("Host {host:?} is not on this proxy's allow list"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
|
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
|
||||||
// where their addresses become known.
|
// where their addresses become known.
|
||||||
@@ -110,17 +60,6 @@ impl DestinationPolicy {
|
|||||||
if self.allow_private_networks {
|
if self.allow_private_networks {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// An address under a configured NAT64 prefix reaches an IPv4 host; judge that host.
|
|
||||||
if let IpAddr::V6(v6) = ip
|
|
||||||
&& self.nat64_prefixes.iter().any(|p| p.segments()[..6] == v6.segments()[..6])
|
|
||||||
{
|
|
||||||
let v4 = trailing_v4(&v6);
|
|
||||||
if let Some(reason) = non_public_v4(v4) {
|
|
||||||
return Err(format!(
|
|
||||||
"Refusing to connect to {ip} (NAT64 for {v4}): {reason}. This proxy only sends to public addresses"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match non_public_reason(ip) {
|
match non_public_reason(ip) {
|
||||||
Some(reason) => Err(format!(
|
Some(reason) => Err(format!(
|
||||||
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
|
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
|
||||||
@@ -136,8 +75,7 @@ impl DestinationPolicy {
|
|||||||
/// itself, the network it sits on, and the link-local range where cloud metadata services
|
/// itself, the network it sits on, and the link-local range where cloud metadata services
|
||||||
/// (169.254.169.254) live. IPv4 addresses carried inside IPv6 forms — IPv4-mapped, the
|
/// (169.254.169.254) live. IPv4 addresses carried inside IPv6 forms — IPv4-mapped, the
|
||||||
/// well-known and local-use NAT64 prefixes, 6to4 — are unwrapped and judged as IPv4, since
|
/// well-known and local-use NAT64 prefixes, 6to4 — are unwrapped and judged as IPv4, since
|
||||||
/// that is where the packets end up. A network-specific NAT64 prefix is not knowable here;
|
/// that is where the packets end up. (A network-specific NAT64 prefix is not knowable here.)
|
||||||
/// see `--nat64-prefix`.
|
|
||||||
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
|
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
|
||||||
match ip {
|
match ip {
|
||||||
IpAddr::V4(v4) => non_public_v4(v4),
|
IpAddr::V4(v4) => non_public_v4(v4),
|
||||||
@@ -309,51 +247,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_configured_nat64_prefix_is_judged_by_the_address_it_carries() {
|
|
||||||
// A made-up global prefix, standing in for whatever the network's translator uses
|
|
||||||
let prefix: Ipv6Addr = "2a02:1234:64::".parse().unwrap();
|
|
||||||
let policy = DestinationPolicy::new(false, &[], &[], &[prefix]);
|
|
||||||
assert!(policy.check_ip(ip("2a02:1234:64::a9fe:a9fe")).is_err(), "metadata behind NAT64");
|
|
||||||
assert!(policy.check_ip(ip("2a02:1234:64::0a00:1")).is_err(), "10.0.0.1 behind NAT64");
|
|
||||||
assert!(policy.check_ip(ip("2a02:1234:64::0101:0101")).is_ok(), "1.1.1.1 behind NAT64");
|
|
||||||
// Without the prefix configured the same address is an ordinary global v6, and passes:
|
|
||||||
// that is exactly the gap the option exists to close.
|
|
||||||
let policy = DestinationPolicy::new(false, &[], &[], &[]);
|
|
||||||
assert!(policy.check_ip(ip("2a02:1234:64::a9fe:a9fe")).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn private_networks_can_be_opted_in() {
|
fn private_networks_can_be_opted_in() {
|
||||||
let policy = DestinationPolicy::new(true, &[], &[], &[]);
|
let policy = DestinationPolicy::new(true);
|
||||||
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
||||||
assert!(policy.check_url("http://169.254.169.254/").is_ok());
|
assert!(policy.check_url("http://169.254.169.254/").is_ok());
|
||||||
let policy = DestinationPolicy::new(false, &[], &[], &[]);
|
let policy = DestinationPolicy::new(false);
|
||||||
assert!(policy.check_url("http://127.0.0.1/").is_err());
|
assert!(policy.check_url("http://127.0.0.1/").is_err());
|
||||||
assert!(policy.check_url("http://[::1]/").is_err());
|
assert!(policy.check_url("http://[::1]/").is_err());
|
||||||
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
|
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn host_lists() {
|
|
||||||
let policy = DestinationPolicy::new(
|
|
||||||
false,
|
|
||||||
&["*.example.com".into(), "api.test".into()],
|
|
||||||
&["bad.example.com".into()],
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
assert!(policy.check_url("https://example.com/").is_ok());
|
|
||||||
assert!(policy.check_url("https://a.b.example.com/").is_ok());
|
|
||||||
assert!(policy.check_url("https://api.test/").is_ok());
|
|
||||||
assert!(policy.check_url("https://API.TEST./").is_ok());
|
|
||||||
assert!(policy.check_url("https://bad.example.com/").is_err(), "deny wins over allow");
|
|
||||||
assert!(policy.check_url("https://notexample.com/").is_err());
|
|
||||||
assert!(policy.check_url("https://httpbin.org/").is_err(), "not on the allow list");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_http_schemes() {
|
fn only_http_schemes() {
|
||||||
let policy = DestinationPolicy::new(true, &[], &[], &[]);
|
let policy = DestinationPolicy::new(true);
|
||||||
assert!(policy.check_url("ftp://example.com/").is_err());
|
assert!(policy.check_url("ftp://example.com/").is_err());
|
||||||
assert!(policy.check_url("file:///etc/passwd").is_err());
|
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||||
assert!(policy.check_url("https://example.com/").is_ok());
|
assert!(policy.check_url("https://example.com/").is_ok());
|
||||||
|
|||||||
@@ -49,12 +49,7 @@ async fn main() {
|
|||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
let config = Config::parse();
|
let config = Config::parse();
|
||||||
|
|
||||||
let policy = DestinationPolicy::new(
|
let policy = DestinationPolicy::new(config.allow_private_networks);
|
||||||
config.allow_private_networks,
|
|
||||||
&config.allow_hosts,
|
|
||||||
&config.deny_hosts,
|
|
||||||
&config.nat64_prefixes,
|
|
||||||
);
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
limits: Arc::new(SendLimits {
|
limits: Arc::new(SendLimits {
|
||||||
policy,
|
policy,
|
||||||
@@ -68,11 +63,10 @@ async fn main() {
|
|||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
.allow_headers([header::CONTENT_TYPE])
|
||||||
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(root))
|
|
||||||
.route("/v1/health", get(health))
|
.route("/v1/health", get(health))
|
||||||
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
||||||
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
||||||
@@ -87,9 +81,8 @@ async fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
info!(
|
info!(
|
||||||
"yaak-send-proxy listening on http://{bind} (private networks: {}, token: {}, rate limit: {}/min)",
|
"yaak-send-proxy listening on http://{bind} (private networks: {}, rate limit: {}/min)",
|
||||||
if state.config.allow_private_networks { "allowed" } else { "refused" },
|
if state.config.allow_private_networks { "allowed" } else { "refused" },
|
||||||
if state.config.token.is_some() { "required" } else { "none" },
|
|
||||||
state.config.rate_limit_per_minute,
|
state.config.rate_limit_per_minute,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,16 +104,11 @@ fn allowed_origins(origins: &[String]) -> AllowOrigin {
|
|||||||
AllowOrigin::list(parsed)
|
AllowOrigin::list(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> impl IntoResponse {
|
|
||||||
"yaak-send-proxy: POST /v1/http/send (see https://github.com/mountain-loop/yaak)\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
"privateNetworks": state.config.allow_private_networks,
|
"privateNetworks": state.config.allow_private_networks,
|
||||||
"tokenRequired": state.config.token.is_some(),
|
|
||||||
"maxResponseBytes": state.config.max_response_bytes,
|
"maxResponseBytes": state.config.max_response_bytes,
|
||||||
"maxTimeoutSecs": state.config.max_timeout_secs,
|
"maxTimeoutSecs": state.config.max_timeout_secs,
|
||||||
}))
|
}))
|
||||||
@@ -144,34 +132,12 @@ fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
|||||||
peer.ip()
|
peer.ip()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authorized(config: &Config, headers: &HeaderMap) -> bool {
|
|
||||||
let Some(expected) = config.token.as_deref() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
|
||||||
.is_some_and(|got| constant_time_eq(got.as_bytes(), expected.as_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_http(
|
async fn send_http(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<SendRequest>,
|
Json(body): Json<SendRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if !authorized(&state.config, &headers) {
|
|
||||||
return error_response(StatusCode::UNAUTHORIZED, "This proxy requires a token");
|
|
||||||
}
|
|
||||||
|
|
||||||
let ip = client_ip(&state.config, &headers, peer);
|
let ip = client_ip(&state.config, &headers, peer);
|
||||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||||
warn!("Rate limited {ip}");
|
warn!("Rate limited {ip}");
|
||||||
|
|||||||
Reference in New Issue
Block a user