Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT

echo "downloading monitor-agent ($ARCH)"
curl -fsSL "$URL" -o "$TMP"
curl -fsSL --max-time 300 "$URL" -o "$TMP"

# Downloaded before the registration below, because that step spends a node: the
# key returns a token and the panel gains a row, while the env file recording it
Expand Down
4 changes: 2 additions & 2 deletions src/agent_ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use serde_json::json;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};

use crate::auth::client_ip;
use crate::auth::node_ip;
use crate::{App, Shared};

/// How often a quiet agent is probed, and how long the hub waits for any frame
Expand Down Expand Up @@ -150,7 +150,7 @@ pub async fn handler(
// The same response whether the token is malformed or merely unknown.
return (StatusCode::UNAUTHORIZED, "invalid token").into_response();
};
let ip = client_ip(&headers, peer.ip()).to_string();
let ip = node_ip(&headers, peer.ip()).to_string();

upgrade.read_buffer_size(crate::api::SOCKET_BUFFER).max_message_size(crate::api::MAX_FRAME).on_upgrade(
move |socket| async move {
Expand Down
28 changes: 28 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,23 @@ pub fn client_ip(headers: &HeaderMap, peer: IpAddr) -> IpAddr {
.map_or(peer, |ip| ip.to_canonical())
}

/// Where a node's connection came from: its exit on the panel, and the address
/// its country falls back to. Called only once the node's token has checked out.
///
/// Cloudflare in front of the hub, by proxy or tunnel, writes the node's address
/// into `CF-Connecting-IP`, while [`client_ip`] sees its edge or the local proxy.
/// The header is not read for throttling: anyone reaching the origin from
/// Cloudflare's network -- a Worker's raw socket, for one -- can write it. A
/// token holder, the only caller here, can report any address of its own
/// already.
pub fn node_ip(headers: &HeaderMap, peer: IpAddr) -> IpAddr {
headers
.get("cf-connecting-ip")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<IpAddr>().ok())
.map_or_else(|| client_ip(headers, peer), |ip| ip.to_canonical())
}

/// Loopback or a private network, where a reverse proxy resides.
fn behind_local_proxy(ip: IpAddr) -> bool {
match ip {
Expand Down Expand Up @@ -604,5 +621,16 @@ mod tests {
assert_eq!(client_ip(&forged, ip("2001:db8::5")), ip("2001:db8::5"));
// No header at all: the peer address is used.
assert_eq!(client_ip(&HeaderMap::new(), ip("10.0.0.1")), ip("10.0.0.1"));

// Cloudflare in front of the local proxy: the tail is its edge. The
// throttle stays on it, as anyone on Cloudflare's network can write
// CF-Connecting-IP; a node's address follows the header.
let mut edge = xff("10.0.0.2, 203.0.113.7, 162.158.88.126");
edge.insert("cf-connecting-ip", "::ffff:203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&edge, ip("127.0.0.1")), ip("162.158.88.126"));
assert_eq!(node_ip(&edge, ip("127.0.0.1")), ip("203.0.113.7"));
// Without the header a node's address is the same as the throttle's.
assert_eq!(node_ip(&forged, ip("127.0.0.1")), ip("198.51.100.9"));
assert_eq!(node_ip(&forged, ip("203.0.113.5")), ip("203.0.113.5"));
}
}
5 changes: 5 additions & 0 deletions web-admin/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@
/* Reserve the scrollbar's width on every page: without it, leaving a route
whose content exceeds the fold widens the page. */
html { scrollbar-gutter: stable; }
/* A dialog locking the page adds a right margin the width of the scrollbar,
which the gutter above already holds: counted twice, it would shift the page
half a scrollbar left on every open. The dialog's rule is injected at run
time with equal specificity, hence the extra `html`. */
html body[data-scroll-locked] { margin-right: 0 !important; }
body { @apply bg-background text-foreground; }
/* Tailwind v4 leaves `button` and `input` on the browser's default cursor,
where v3 made buttons a pointer. Radix renders Switch as a button, so without
Expand Down
Loading