use std::time::Duration;
use std::{str::FromStr, sync::LazyLock};
use anyhow::Result;
use clap::{Parser, builder::TypedValueParser as _};
use engineering_repr::{EngineeringQuantity, EngineeringRepr};
use human_repr::{HumanCount as _, HumanDuration as _};
use serde::{Deserialize, Serialize};
use struct_field_names_as_array::FieldNamesAsSlice;
use crate::{
cli::styles::{ColourMode, info, reset},
protocol::control::{CongestionController, CredentialsType},
util::serialization::{EQHelper, StringOrVec},
util::{
AddressFamily, PortRange, SerializeAsString, SerializeEnumAsString, TimeFormat,
ToStringForFigment, derive_deftly_template_Optionalify,
},
};
use derive_deftly::Deftly;
pub(crate) const MINIMUM_BANDWIDTH: u64 = 150;
pub(crate) const MINIMUM_UDP_BUFFER: u64 = 1024;
#[derive(PartialEq, Deftly, FieldNamesAsSlice, Serialize)]
#[derive_deftly(Optionalify)]
#[deftly(visibility = "pub(crate)")]
#[derive(Clone, Debug, Parser, Deserialize)]
pub struct Configuration {
#[arg(
long,
alias("rx-bw"),
value_name = "bytes",
value_parser (clap::builder::StringValueParser::new().try_map(|s| EngineeringQuantity::<u64>::from_str(&s)).map(|v| u64::from(v))),
help_heading("Tuning"),
display_order(0),
long_help(r"
The maximum network bandwidth we expect receiving data FROM the remote system.
[default: 12.5M]
This is the single most important configuration necessary for good performance! If you configure nothing else, at least set this to suit your network.
This parameter is always interpreted as the _local_ bandwidth, whether operating in client or server mode.
This may be specified directly as a number, or as an SI quantity like `10M` or `256k`. Note that this is described in BYTES, not bits; if (for example) you expect to fill a 1Gbit ethernet connection, 125M would be a suitable setting.
"),
)]
#[serde(with = "EQHelper")]
#[deftly(
serde = "default, deserialize_with = \"EQHelper::deserialize_optional\"",
serialize_with = "EQHelper::to_string_figment"
)]
pub rx: u64,
#[arg(
long,
alias("tx-bw"),
value_name = "bytes",
value_parser (clap::builder::StringValueParser::new().try_map(|s| EngineeringQuantity::<u64>::from_str(&s)).map(|v| u64::from(v))),
help_heading("Tuning"),
display_order(0),
long_help(r"
The maximum network bandwidth we expect sending data TO the remote system, if it is different from the bandwidth FROM the system. (For example, when you are connected via an asymmetric last-mile DSL or fibre profile.)
Specify as a number, or as an SI quantity (e.g. `10M`).
This parameter is always interpreted as the _local_ bandwidth, whether operating in client or server mode. If not specified or 0, uses the value of `rx`.
"),
)]
#[serde(with = "EQHelper")]
#[deftly(
serde = "default, deserialize_with = \"EQHelper::deserialize_optional\"",
serialize_with = "EQHelper::to_string_figment"
)]
pub tx: u64,
#[arg(long, help_heading("Tuning"), value_name("ms"), display_order(1))]
pub rtt: u16,
#[arg(
long,
value_name = "ALGORITHM",
value_enum,
ignore_case(true),
help_heading("Advanced network tuning"),
display_order(0)
)]
#[serde(serialize_with = "CongestionController::serialize_str")]
#[serde(deserialize_with = "CongestionController::deserialize_str")]
#[deftly(
serde = "default, deserialize_with = \"CongestionController::deserialize_str_optional\"",
serialize_with = "CongestionController::to_string_figment"
)]
pub congestion: CongestionController,
#[arg(
long,
value_name = "bytes",
alias("cwnd"),
help_heading("Advanced network tuning"),
display_order(0),
long_help(
r"
(Network wizards only!)
The initial value for the sending congestion control window, in bytes.
If unspecified, the active congestion control algorithm decides.
Setting this value too high reduces performance!
This may be specified directly as a number, or as an SI quantity like `10k`."
)
)]
#[serde(with = "EQHelper")]
#[deftly(
serde = "default, deserialize_with = \"EQHelper::deserialize_optional\"",
serialize_with = "EQHelper::to_string_figment"
)]
pub initial_congestion_window: u64,
#[arg(long, value_name("M-N"), help_heading("Connection"), display_order(0))]
#[serde(
serialize_with = "PortRange::serialize_str",
deserialize_with = "PortRange::deserialize_str"
)]
#[deftly(
serde = "default, deserialize_with = \"PortRange::deserialize_str_optional\"",
serialize_with = "PortRange::to_string_figment"
)]
pub port: PortRange,
#[arg(
long,
value_name("seconds"),
help_heading("Connection"),
display_order(0)
)]
pub timeout: u16,
#[arg(long,
help_heading("Advanced network tuning"),
display_order(0),
value_name("bytes"),
value_parser(clap::builder::StringValueParser::new().try_map(|s| EngineeringQuantity::<u64>::from_str(&s)).map(|v| u64::from(v))))
]
#[serde(with = "EQHelper")]
#[deftly(
serde = "default, deserialize_with = \"EQHelper::deserialize_optional\"",
serialize_with = "EQHelper::to_string_figment"
)]
pub udp_buffer: u64,
#[arg(
long,
help_heading("Advanced network tuning"),
display_order(0),
value_name = "N"
)]
pub packet_threshold: u32,
#[arg(
long,
help_heading("Advanced network tuning"),
display_order(0),
value_name = "M"
)]
pub time_threshold: f32,
#[arg(
long,
help_heading("Advanced network tuning"),
display_order(0),
value_name = "bytes"
)]
pub initial_mtu: u16,
#[arg(
long,
help_heading("Advanced network tuning"),
display_order(0),
value_name = "bytes"
)]
pub min_mtu: u16,
#[arg(
long,
help_heading("Advanced network tuning"),
display_order(0),
value_name = "bytes"
)]
pub max_mtu: u16,
#[arg(
long,
help_heading("Connection"),
group("ip address"),
value_name("FAMILY"),
display_order(0),
value_enum,
ignore_case(true)
)]
pub address_family: AddressFamily,
#[arg(long, help_heading("Connection"), display_order(0), value_name("PATH"))]
pub ssh: String,
#[arg(long, value_name("PATH"), help_heading("Connection"), display_order(0))]
pub remote_qcp_binary: String,
#[arg(
short = 'S',
value_name("SSH OPTION"),
allow_hyphen_values(true),
help_heading("Connection"),
display_order(0),
long_help(
r"
Provides an additional option or argument to pass to the ssh client. [default: none]
On the command line, you must repeat `-S` for each argument.
For example, to pass `-i /dev/null` to ssh, specify: `-S -i -S /dev/null`"
)
)]
#[serde(deserialize_with = "StringOrVec::deserialize")]
#[deftly(serde = "default, deserialize_with = \"StringOrVec::deserialize_optional\"")]
pub ssh_options: Vec<String>,
#[arg(
short = 'P',
long,
value_name("M-N"),
help_heading("Connection"),
display_order(0)
)]
#[serde(
serialize_with = "PortRange::serialize_str",
deserialize_with = "PortRange::deserialize_str"
)]
#[deftly(
serde = "default, deserialize_with = \"PortRange::deserialize_str_optional\"",
serialize_with = "PortRange::to_string_figment"
)]
pub remote_port: PortRange,
#[arg(
short = 'l',
long,
value_name("NAME"),
help_heading("Connection"),
display_order(0)
)]
pub remote_user: String,
#[arg(
long,
value_name("FORMAT"),
display_order(0),
value_enum,
ignore_case(true)
)]
pub time_format: TimeFormat,
#[arg(long, value_name("FILE"), help_heading("Connection"), display_order(0))]
#[serde(deserialize_with = "StringOrVec::deserialize")]
#[deftly(serde = "default, deserialize_with = \"StringOrVec::deserialize_optional\"")]
pub ssh_config: Vec<String>,
#[arg(
long,
alias("subsystem"),
action = clap::ArgAction::SetTrue,
help_heading("Connection"),
display_order(0)
)]
pub ssh_subsystem: bool,
#[arg(
long,
alias("colour"),
default_missing_value("always"), // to support `--color`
num_args(0..=1),
value_name("MODE"),
ignore_case(true),
display_order(0),
long_help(r"Colour mode for console output (default: auto)
Passing `--color` without a value is equivalent to `--color always`.
Note that color configuration is not shared with the remote system, so the color output
from the remote system (log messages, remote-config) will be coloured per the
config file on the remote system.
qcp also supports the `CLICOLOR`, `CLICOLOR_FORCE` and `NO_COLOR` environment variables.
See https://bixense.com/clicolors/ for more details.
CLI options take precedence over the configuration file, which takes precedence over environment variables."),
)]
pub color: ColourMode,
#[arg(
long,
value_name("TYPE"),
help_heading("Connection"),
display_order(0),
value_enum,
ignore_case = true
)]
#[serde(deserialize_with = "CredentialsType::deserialize_str")]
#[deftly(serde = "default, deserialize_with = \"CredentialsType::deserialize_str_optional\"")]
pub tls_auth_type: CredentialsType,
#[arg(
long,
action = clap::ArgAction::SetTrue,
display_order(0),
help_heading("Connection"),
)]
pub aes256: bool,
#[arg(long,
help_heading("Advanced network tuning"),
display_order(10),
value_name = "bytes",
value_parser (clap::builder::StringValueParser::new().try_map(|s| EngineeringQuantity::<u64>::from_str(&s)).map(|v| u64::from(v))),
)]
#[serde(with = "EQHelper")]
#[deftly(
serde = "default, deserialize_with = \"EQHelper::deserialize_optional\"",
serialize_with = "EQHelper::to_string_figment"
)]
pub io_buffer_size: u64,
}
static SYSTEM_DEFAULT_CONFIG: LazyLock<Configuration> = LazyLock::new(|| Configuration {
rx: 12_500_000, tx: 0,
rtt: 300,
congestion: CongestionController::Cubic,
initial_congestion_window: 0,
port: PortRange::default(),
timeout: 5,
udp_buffer: 4_000_000,
packet_threshold: 3, time_threshold: 9. / 8., initial_mtu: 1200, min_mtu: 1200, max_mtu: 1452,
address_family: AddressFamily::Any,
ssh: "ssh".into(),
remote_qcp_binary: "qcp".into(),
ssh_options: Vec::new(),
remote_port: PortRange::default(),
remote_user: String::new(),
time_format: TimeFormat::Local,
ssh_config: Vec::new(),
ssh_subsystem: false,
color: ColourMode::Auto,
io_buffer_size: crate::util::io::DEFAULT_COPY_BUFFER_SIZE,
tls_auth_type: CredentialsType::Any,
aes256: false,
});
impl Configuration {
#[must_use]
pub(crate) fn rtt_bandwidth_delay_product_tx(&self) -> u64 {
self.tx() * u64::from(self.rtt) / 1000
}
#[must_use]
pub(crate) fn rtt_bandwidth_delay_product_rx(&self) -> u64 {
self.rx() * u64::from(self.rtt) / 1000
}
#[must_use]
pub fn rx(&self) -> u64 {
self.rx
}
#[must_use]
pub fn tx(&self) -> u64 {
match self.tx {
0 => self.rx(),
tx => tx,
}
}
#[must_use]
pub fn rtt_duration(&self) -> Duration {
Duration::from_millis(u64::from(self.rtt))
}
#[must_use]
pub fn recv_window(&self) -> u64 {
self.rtt_bandwidth_delay_product_rx()
}
#[must_use]
pub fn send_window(&self) -> u64 {
8 * self.rtt_bandwidth_delay_product_tx()
}
#[must_use]
pub fn timeout_duration(&self) -> Duration {
Duration::from_secs(self.timeout.into())
}
#[must_use]
pub fn format_transport_config(&self) -> String {
let iwind = match self.initial_congestion_window {
0 => "<default>".to_string(),
s => s.human_count_bytes().to_string(),
};
let (tx, rx) = (self.tx(), self.rx());
format!(
concat!(
"rx {rx} ({rxbits}), tx {tx} ({txbits}), rtt {rtt}; ",
"congestion algorithm {congestion} with initial window {iwind}; ",
"send window {swnd}, receive window {rwnd}, ",
"UDP buffer size {udp}; ",
"packet_threshold {pkt_t}, time_threshold {tim_t}xRTT"
),
tx = tx.human_count_bytes(),
txbits = (tx * 8).human_count("bit"),
rx = rx.human_count_bytes(),
rxbits = (rx * 8).human_count("bit"),
rtt = self.rtt_duration().human_duration(),
congestion = self.congestion,
iwind = iwind,
swnd = self.send_window().human_count_bytes(),
rwnd = self.recv_window().human_count_bytes(),
udp = self.udp_buffer.human_count_bytes(),
pkt_t = self.packet_threshold,
tim_t = self.time_threshold,
)
}
#[must_use]
pub fn system_default() -> &'static Self {
&SYSTEM_DEFAULT_CONFIG
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ValidationData {
rtt: u16,
rx: u64,
tx: u64,
udp: u64,
}
impl Configuration {
fn validation_data(&self) -> ValidationData {
ValidationData {
rtt: self.rtt,
rx: self.rx(),
tx: self.tx(),
udp: self.udp_buffer,
}
}
pub(crate) fn try_validate(&self) -> Result<()> {
let data = self.validation_data();
let rtt = data.rtt;
let rx = data.rx;
#[allow(non_snake_case)] let INFO = info();
#[allow(non_snake_case)] let RESET = reset();
anyhow::ensure!(
rx >= MINIMUM_BANDWIDTH,
"The receive bandwidth ({INFO}rx {val}{RESET}B) is too small; it must be at least {min}",
val = rx.to_eng(0),
min = MINIMUM_BANDWIDTH.to_eng(3),
);
anyhow::ensure!(rtt > 0, "RTT cannot be zero");
anyhow::ensure!(
rx.checked_mul(rtt.into()).is_some(),
"The receive bandwidth delay product calculation ({INFO}rx {val}{RESET}B x {INFO}rtt {rtt}{RESET}ms) overflowed",
val = rx.to_eng(0),
);
let tx = data.tx;
anyhow::ensure!(
tx == 0 || tx >= MINIMUM_BANDWIDTH,
"The transmit bandwidth ({INFO}tx {val}{RESET}B) is too small; it must be at least {min}",
val = tx.to_eng(0),
min = MINIMUM_BANDWIDTH.to_eng(3),
);
anyhow::ensure!(
tx == 0 || tx.checked_mul(rtt.into()).is_some(),
"The transmit bandwidth delay product calculation ({INFO}tx {val}{RESET}B x {INFO}rtt {rtt}{RESET}ms) overflowed",
val = tx.to_eng(0),
);
let udp = data.udp;
anyhow::ensure!(
udp >= MINIMUM_UDP_BUFFER,
"The UDP buffer size ({INFO}{udp}{RESET}) is too small; it must be at least {MINIMUM_UDP_BUFFER}",
);
anyhow::ensure!(
self.min_mtu >= 1200,
"Minimum MTU ({mtu}) cannot be less than 1200",
mtu = self.min_mtu
);
anyhow::ensure!(
self.max_mtu >= 1200,
"Maximum MTU ({mtu}) cannot be less than 1200",
mtu = self.max_mtu
);
anyhow::ensure!(
self.initial_mtu >= 1200,
"Initial MTU ({mtu}) cannot be less than 1200",
mtu = self.initial_mtu
);
Ok(())
}
pub(crate) fn validate(self) -> Result<Self>
where
Self: std::marker::Sized,
{
self.try_validate()?;
Ok(self)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
use crate::{
Configuration,
config::{ColourMode, Manager},
protocol::control::{CongestionController, CredentialsType},
util::{AddressFamily, TimeFormat},
};
use super::SYSTEM_DEFAULT_CONFIG;
use assertables::assert_contains;
use littertray::LitterTray;
use pretty_assertions::assert_eq;
#[test]
fn flattened() {
let v = SYSTEM_DEFAULT_CONFIG.clone();
let j = serde_json::to_string(&v).unwrap();
let d = jzon::parse(&j).unwrap();
assert!(!d.has_key("bw"));
assert!(d.has_key("rtt"));
}
#[test]
fn accessors() {
let mut cfg = SYSTEM_DEFAULT_CONFIG.clone();
cfg.rtt = 123;
assert_eq!(cfg.rtt_duration().as_millis(), 123);
cfg.timeout = 4;
assert_eq!(cfg.timeout_duration().as_secs(), 4);
let s = cfg.format_transport_config();
assert!(s.contains("rx 12.5MB (100Mbit)"));
assert!(s.contains("rtt 123ms"));
assert!(s.contains("congestion algorithm cubic with initial window <default>"));
cfg.initial_congestion_window = 1000;
let s = cfg.format_transport_config();
assert!(s.contains("congestion algorithm cubic with initial window 1kB"));
}
#[test]
fn validate() {
fn tc<T: Fn(&mut crate::Configuration)>(func: T, expected: &str, expected2: Option<&str>) {
let mut cfg = SYSTEM_DEFAULT_CONFIG.clone();
func(&mut cfg);
let str = cfg.try_validate().unwrap_err().to_string();
let error = console::strip_ansi_codes(&str);
assert_contains!(error, expected);
if let Some(expected2_) = expected2 {
assert_contains!(error, expected2_);
}
}
let cfg = SYSTEM_DEFAULT_CONFIG.clone();
assert!(cfg.try_validate().is_ok());
tc(|c| c.rx = 1, "receive bandwidth (rx 1B) is too small", None);
tc(
|c| c.tx = 1,
"transmit bandwidth (tx 1B) is too small",
None,
);
tc(
|c| c.rx = u64::MAX,
"receive bandwidth delay product calculation",
Some("overflowed"),
);
tc(
|c| c.tx = u64::MAX,
"transmit bandwidth delay product calculation",
Some("overflowed"),
);
tc(|c| c.rtt = 0, "RTT cannot be zero", None);
tc(
|c| c.udp_buffer = 0,
"The UDP buffer size (0) is too small",
None,
);
tc(|c| c.min_mtu = 0, "Minimum MTU (0) cannot be ", None);
tc(|c| c.max_mtu = 0, "Maximum MTU (0) cannot be ", None);
tc(|c| c.initial_mtu = 0, "Initial MTU (0) cannot be ", None);
}
#[test]
fn issue_123_validate_default_data() {
let mgr = Manager::without_files(None);
let cfg = mgr.get::<super::Configuration>().unwrap();
let data = cfg.validation_data();
assert_eq!(data.rtt, SYSTEM_DEFAULT_CONFIG.rtt);
assert_eq!(data.rx, SYSTEM_DEFAULT_CONFIG.rx());
assert_eq!(data.tx, SYSTEM_DEFAULT_CONFIG.tx());
}
fn assert_cfg_parseable(data: &str) -> Configuration {
use crate::config::{Configuration, Configuration_Optional};
let mut mgr = LitterTray::try_with(|tray| {
let path = "test.conf";
let _ = tray.create_text(path, data);
let mut mgr = Manager::without_files(None);
mgr.merge_ssh_config(path, None, false);
Ok(mgr)
})
.unwrap();
let cfg_opt = mgr.get::<Configuration_Optional>();
assert!(cfg_opt.is_ok(), "optional config failed for case {data}");
mgr.apply_system_default();
let cfg = mgr.get::<Configuration>();
assert!(cfg.is_ok(), "non-optional config failed for case {data}");
cfg.unwrap()
}
#[test]
#[allow(unused_results)]
fn cfg_enum_capitalisation() {
assert_cfg_parseable("");
assert_cfg_parseable(
r"
addressfamily inet6
congestion bbr
timeformat rfc3339
color always
tlsauthtype x509
",
);
let c = assert_cfg_parseable(
r"
addressfamily iNEt6
congestion bBr
timeformat rFc3339
color aLWAys
tlsauthtype X509
",
);
assert_eq!(c.address_family, AddressFamily::Inet6);
assert_eq!(c.congestion, CongestionController::Bbr);
assert_eq!(c.time_format, TimeFormat::Rfc3339);
assert_eq!(c.color, ColourMode::Always);
assert_eq!(c.tls_auth_type, CredentialsType::X509);
}
#[test]
#[should_panic(expected = "optional config failed")]
#[allow(unused_results)]
fn cfg_unparsable_enum() {
assert_cfg_parseable("congestion nosuchalgorithm");
}
#[test]
fn cfg_ssh_options_regression() {
let c = assert_cfg_parseable("sshoptions");
assert_eq!(c.ssh_options, Vec::<String>::new());
let c = assert_cfg_parseable("sshoptions a");
assert_eq!(c.ssh_options, vec!["a"]);
let c = assert_cfg_parseable("sshoptions a b");
assert_eq!(c.ssh_options, vec!["a", "b"]);
}
}