Compare commits

..

No commits in common. "a1c146aff3e7ebc7e936bae92ac352f0ca1a61cc" and "ebfc751471da5972194169ac19a7c18da1e071e5" have entirely different histories.

15 changed files with 33 additions and 431 deletions

View File

@ -1,6 +1,6 @@
{
"target_version": "0.4.3",
"branch": "beta",
"target_version": "0.4.2",
"branch": "master",
"alpha_iteration": 0,
"beta_iteration": 1
"beta_iteration": 5
}

12
Cargo.lock generated
View File

@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1409,7 +1409,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"bytes",
@ -1474,7 +1474,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"axum",
@ -1507,7 +1507,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"libc",
@ -1519,7 +1519,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "AGPL-3.0"
version = "0.4.3"
version = "0.4.2"
[workspace.dependencies]
anyhow = "1.0"

View File

@ -20,15 +20,9 @@
// Адрес следующего узла в цепочке UDP
"upstream_udp": "TARGET_SERVER_IP:50000",
// URL API конечного (целевого) сервера для синхронизации access_keys.
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
//
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
// сервера). Management API смонтирован ВНУТРИ этого пути именно он скрывает
// панель от сканеров, поэтому голый host:port попадает в несуществующий
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
// Это тот же адрес, по которому вы открываете веб-панель.
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
// URL API конечного (целевого) сервера для синхронизации access_keys
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
// Bearer-токен для доступа к API целевого сервера
// Должен совпадать с api.token в конфиге target-сервера

View File

@ -137,16 +137,6 @@ pub struct Bridge {
last_rtt_ms: f64,
last_sample_at: Instant,
last_valid_recv: Instant,
/// Set when a suspend/resume is detected, cleared once a reconnect actually
/// succeeds. Waking is precisely when the network is least likely to be
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
/// milliseconds after resume usually fails, and a one-shot forced reconnect
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
/// off a monotonic clock which does not advance while the machine is
/// asleep, so it could take a further 25s of real uptime to fire, or not
/// fire at all. Retrying until success removes the dependency on either.
forced_reconnect_pending: bool,
last_forced_reconnect_try: Instant,
}
impl Bridge {
@ -183,8 +173,6 @@ impl Bridge {
last_rtt_ms: 0.0,
last_sample_at: Instant::now(),
last_valid_recv: Instant::now(),
forced_reconnect_pending: false,
last_forced_reconnect_try: Instant::now(),
})
}
@ -267,27 +255,7 @@ impl Bridge {
let _ = tx.send(UiEvent::Log(format!(
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
))).await;
self.forced_reconnect_pending = true;
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
}
// Keep retrying a resume-triggered reconnect until one lands.
// The first attempt fires within half a second of waking, when
// the NIC is typically still reassociating, so treating it as
// one-shot left the tunnel dead until some other timer noticed.
if self.running
&& self.forced_reconnect_pending
&& self.last_forced_reconnect_try.elapsed() >= Duration::from_secs(3)
{
self.last_forced_reconnect_try = Instant::now();
self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
// handle_keepalive refreshes last_valid_recv only when a
// session was actually established, so this is a real
// success check rather than "we tried".
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
self.forced_reconnect_pending = false;
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
}
}
if self.running {
self.emit_metrics(&tx).await;
@ -304,20 +272,7 @@ impl Bridge {
}
}
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion
// controller. The old 16384 ceiling let ~20 MB sit in flight,
// which on a mobile uplink is minutes of buffered queue rather
// than throughput — the app kept handing over data long after
// the path had stopped draining it.
// Two independent gates. cwnd bounds how much may be in
// flight; pacing bounds how FAST it is released. Without the
// second, a full window goes out back-to-back and lands in
// the bottleneck's buffer as standing queue rather than
// throughput — the thing that produced multi-second RTT.
s.iter().any(|ses| {
ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 1024)
&& ses.machine.can_pace_packet()
})
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
}).unwrap_or(true) => {
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
}
@ -971,21 +926,7 @@ impl Bridge {
Ok(addrs) => addrs.collect(),
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
};
// IPv4 first. Addresses are tried strictly in order, each burning its
// full retry budget before the next is touched, so this ordering decides
// how long a bad family stalls the whole connect. Mobile carriers
// routinely hand out IPv6 with no working route and BLACKHOLE it rather
// than rejecting, so every IPv6 candidate costs the full timeout budget
// — with several AAAA records the working IPv4 address was not reached
// for tens of seconds. (The same ordering bug was already fixed on the
// server's outbound path and in the UoT connect.)
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 1 } else { 0 });
// NAT64 is a fallback for IPv6-only networks. Retrying it per failing
// address multiplied an already-long connect: each attempt re-runs a DNS
// lookup and another full round of handshake retries, for a path that
// either works for the whole network or for none of it.
let mut nat64_attempted = false;
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
@ -998,8 +939,7 @@ impl Bridge {
let socket = match self.try_connect_transport(target_ip, port).await {
Ok(sock) => sock,
Err(e) => {
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
nat64_attempted = true;
if let std::net::IpAddr::V4(ipv4) = target_ip {
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
@ -1080,8 +1020,7 @@ impl Bridge {
let (final_socket, size) = if success {
(socket, size)
} else {
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
nat64_attempted = true;
if let std::net::IpAddr::V4(ipv4) = target_ip {
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
@ -1325,19 +1264,8 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
}
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
// Well-known prefix (RFC 6052), used if discovery doesn't answer in time.
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
// Bound the discovery lookup. This runs on exactly the networks that are
// already misbehaving, where the resolver can hang for tens of seconds
// before giving up — unbounded, it was a large part of why connecting over
// a broken mobile network took minutes. Falling back to the well-known
// prefix is strictly better than waiting.
let discovery = tokio::time::timeout(
Duration::from_secs(2),
tokio::net::lookup_host("ipv4only.arpa:80"),
)
.await;
if let Ok(Ok(addrs)) = discovery {
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
for addr in addrs {
if let std::net::SocketAddr::V6(v6) = addr {
let octets = v6.ip().octets();

View File

@ -39,9 +39,6 @@ pub struct CongestionController {
loss_count: u32,
/// Pacing rate: bytes per second
pacing_rate: u64,
/// Token-bucket allowance for pacing, in bytes.
pacing_tokens: f64,
pacing_last_refill: Instant,
/// MTU estimate (used for cwnd → packet count conversion)
mtu: u64,
/// Min RTT expiry: re-probe after 10 seconds
@ -68,20 +65,6 @@ const MIN_CWND_PACKETS: u64 = 2;
/// Min RTT expiry window (after which we re-probe)
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
/// product of any link this protocol realistically runs over, so anything
/// beyond it is standing queue, not throughput. The client previously allowed
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
const MAX_CWND_PACKETS: u64 = 1024;
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
/// buffer fastest, so growth must end when the queue starts building rather
/// than waiting for a loss that a deep buffer may never produce.
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
const RTT_INFLATION_BACKOFF: f64 = 4.0;
/// How much pacing allowance may accumulate, expressed as time-at-rate.
const PACING_BURST: Duration = Duration::from_millis(10);
const RTO_MIN: Duration = Duration::from_millis(50);
/// Maximum RTO
const RTO_MAX: Duration = Duration::from_secs(16);
@ -130,50 +113,9 @@ impl CongestionController {
min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
pacing_last_refill: now,
}
}
/// Bytes of pacing allowance available right now, without consuming any.
///
/// Read-only so the send path can use it as an admission check before it
/// commits to building a datagram.
pub fn pacing_available(&self) -> f64 {
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
}
/// Whether at least one full-size packet may be released right now.
pub fn can_pace_packet(&self) -> bool {
self.pacing_available() >= self.mtu as f64
}
/// Ceiling on accumulated allowance.
///
/// Pacing intervals here are fractions of a millisecond, so releasing
/// strictly one packet at a time would need a sub-millisecond timer per
/// packet. Instead we allow a short burst — the same trade every real
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
/// can still saturate the configured rate, with a small floor so a
/// cold/low estimate can never wedge sending entirely.
fn pacing_burst(&self) -> f64 {
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
by_rate.max((self.mtu * 4) as f64)
}
/// Refill from elapsed time and deduct `bytes`. Called on the real send
/// path; allowance is permitted to go negative so an oversized packet still
/// pays for itself rather than being released for free.
fn consume_pacing(&mut self, bytes: u64) {
let now = Instant::now();
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
self.pacing_last_refill = now;
self.pacing_tokens =
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
- bytes as f64;
}
/// Returns the current congestion window in bytes.
pub fn cwnd(&self) -> u64 {
self.cwnd
@ -225,11 +167,6 @@ impl CongestionController {
/// Record that we sent `bytes` of data.
pub fn on_send(&mut self, bytes: u64) {
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
// Charge the pacing bucket here rather than at the admission check, so
// every byte that actually reaches the wire is paid for exactly once —
// including retransmits, which are precisely what must not be allowed
// to bypass the rate limit and pile into an already-full queue.
self.consume_pacing(bytes);
}
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
@ -261,46 +198,9 @@ impl CongestionController {
/// Congestion-window growth shared by both ACK paths (slow start / probe).
fn grow_window(&mut self, bytes: u64) {
// ── Delay-based congestion signal ────────────────────────────────────
// A loss-only controller is blind on a deeply-buffered path, and mobile
// carrier buffers are very deep: they absorb a burst instead of dropping
// it, so no loss is ever signalled and cwnd keeps growing. The queue —
// not the link — is what grows, and the standing delay it adds shows up
// as RTT inflating far above the path's floor. Left unchecked this is a
// positive feedback loop: bigger queue -> larger RTT samples -> larger
// SRTT -> larger RTO -> retransmits pile on -> bigger queue, which is
// how a session ends up reporting multi-second (even multi-minute) RTT
// and stalls video until the buffer finally drains or the user
// reconnects. Treat sustained RTT inflation as congestion in its own
// right, exactly as it is.
let inflation = if self.rtt_initialized && !self.min_rtt.is_zero() {
self.srtt.as_secs_f64() / self.min_rtt.as_secs_f64()
} else {
1.0
};
if inflation >= RTT_INFLATION_BACKOFF {
// Standing queue is severe — actively drain it.
self.cwnd = (self.cwnd / 2).max(MIN_CWND_PACKETS * self.mtu);
self.ssthresh = self.cwnd;
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: draining standing queue");
self.clamp_cwnd();
return;
}
// State machine
match self.phase {
Phase::SlowStart => {
// Exponential doubling is what fills a deep buffer fastest, so
// leave slow start as soon as the queue starts to build rather
// than waiting for the loss that may never come.
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
self.ssthresh = self.cwnd;
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
self.clamp_cwnd();
return;
}
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
self.cwnd = self.cwnd.saturating_add(bytes);
if self.cwnd >= self.ssthresh {
@ -313,21 +213,6 @@ impl CongestionController {
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
}
}
self.clamp_cwnd();
}
/// Hard ceiling on the congestion window.
///
/// Independent of any estimate: no real path this protocol runs over has a
/// bandwidth-delay product anywhere near this, so a window above it is
/// buffered queue rather than data in transit. Without it, slow start on a
/// buffer that never drops could grow the window into the tens of megabytes.
fn clamp_cwnd(&mut self) {
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
if self.cwnd > ceiling {
self.cwnd = ceiling;
}
}
/// Record a loss event.
@ -447,94 +332,6 @@ mod tests {
assert!(cc.cwnd() < initial);
}
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
/// ever lost, but the standing queue inflates RTT. A loss-only controller
/// grows cwnd forever here — which is how a session ends up reporting
/// multi-second RTT and stalling video.
#[test]
fn test_rtt_inflation_halts_growth_without_any_loss() {
let mut cc = CongestionController::new(1200);
// Establish a low path floor; this becomes min_rtt.
for _ in 0..4 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(20));
}
let cwnd_before = cc.cwnd();
// Queue builds: RTT climbs far above the floor, still zero loss.
for _ in 0..20 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(400));
}
assert!(
cc.cwnd() <= cwnd_before,
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
cwnd_before,
cc.cwnd()
);
}
/// Pacing must actually bound the release rate: draining the bucket has to
/// deny the next packet. Without this the congestion window alone decides,
/// and a whole window leaves back-to-back.
#[test]
fn test_pacing_bucket_denies_once_drained() {
let mut cc = CongestionController::new(1200);
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
// Spend well beyond one burst allowance.
let burst_bytes = cc.pacing_available();
let mut spent = 0.0;
while spent <= burst_bytes + 1200.0 {
cc.on_send(1200);
spent += 1200.0;
}
assert!(
!cc.can_pace_packet(),
"pacing allowed unbounded sending: {} bytes still available after spending {}",
cc.pacing_available(),
spent
);
}
/// The allowance must refill over time, or sending would stall permanently
/// once the first burst is spent.
#[test]
fn test_pacing_bucket_refills_over_time() {
let mut cc = CongestionController::new(1200);
while cc.can_pace_packet() {
cc.on_send(1200);
}
assert!(!cc.can_pace_packet());
std::thread::sleep(Duration::from_millis(25));
assert!(
cc.can_pace_packet(),
"pacing bucket never refilled; sending would be stuck forever"
);
}
/// cwnd must never exceed the absolute ceiling, however long slow start
/// runs unopposed — above it the window is buffered queue, not throughput.
#[test]
fn test_cwnd_never_exceeds_absolute_ceiling() {
let mut cc = CongestionController::new(1200);
// Constant RTT: no inflation signal, so only the hard cap can stop this.
for _ in 0..5000 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(30));
}
assert!(
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
"cwnd {} exceeded the {}-packet ceiling",
cc.cwnd(),
MAX_CWND_PACKETS
);
}
#[test]
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
// A single dropped packet (wireless noise, a brief handover blip) is

View File

@ -4,11 +4,6 @@ use thiserror::Error;
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
/// Upper bound on a single frame's retransmit timer, after exponential backoff
/// is applied to the adaptive RTO. Past this the session is dead from the
/// user's point of view, and waiting longer only delays recovery.
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
use crate::congestion::CongestionController;
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
@ -188,16 +183,6 @@ impl ProtocolMachine {
self.cc.cwnd_packets() as usize
}
/// Whether the pacing bucket currently allows releasing another packet.
///
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
/// nothing about how fast that window is emptied onto the wire. Sending a
/// whole window back-to-back is what drives a deep buffer into standing
/// queue, so admission is gated on both.
pub fn can_pace_packet(&self) -> bool {
self.cc.can_pace_packet()
}
pub fn on_send(&mut self, bytes: u64) {
self.cc.on_send(bytes);
}
@ -690,15 +675,8 @@ impl ProtocolMachine {
break;
}
// Exponential backoff, but bounded in absolute terms. base_rto is
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
// multiplying that by the 64x backoff cap yields a frame that sits
// unretransmitted for ~17 MINUTES, long past the point where the
// session is simply dead to the user. Cap the product so backoff
// stays a backoff rather than an outage.
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
.min(MAX_EFFECTIVE_RTO);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
if now.duration_since(frame.last_sent) >= effective_rto {
// Only burn the retry counter and reset the RTO timer when the

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.4.3+26
version: 0.4.2+25
environment:
sdk: ^3.11.4

View File

@ -1,7 +1,7 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.4.3",
"version": "0.4.2",
"type": "module",
"scripts": {
"tauri": "tauri",

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2696,7 +2696,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"bytes",
@ -2713,7 +2713,7 @@ dependencies = [
[[package]]
name = "ostp-gui"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"json_comments",
@ -2733,7 +2733,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.3"
version = "0.4.2"
dependencies = [
"anyhow",
"libc",

View File

@ -1,6 +1,6 @@
[package]
name = "ostp-gui"
version = "0.4.3"
version = "0.4.2"
description = "OSTP desktop GUI"
authors = ["ospab"]
edition = "2021"

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.4.3",
"version": "0.4.2",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -263,19 +263,8 @@ impl Dispatcher {
self.peer_machines
.iter()
.map(|(&sid, ps)| {
// Ceiling matches MAX_CWND_PACKETS in ostp-core. The old 16384
// allowed ~20 MB outstanding toward one client — on a mobile
// downlink that is standing queue, not throughput, and it is the
// download direction that carries video.
let cwnd = (ps.machine.cwnd_packets() as i64).clamp(16, 1024);
let cwnd = (ps.machine.cwnd_packets() as i64).clamp(16, 16384);
let in_flight = ps.machine.in_flight_count() as i64;
// Pacing gates the RATE, cwnd only the outstanding amount. With
// the pacing bucket empty, report no headroom so the relay
// reader pauses instead of handing over another chunk that would
// leave back-to-back.
if !ps.machine.can_pace_packet() {
return (sid, 0);
}
(sid, cwnd - in_flight)
})
.collect()

View File

@ -95,29 +95,7 @@ async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result<usize>
let resp = req.send().await?;
if !resp.status().is_success() {
// 404 here almost always means the URL is missing the panel's secret
// path segment rather than the server being down or the token being
// wrong. The management API is not served at /api — it is nested under
// the configured `api.webpath` (see create_api_router), which exists to
// keep the panel from being discoverable by scanners. A bare
// host:port therefore resolves to a route that does not exist, and the
// token is never even looked at, which makes "404" a deeply misleading
// thing to report on its own.
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"API returned HTTP 404 for {url}. The management API is served under the \
target server's secret `api.webpath`, not at /api set upstream_api_url \
to include it, e.g. \"http://HOST:9090/<webpath>\" (the same path you open \
the web panel at). Check `api.webpath` in the target server's config."
);
}
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
anyhow::bail!(
"API returned HTTP 401 for {url}: upstream_api_token does not match the \
target server's `api.token`."
);
}
anyhow::bail!("API returned HTTP {} for {url}", resp.status());
anyhow::bail!("API returned HTTP {}", resp.status());
}
#[derive(serde::Deserialize)]

View File

@ -28,12 +28,6 @@ enum Commands {
Init {
mode: String,
},
/// Hash a password for the web panel's `api.password_hash` config field
#[command(name = "hash-password", alias = "hp")]
HashPassword {
/// The password to hash. Omit to be prompted (keeps it out of shell history).
password: Option<String>,
},
/// Generate a new secure access key
#[command(name = "gk", alias = "generate-key")]
GenerateKey {
@ -799,26 +793,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
let upstream = wizard_prompt("Upstream server address (host:port)", "");
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
let api_url = wizard_prompt(
"Upstream API URL, including the panel's secret path (e.g. http://1.2.3.4:9090/bNAzr8Ss)",
"",
);
// The management API lives under the target server's api.webpath, so
// a bare host:port 404s on every key sync without ever checking the
// token. Catch that here instead of leaving it to be debugged from
// relay logs.
let has_path = api_url
.split("://")
.nth(1)
.map(|rest| rest.contains('/') && !rest.trim_end_matches('/').split('/').nth(1).unwrap_or("").is_empty())
.unwrap_or(false);
if !api_url.is_empty() && !has_path {
wizard_warn(
"This URL has no path segment. The API is served under the target server's \
api.webpath - key sync will fail with 404 unless you append it.",
);
}
let api_token = wizard_prompt("Upstream API token (must equal api.token on the target server)", "");
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
wizard_step(2, TOTAL, "Saving configuration");
let relay_json = serde_json::json!({
@ -944,38 +920,6 @@ async fn run_app() -> Result<()> {
match cmd {
Commands::Setup { init } => { args.setup = true; args.init = init; }
Commands::Init { mode } => { args.init = Some(mode); }
Commands::HashPassword { password } => {
// The panel stores only a hash, and until now nothing in the CLI
// could produce one: `ostp init server` writes password_hash: ""
// and the only generator lived inside the Unix-only Server+Panel
// wizard branch, leaving no supported way to set up API auth on a
// plain server.
let password = match password {
Some(p) => p,
None => {
print!("Password: ");
use std::io::Write as _;
std::io::stdout().flush().ok();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
buf.trim_end_matches(['\r', '\n']).to_string()
}
};
if password.is_empty() {
anyhow::bail!("password must not be empty");
}
// Must match api.rs's handle_login byte for byte.
let hash = format!(
"{:x}",
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
);
println!();
println!("Add this to the \"api\" section of your config:");
println!();
println!(" \"password_hash\": \"{hash}\"");
println!();
return Ok(());
}
Commands::GenerateKey { format, count } => { args.generate_key = true; args.format = format; args.count = count; }
Commands::Links => { args.links = true; }
Commands::Check => { args.check = true; }
@ -1231,13 +1175,7 @@ async fn run_app() -> Result<()> {
"listen": "0.0.0.0:50000",
"upstream_tcp": "TARGET_SERVER_IP:50000",
"upstream_udp": "TARGET_SERVER_IP:50000",
// MUST include the target server's secret api.webpath. The management API is
// nested under it (that path is what hides the panel from scanners), so a
// bare host:port hits a route that does not exist and key sync fails with 404
// before the token is ever checked. This is the same URL you open the panel
// at, e.g. "http://1.2.3.4:9090/bNAzr8Ss".
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
// Must equal api.token on the target server (NOT the panel password).
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
"upstream_api_token": "YOUR_API_TOKEN_HERE",
"sync_interval_secs": 30,
"debug": false