增加ssh

增加deploy下细分的tcp、tls、ssh
This commit is contained in:
xiuting.xu
2026-04-22 16:02:42 +08:00
parent 99250f8aa9
commit b60d579a38
37 changed files with 2951 additions and 636 deletions

View File

@@ -1,42 +1,41 @@
use std::collections::BTreeSet;
use std::env;
use std::io;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_rustls::TlsConnector;
use rpki_rs::rtr::client::{Client, PayloadError, PayloadTarget};
use rpki_rs::rtr::payload::{Action, Payload, Timing};
const DEFAULT_TIMEOUT_SECS: u64 = 10;
const DEFAULT_STEPS: usize = 1;
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
const DEFAULT_PROGRESS_EVERY: u64 = 10_000;
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
type DynStream = Box<dyn AsyncStream>;
#[derive(Debug, Clone)]
struct Config {
addr: String,
version: u8,
mode: QueryMode,
steps: usize,
follow: bool,
transport: TransportConfig,
assert_substr: Vec<String>,
assert_min_records: Option<usize>,
print_records: bool,
}
#[derive(Debug, Clone, Copy)]
enum QueryMode {
Reset,
SerialAuto,
Serial { session_id: u16, serial: u32 },
step_timeout_secs: u64,
progress_every: u64,
}
impl Config {
@@ -50,54 +49,74 @@ impl Config {
let mut assert_substr = Vec::new();
let mut assert_min_records = None;
let mut print_records = false;
let mut step_timeout_secs = DEFAULT_STEP_TIMEOUT_SECS;
let mut progress_every = DEFAULT_PROGRESS_EVERY;
while let Some(arg) = args.next() {
match arg.as_str() {
"--version" => {
let _ = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--version requires value")
})?;
// rpki-rs v0.18 client only exposes Client::new without
// initial-version override. Keep this option as reserved.
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
"--steps" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--steps requires value")
})?;
steps = parse_usize_arg(&v, "--steps")?;
if steps == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--steps must be >= 1",
));
}
}
"--follow" => {
follow = true;
}
"--tls" => {
if matches!(transport, TransportConfig::Tcp) {
transport = TransportConfig::Tls(TlsConfig::default());
}
ensure_tls(&mut transport)?;
}
"--ca-cert" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--ca-cert requires path")
})?;
ensure_tls(&mut transport)?.ca_cert = Some(PathBuf::from(v));
}
"--server-name" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--server-name requires value")
io::Error::new(
io::ErrorKind::InvalidInput,
"--server-name requires value",
)
})?;
ensure_tls(&mut transport)?.server_name = Some(v);
}
"--client-cert" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--client-cert requires path")
io::Error::new(
io::ErrorKind::InvalidInput,
"--client-cert requires path",
)
})?;
ensure_tls(&mut transport)?.client_cert = Some(PathBuf::from(v));
}
"--client-key" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--client-key requires path")
io::Error::new(
io::ErrorKind::InvalidInput,
"--client-key requires path",
)
})?;
ensure_tls(&mut transport)?.client_key = Some(PathBuf::from(v));
}
"--assert-substr" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
@@ -107,6 +126,7 @@ impl Config {
})?;
assert_substr.push(v);
}
"--assert-min-records" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
@@ -116,21 +136,71 @@ impl Config {
})?;
assert_min_records = Some(parse_usize_arg(&v, "--assert-min-records")?);
}
"--print-records" => {
print_records = true;
}
"--step-timeout-secs" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--step-timeout-secs requires value",
)
})?;
step_timeout_secs = parse_u64_arg(&v, "--step-timeout-secs")?;
if step_timeout_secs == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--step-timeout-secs must be >= 1",
));
}
}
"--progress-every" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--progress-every requires value",
)
})?;
progress_every = parse_u64_arg(&v, "--progress-every")?;
if progress_every == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--progress-every must be >= 1",
));
}
}
// 明确拒绝当前 wrapper 不支持的能力
"--version" => {
let _ = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--version requires value")
})?;
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--version is not exposed by this rpki client wrapper",
));
}
"--timeout" => {
let _ = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--timeout requires value")
})?;
// This binary relies on rpki-rs client's built-in IO timeout.
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--timeout is not exposed by this rpki client wrapper; use --step-timeout-secs instead",
));
}
_ if arg.starts_with("--") => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown option '{}'", arg),
));
}
_ => positional.push(arg),
}
}
@@ -139,56 +209,72 @@ impl Config {
let addr = positional
.next()
.unwrap_or_else(|| "127.0.0.1:323".to_string());
let version = positional
.next()
.map(|v| parse_u8_arg(&v, "version"))
.transpose()?
.unwrap_or(2);
let mode = match positional.next().as_deref() {
None | Some("reset") => QueryMode::Reset,
Some("serial") if positional.clone().next().is_none() => QueryMode::SerialAuto,
Some("serial") => {
let session_id = parse_u16_arg(
&positional.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"serial mode requires session_id and serial",
)
})?,
"session_id",
)?;
let serial = parse_u32_arg(
&positional.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "serial mode requires serial")
})?,
"serial",
)?;
QueryMode::Serial { session_id, serial }
}
Some(other) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid mode '{}', expected 'reset' or 'serial'", other),
));
}
};
if let Some(extra) = positional.next() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unexpected positional argument '{}'; only optional [addr] is supported",
extra
),
));
}
let transport = finalize_transport(transport, &addr)?;
Ok(Self {
addr,
version,
mode,
steps,
follow,
transport,
assert_substr,
assert_min_records,
print_records,
step_timeout_secs,
progress_every,
})
}
}
fn print_usage() {
eprintln!(
"\
rpki-rs-test-client
Usage:
rpki-rs-test-client [OPTIONS] [ADDR]
Examples:
rpki-rs-test-client
rpki-rs-test-client 127.0.0.1:323 --steps 1
rpki-rs-test-client 127.0.0.1:323 --steps 1 --step-timeout-secs 600
rpki-rs-test-client 127.0.0.1:323 --follow
rpki-rs-test-client 127.0.0.1:323 --assert-min-records 1 --assert-substr 192.0.2.
rpki-rs-test-client 127.0.0.1:3324 --tls --ca-cert certs/ca.pem --server-name localhost
Options:
--steps <N> Number of client.step() calls to perform (default: 1)
--follow Keep calling step() forever
--tls Enable TLS
--ca-cert <PATH> CA certificate PEM file (required in TLS mode)
--server-name <NAME> TLS server name; required when ADDR host is an IP
--client-cert <PATH> Client certificate PEM file (optional, with --client-key)
--client-key <PATH> Client private key PEM file (optional, with --client-cert)
--assert-substr <TEXT> Assert final stable record dump contains substring
--assert-min-records <N> Assert final record count >= N
--print-records Print records after each successful step
--step-timeout-secs <N> Timeout for each step() call in seconds (default: 300)
--progress-every <N> Print apply progress every N updates (default: 10000)
-h, --help Show this help
Not supported by this wrapper:
--version
--timeout
explicit serial bootstrap via session_id/serial
"
);
}
#[derive(Debug, Clone)]
enum TransportConfig {
Tcp,
@@ -207,6 +293,7 @@ fn ensure_tls(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
if matches!(transport, TransportConfig::Tcp) {
*transport = TransportConfig::Tls(TlsConfig::default());
}
match transport {
TransportConfig::Tls(cfg) => Ok(cfg),
TransportConfig::Tcp => Err(io::Error::other("tls configuration unavailable")),
@@ -234,16 +321,26 @@ fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<Tran
}
}
let server_name = cfg
.server_name
.take()
.or_else(|| default_server_name_for_addr(addr))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"TLS mode requires --server-name or parseable host",
)
})?;
let server_name = match cfg.server_name.take() {
Some(name) => name,
None => {
let host = parse_host_from_addr(addr).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"failed to parse host from address",
)
})?;
if host.parse::<IpAddr>().is_ok() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TLS with IP address requires explicit --server-name",
));
}
host
}
};
Ok(TransportConfig::Tls(TlsConfig {
server_name: Some(server_name),
@@ -255,169 +352,231 @@ fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<Tran
}
}
#[derive(Debug, Default)]
struct InMemoryTarget {
records: Vec<Payload>,
timing: Option<Timing>,
announced: u64,
withdrawn: u64,
#[derive(Debug, Default, Clone)]
struct SharedTarget {
inner: Arc<Mutex<TargetState>>,
}
impl InMemoryTarget {
#[derive(Debug)]
struct TargetState {
records: BTreeSet<Payload>,
timing: Option<Timing>,
announced_seen: u64,
withdrawn_seen: u64,
updates_applied_total: u64,
progress_every: u64,
apply_batches: u64,
last_apply_started_at: Option<Instant>,
}
impl Default for TargetState {
fn default() -> Self {
Self {
records: BTreeSet::new(),
timing: None,
announced_seen: 0,
withdrawn_seen: 0,
updates_applied_total: 0,
progress_every: DEFAULT_PROGRESS_EVERY,
apply_batches: 0,
last_apply_started_at: None,
}
}
}
#[derive(Debug, Clone)]
struct TargetSnapshot {
records: Vec<Payload>,
timing: Option<Timing>,
announced_seen: u64,
withdrawn_seen: u64,
updates_applied_total: u64,
apply_batches: u64,
}
impl SharedTarget {
fn new(progress_every: u64) -> Self {
let state = TargetState {
progress_every,
..TargetState::default()
};
Self {
inner: Arc::new(Mutex::new(state)),
}
}
fn snapshot(&self) -> TargetSnapshot {
let guard = self.inner.lock().expect("target mutex poisoned");
TargetSnapshot {
records: guard.records.iter().cloned().collect(),
timing: guard.timing,
announced_seen: guard.announced_seen,
withdrawn_seen: guard.withdrawn_seen,
updates_applied_total: guard.updates_applied_total,
apply_batches: guard.apply_batches,
}
}
fn payload_to_stable_text(payload: &Payload) -> String {
format!("{:?}", payload)
}
}
impl TargetSnapshot {
fn dump_text(&self) -> String {
self.records
.iter()
.map(|p| format!("{:?}", p))
.map(SharedTarget::payload_to_stable_text)
.collect::<Vec<_>>()
.join("\n")
}
}
impl PayloadTarget for InMemoryTarget {
impl PayloadTarget for SharedTarget {
type Update = Vec<(Action, Payload)>;
fn start(&mut self, reset: bool) -> Self::Update {
if reset {
self.records.clear();
let mut guard = self.inner.lock().expect("target mutex poisoned");
println!(
"[target] start reset=true | clearing existing records={}",
guard.records.len()
);
guard.records.clear();
} else {
let guard = self.inner.lock().expect("target mutex poisoned");
println!(
"[target] start reset=false | current records={}",
guard.records.len()
);
}
Vec::new()
}
fn apply(&mut self, update: Self::Update, timing: Timing) -> Result<(), PayloadError> {
let total = update.len() as u64;
{
let mut guard = self.inner.lock().expect("target mutex poisoned");
guard.apply_batches += 1;
guard.last_apply_started_at = Some(Instant::now());
println!(
"[target] apply batch #{} started | updates={} | current records={}",
guard.apply_batches,
total,
guard.records.len()
);
}
let started = Instant::now();
let mut local_processed: u64 = 0;
let progress_every = {
let guard = self.inner.lock().expect("target mutex poisoned");
guard.progress_every
};
let mut guard = self.inner.lock().expect("target mutex poisoned");
for (action, payload) in update {
match action {
Action::Announce => {
self.announced += 1;
if self.records.iter().any(|p| p == &payload) {
guard.announced_seen += 1;
if !guard.records.insert(payload) {
return Err(PayloadError::DuplicateAnnounce);
}
self.records.push(payload);
}
Action::Withdraw => {
self.withdrawn += 1;
if let Some(pos) = self.records.iter().position(|p| p == &payload) {
self.records.swap_remove(pos);
} else {
guard.withdrawn_seen += 1;
if !guard.records.remove(&payload) {
return Err(PayloadError::UnknownWithdraw);
}
}
}
local_processed += 1;
guard.updates_applied_total += 1;
if local_processed % progress_every == 0 || local_processed == total {
println!(
"[target] apply progress | batch_processed={}/{} | total_updates_seen={} | records={} | elapsed={:.2?}",
local_processed,
total,
guard.updates_applied_total,
guard.records.len(),
started.elapsed(),
);
}
}
self.timing = Some(timing);
guard.timing = Some(timing);
println!(
"[target] apply batch complete | updates={} | records={} | announced_seen={} | withdrawn_seen={} | elapsed={:.2?}",
total,
guard.records.len(),
guard.announced_seen,
guard.withdrawn_seen,
started.elapsed(),
);
Ok(())
}
}
#[tokio::main]
async fn main() -> io::Result<()> {
let config = Config::from_args()?;
println!("== rpki_rs_test_client ==");
println!("target : {}", config.addr);
println!("version : {}", config.version);
fn print_step_summary(
step_no: usize,
before: &TargetSnapshot,
after: &TargetSnapshot,
print_records: bool,
) {
println!(
"mode : {}",
match config.mode {
QueryMode::Reset => "reset".to_string(),
QueryMode::SerialAuto => "serial(auto)".to_string(),
QueryMode::Serial { session_id, serial } => {
format!("serial sid={} serial={}", session_id, serial)
"[step] {} ok | records: {} -> {} | delta announce={} withdraw={} | delta updates={} | apply_batches={}",
step_no,
before.records.len(),
after.records.len(),
after.announced_seen.saturating_sub(before.announced_seen),
after.withdrawn_seen.saturating_sub(before.withdrawn_seen),
after
.updates_applied_total
.saturating_sub(before.updates_applied_total),
after.apply_batches,
);
if let Some(timing) = after.timing {
println!(
"[step] {} timing | refresh={} retry={} expire={}",
step_no, timing.refresh, timing.retry, timing.expire
);
}
if print_records {
println!("-- records after step {} --", step_no);
if after.records.is_empty() {
println!("(empty)");
} else {
for rec in &after.records {
println!("{}", SharedTarget::payload_to_stable_text(rec));
}
}
);
println!("steps : {}", config.steps);
println!("follow : {}", config.follow);
println!(
"timeout : {}s (from rpki-rs client IO timeout)",
DEFAULT_TIMEOUT_SECS
);
if config.version != 2 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"rpki-rs v0.18 client API does not expose initial-version override; please use version 2",
));
}
if matches!(config.mode, QueryMode::Serial { .. }) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"rpki-rs v0.18 Client::new cannot bootstrap explicit serial state; use reset mode",
));
}
let stream = connect_stream(&config).await?;
let target = InMemoryTarget::default();
let mut client = Client::new(stream, target, None);
let bootstrap_steps = match config.mode {
QueryMode::SerialAuto if config.steps < 2 => 2,
_ => config.steps,
};
if bootstrap_steps != config.steps {
println!(
"steps adjusted : {} -> {} (serial(auto) needs at least 2 steps)",
config.steps, bootstrap_steps
);
}
for idx in 0..bootstrap_steps {
client.step().await.map_err(|err| {
io::Error::new(err.kind(), format!("step {} failed: {}", idx + 1, err))
})?;
println!("[step] bootstrap {} ok", idx + 1);
}
if config.follow {
println!("[follow] enabled, entering continuous step loop");
let mut step_index = bootstrap_steps;
loop {
step_index += 1;
client.step().await.map_err(|err| {
io::Error::new(err.kind(), format!("step {} failed: {}", step_index, err))
})?;
println!("[step] follow {} ok", step_index);
}
}
let negotiated_state = client.state();
let target = client.into_target();
println!("state : {:?}", negotiated_state);
if let Some(timing) = target.timing {
println!(
"timing : refresh={} retry={} expire={}",
timing.refresh, timing.retry, timing.expire
);
}
println!("records : {}", target.records.len());
println!(
"updates : announce={} withdraw={}",
target.announced, target.withdrawn
);
if config.print_records {
println!("-- records --");
for rec in &target.records {
println!("{:?}", rec);
}
}
run_assertions(&config, &target)?;
println!("[assert] passed");
Ok(())
}
fn run_assertions(config: &Config, target: &InMemoryTarget) -> io::Result<()> {
fn run_assertions(config: &Config, snapshot: &TargetSnapshot) -> io::Result<()> {
if let Some(min) = config.assert_min_records
&& target.records.len() < min
&& snapshot.records.len() < min
{
return Err(io::Error::other(format!(
"assertion failed: records {} < {}",
target.records.len(),
snapshot.records.len(),
min
)));
}
if !config.assert_substr.is_empty() {
let dump = target.dump_text();
let dump = snapshot.dump_text();
for needle in &config.assert_substr {
if !dump.contains(needle) {
return Err(io::Error::other(format!(
@@ -427,6 +586,136 @@ fn run_assertions(config: &Config, target: &InMemoryTarget) -> io::Result<()> {
}
}
}
Ok(())
}
#[tokio::main]
async fn main() -> io::Result<()> {
let config = Config::from_args()?;
println!("== rpki-rs-test-client ==");
println!("target : {}", config.addr);
println!("steps : {}", config.steps);
println!("follow : {}", config.follow);
println!("step_timeout_secs : {}", config.step_timeout_secs);
println!("progress_every : {}", config.progress_every);
match &config.transport {
TransportConfig::Tcp => {
println!("transport : tcp");
}
TransportConfig::Tls(tls) => {
println!("transport : tls");
println!(
"server_name : {}",
tls.server_name.as_deref().unwrap_or("<unset>")
);
println!(
"ca_cert : {}",
tls.ca_cert
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unset>".to_string())
);
}
}
let stream = connect_stream(&config).await?;
let target = SharedTarget::new(config.progress_every);
let inspect = target.clone();
let mut client = Client::new(stream, target, None);
for idx in 0..config.steps {
let step_no = idx + 1;
let before = inspect.snapshot();
println!("[step] {} begin", step_no);
let step_started = Instant::now();
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!(
"step {} timed out after {}s",
step_no, config.step_timeout_secs
),
)
})?
.map_err(|err| io::Error::new(err.kind(), format!("step {} failed: {}", step_no, err)))?;
let after = inspect.snapshot();
print_step_summary(step_no, &before, &after, config.print_records);
println!(
"[step] {} finished in {:.2?}",
step_no,
step_started.elapsed()
);
}
if config.follow {
let mut step_index = config.steps;
println!("[follow] enabled, entering continuous step loop");
loop {
step_index += 1;
let before = inspect.snapshot();
println!("[step] {} begin", step_index);
let step_started = Instant::now();
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!(
"step {} timed out after {}s",
step_index, config.step_timeout_secs
),
)
})?
.map_err(|err| {
io::Error::new(err.kind(), format!("step {} failed: {}", step_index, err))
})?;
let after = inspect.snapshot();
print_step_summary(step_index, &before, &after, config.print_records);
println!(
"[step] {} finished in {:.2?}",
step_index,
step_started.elapsed()
);
}
}
let state = client.state();
let final_snapshot = inspect.snapshot();
println!("state : {:?}", state);
if let Some(timing) = final_snapshot.timing {
println!(
"timing : refresh={} retry={} expire={}",
timing.refresh, timing.retry, timing.expire
);
}
println!("records : {}", final_snapshot.records.len());
println!(
"updates_seen : announce={} withdraw={}",
final_snapshot.announced_seen, final_snapshot.withdrawn_seen
);
println!(
"updates_applied : {}",
final_snapshot.updates_applied_total
);
println!("apply_batches : {}", final_snapshot.apply_batches);
run_assertions(&config, &final_snapshot)?;
println!("[assert] passed");
Ok(())
}
@@ -440,22 +729,26 @@ async fn connect_stream(config: &Config) -> io::Result<DynStream> {
async fn connect_tls_stream(addr: &str, tls: &TlsConfig) -> io::Result<DynStream> {
let stream = TcpStream::connect(addr).await?;
let connector = build_tls_connector(tls)?;
let server_name_str = tls
.server_name
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS server name"))?;
let server_name = ServerName::try_from(server_name_str.clone()).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid TLS server name '{}': {}", server_name_str, err),
)
})?;
let tls_stream = connector.connect(server_name, stream).await.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("TLS handshake failed: {}", err),
)
})?;
Ok(Box::new(tls_stream))
}
@@ -464,10 +757,11 @@ fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
.ca_cert
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS CA cert"))?;
let ca_certs = load_certs(ca_cert_path)?;
let mut roots = RootCertStore::empty();
let (added, _) = roots.add_parsable_certificates(ca_certs);
let (added, _ignored) = roots.add_parsable_certificates(ca_certs);
if added == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -476,6 +770,7 @@ fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
}
let builder = RustlsClientConfig::builder().with_root_certificates(roots);
let cfg = match (&tls.client_cert, &tls.client_key) {
(Some(cert_path), Some(key_path)) => {
let certs = load_certs(cert_path)?;
@@ -490,6 +785,7 @@ fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
(None, None) => builder.with_no_client_auth(),
_ => unreachable!(),
};
Ok(TlsConnector::from(Arc::new(cfg)))
}
@@ -498,12 +794,14 @@ fn load_certs(path: &Path) -> io::Result<Vec<CertificateDer<'static>>> {
let certs = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if certs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("no certs found in {}", path.display()),
));
}
Ok(certs)
}
@@ -519,40 +817,13 @@ fn load_private_key(path: &Path) -> io::Result<PrivateKeyDer<'static>> {
})
}
fn default_server_name_for_addr(addr: &str) -> Option<String> {
fn parse_host_from_addr(addr: &str) -> Option<String> {
if let Some(rest) = addr.strip_prefix('[') {
return rest.split(']').next().map(str::to_string);
}
addr.rsplit_once(':').map(|(host, _)| host.to_string())
}
fn parse_u8_arg(value: &str, name: &str) -> io::Result<u8> {
value.parse::<u8>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}
fn parse_u16_arg(value: &str, name: &str) -> io::Result<u16> {
value.parse::<u16>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}
fn parse_u32_arg(value: &str, name: &str) -> io::Result<u32> {
value.parse::<u32>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}
fn parse_usize_arg(value: &str, name: &str) -> io::Result<usize> {
value.parse::<usize>().map_err(|err| {
io::Error::new(
@@ -561,3 +832,12 @@ fn parse_usize_arg(value: &str, name: &str) -> io::Result<usize> {
)
})
}
fn parse_u64_arg(value: &str, name: &str) -> io::Result<u64> {
value.parse::<u64>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}

View File

@@ -1,48 +1,11 @@
# rtr_debug_client
`rtr_debug_client` 是一个轻量级的 RTR 调试客户端,用于手工联调和协议行为观察。
`rtr_debug_client` 是用于 RTR 协议联调的命令行调试客户端,支持 `TCP`、`TLS`、`SSH` 三种传输。
它适合以下场景:
- 在开发阶段验证 RTR server 的行为
- 发送 `Reset Query` 和 `Serial Query`
- 观察服务端返回的各类 PDU
- 检查会话状态、`session_id`、`serial` 的变化
- 排查 `ErrorReport`、`CacheReset`、`SerialNotify`、`RouterKey`、`ASPA`
- 联调纯 TCP 和 TLS 两种 RTR 传输方式
它不是生产级 router client,而是一个便于调试和观察协议细节的小工具。
## 当前支持的能力
当前版本支持:
- 纯 TCP 连接
- TLS 连接
- TLS 服务端证书校验
- 可选的 TLS 客户端证书认证
- 发送 `Reset Query`
- 发送 `Serial Query`
- 保持长连接持续接收服务端 PDU
- 格式化展示以下 PDU:
- `Serial Notify`
- `Serial Query`
- `Reset Query`
- `Cache Response`
- `IPv4 Prefix`
- `IPv6 Prefix`
- `Router Key`
- `ASPA`
- `End of Data`
- `Cache Reset`
- `Error Report`
- 结构化展示 `ErrorReport`:
- 错误码及语义名称
- encapsulated PDU 的 header 摘要
- encapsulated PDU 原始 hex
- arbitrary text 是否为 UTF-8
- arbitrary text 内容
- 根据 `EndOfData` 的 timing hint 自动轮询
- 收到 `ErrorReport` 后默认暂停自动轮询
- 通过 `--keep-after-error` 保持错误后的自动轮询
它用于:
- 手动发送 `Reset Query`、`Serial Query`
- 持续接收并打印服务端 PDU
- 观察 `session_id`、`serial`、`EndOfData` timing hint、`ErrorReport` 等状态变化
## 构建
@@ -52,8 +15,6 @@ cargo build --bin rtr_debug_client
## 基本用法
基本形式:
```sh
cargo run --bin rtr_debug_client -- <addr> <version> [reset|serial <session_id> <serial>] [options]
```
@@ -62,32 +23,22 @@ cargo run --bin rtr_debug_client -- <addr> <version> [reset|serial <session_id>
- `addr`: `127.0.0.1:323`
- `version`: `1`
- `mode`: `reset`
- `timeout`: `30`
- `poll`: `600`
- `--timeout`: `30`
- `--poll`: `600`
## TCP 示例
发送 `Reset Query`:
## TCP 用法
```sh
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 reset
```
发送 `Serial Query`:
```sh
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 serial 42 100
```
持续观察错误路径:
## TLS 用法
```sh
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 reset --keep-after-error
```
## TLS 示例
只做服务端证书校验:
仅校验服务端证书:
```sh
cargo run --bin rtr_debug_client -- \
@@ -97,7 +48,7 @@ cargo run --bin rtr_debug_client -- \
--server-name localhost
```
双向 TLS 认证:
双向 TLS:
```sh
cargo run --bin rtr_debug_client -- \
@@ -109,127 +60,91 @@ cargo run --bin rtr_debug_client -- \
--client-key tests/fixtures/tls/client-good.key
```
双向 TLS + 错误后继续自动轮询:
## SSH 用法(按 draft-ietf-sidrops-8210bis-25)
`rtr_debug_client --ssh` 采用以下流程:
- SSHv2 连接
- `session` channel
- 请求 `subsystem`,默认 `rpki-rtr`
- 使用 `publickey` 认证
- 强制服务端 host key 校验(`known_hosts` 或 pinned server key 二选一)
### 1. 使用 known_hosts 校验服务端
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:324 1 reset \
--tls \
--ca-cert tests/fixtures/tls/client-ca.crt \
--server-name localhost \
--client-cert tests/fixtures/tls/client-good.crt \
--client-key tests/fixtures/tls/client-good.key \
--keep-after-error
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-known-hosts certs/known_hosts
```
说明:
- 开启 `--tls` 时必须提供 `--ca-cert`
- 如果目标地址本身不适合直接作为 TLS 名称,显式提供 `--server-name`
- 客户端认证必须同时提供 `--client-cert` 和 `--client-key`
### 2. 使用固定服务端公钥校验
## 命令行参数
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-server-key certs/ssh_host_ed25519_key.pub
```
### 3. 自定义 subsystem 名称
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-known-hosts certs/known_hosts \
--ssh-subsystem rpki-rtr
```
## 参数说明
通用参数:
- `--timeout <secs>`:读取 PDU 超时时间(秒)
- `--poll <secs>`:默认自动轮询间隔(秒)
- `--keep-after-error`:收到 `ErrorReport` 后不暂停自动轮询
- `--summary-only`:仅打印摘要,抑制 payload PDU 详细内容
TLS 参数:
- `--tls`
使用 TLS 而不是纯 TCP。
- `--ca-cert <path>`
用于校验服务端证书的 CA 证书文件,PEM 格式。
- `--server-name <name>`
TLS 握手时用于校验证书的服务端名称。
- `--client-cert <path>`
双向 TLS 时使用的客户端证书,PEM 格式。
- `--client-key <path>`
与 `--client-cert` 配套的客户端私钥,PEM 格式。
- `--timeout <secs>`
等待下一个 PDU 的读取超时时间,单位秒。
SSH 参数:
- `--ssh`
- `--ssh-user <name>`
- `--ssh-key <path>`(OpenSSH 私钥)
- `--ssh-subsystem <name>`(默认 `rpki-rtr`)
- `--ssh-known-hosts <path>` 或 `--ssh-server-key <path>`(二选一,必须提供)
- `--poll <secs>`
在尚未拿到 `EndOfData` timing hint 前,默认使用的自动轮询间隔。默认值为 `600` 秒,对齐 draft 第 6 节的默认 Retry Interval。
## SSH 连通性测试建议
- `--keep-after-error`
收到 `ErrorReport` 后不暂停自动轮询。
如果你已经在 Docker 中启动了支持 SSH 的 RTR server,可按以下方式验证:
## 运行中可用命令
1. 先用 `ssh` 命令确认认证与 host key 配置正确(可连通)。
2. 再用 `rtr_debug_client --ssh` 发起连接并发送 `reset`。
3. 观察是否收到 `Cache Response` 和 `EndOfData`。
程序启动后,可以在控制台输入以下命令:
如果 `rtr_debug_client` 报 `failed to request SSH subsystem 'rpki-rtr'`,通常表示服务端未开启对应 subsystem 名称,或名称不一致。
## 运行时交互命令
客户端启动后可在标准输入中使用:
- `help`
显示帮助。
- `state`
打印当前客户端状态。
- `version` / `version <n>`
- `reset`
发送 `Reset Query`。
- `serial`
使用当前 `session_id` 和 `serial` 发送 `Serial Query`。
- `serial <sid> <serial>`
使用显式参数发送 `Serial Query`。
- `timeout`
查看当前读取超时设置。
- `timeout <secs>`
修改读取超时。
- `poll`
查看当前自动轮询间隔、轮询来源以及暂停状态。
- `poll <secs>`
手工覆盖当前轮询间隔。
- `poll pause`
暂停自动轮询。
- `poll resume`
恢复自动轮询。
- `serial` / `serial <sid> <serial>`
- `timeout` / `timeout <secs>`
- `poll` / `poll <secs>` / `poll pause` / `poll resume`
- `keep-after-error`
查看当前是否启用了错误后持续轮询。
- `output` / `output verbose` / `output summary`
- `quit`
退出客户端。
## 自动轮询行为
客户端会保持连接,并周期性地向服务端发起下一次查询。
选择下一次轮询间隔的优先级如下:
1. `retry`,当最近一次 `ErrorReport` 是 `No Data Available` 或 `Transport Failure`
2. `refresh`,如果已经从 `EndOfData` 中拿到
3. 启动参数里的默认轮询间隔
收到 `ErrorReport` 后的默认行为:
- 默认暂停自动轮询
- 连接保持不关,方便继续观察
- 你可以手工输入 `reset`、`serial` 或 `poll resume` 继续
如果带了 `--keep-after-error`:
- 收到 `ErrorReport` 后不会暂停
- 会继续按当前有效轮询间隔自动轮询
特殊情况:
- 当最近一次错误是 `No Data Available` 或 `Transport Failure` 时,恢复自动轮询后会优先参考 `retry`,而不是继续只看 `refresh`
## ErrorReport 展示内容
`ErrorReport` 会展示以下内容:
- 错误码及其语义名称
- encapsulated PDU 长度
- encapsulated PDU 的 header 摘要
- PDU 类型
- version
- length
- field1(按类型解释为 `session_id` 或 `error_code`)
- encapsulated PDU 原始 hex
- arbitrary text 长度
- arbitrary text 是否是 UTF-8
- arbitrary text 内容
这样在排查协议问题时,不需要先手工拆原始 hex,就能快速知道是哪一个请求触发了错误。

View File

@@ -2,11 +2,20 @@ use std::env;
use std::future::pending;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use russh::client;
use russh::keys::{
PrivateKeyWithHashAlg, check_known_hosts_path, load_public_key, load_secret_key,
};
use russh::{ChannelStream, client::Msg as SshClientMsg};
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio::io::{self as tokio_io, AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader, WriteHalf};
use tokio::io::{
self as tokio_io, AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader, ReadBuf, WriteHalf,
};
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, timeout};
use tokio_rustls::TlsConnector;
@@ -19,8 +28,29 @@ use crate::pretty::{parse_end_of_data_info, parse_serial_notify_serial, print_pd
use crate::protocol::{PduHeader, PduType, QueryMode};
use crate::wire::{read_pdu, send_reset_query, send_serial_query};
macro_rules! println {
() => {
::std::println!();
};
($($arg:tt)*) => {{
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f%:z");
::std::println!("[{}] {}", ts, format_args!($($arg)*));
}};
}
macro_rules! eprintln {
() => {
::std::eprintln!();
};
($($arg:tt)*) => {{
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f%:z");
::std::eprintln!("[{}] {}", ts, format_args!($($arg)*));
}};
}
const DEFAULT_READ_TIMEOUT_SECS: u64 = 30;
const DEFAULT_POLL_INTERVAL_SECS: u64 = 600;
const DEFAULT_SSH_SUBSYSTEM_NAME: &str = "rpki-rtr";
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
@@ -60,7 +90,9 @@ async fn main() -> io::Result<()> {
}
}
println!();
print_help();
if config.output_mode == OutputMode::Verbose {
print_help();
}
let mut state = ClientState::new(
config.version,
@@ -78,7 +110,9 @@ async fn main() -> io::Result<()> {
let stream = loop {
match connect_stream(&config).await {
Ok(stream) => {
println!("connected to {}", config.addr);
if state.output_mode == OutputMode::Verbose {
println!("connected to {}", config.addr);
}
break stream;
}
Err(err) => {
@@ -175,10 +209,12 @@ async fn main() -> io::Result<()> {
return Err(err);
}
Err(_) => {
println!(
"[timeout] no PDU received in {}s, connection kept open.",
state.read_timeout_secs
);
if state.output_mode == OutputMode::Verbose {
println!(
"[timeout] no PDU received in {}s, connection kept open.",
state.read_timeout_secs
);
}
}
}
}
@@ -188,7 +224,9 @@ async fn main() -> io::Result<()> {
if reconnect {
let delay = state.reconnect_delay_secs();
state.current_session_id = None;
println!("[reconnect] transport disconnected, retry after {}s", delay);
if state.output_mode == OutputMode::Verbose {
println!("[reconnect] transport disconnected, retry after {}s", delay);
}
let reconnect_sleep = tokio::time::sleep(Duration::from_secs(delay));
tokio::pin!(reconnect_sleep);
@@ -235,7 +273,9 @@ async fn main() -> io::Result<()> {
}
if reconnect_now {
println!("[reconnect] user requested immediate reconnect");
if state.output_mode == OutputMode::Verbose {
println!("[reconnect] user requested immediate reconnect");
}
}
}
}
@@ -252,28 +292,36 @@ async fn send_resume_query(
state.serial = None;
state.current_session_id = None;
send_reset_query(writer, state.version).await?;
println!("reconnected, send Reset Query (forced)");
if state.output_mode == OutputMode::Verbose {
println!("reconnected, send Reset Query (forced)");
}
return Ok(());
}
match (state.session_id, state.serial) {
(Some(session_id), Some(serial)) => {
println!(
"reconnected, send Serial Query with session_id={}, serial={}",
session_id, serial
);
if state.output_mode == OutputMode::Verbose {
println!(
"reconnected, send Serial Query with session_id={}, serial={}",
session_id, serial
);
}
send_serial_query(writer, state.version, session_id, serial).await?;
}
_ => match mode {
QueryMode::Reset => {
send_reset_query(writer, state.version).await?;
println!("sent Reset Query");
if state.output_mode == OutputMode::Verbose {
println!("sent Reset Query");
}
}
QueryMode::Serial { session_id, serial } => {
state.session_id = Some(*session_id);
state.serial = Some(*serial);
send_serial_query(writer, state.version, *session_id, *serial).await?;
println!("sent Serial Query");
if state.output_mode == OutputMode::Verbose {
println!("sent Serial Query");
}
}
},
}
@@ -317,8 +365,6 @@ async fn handle_incoming_pdu(
state.session_id = Some(session_id);
state.current_session_id = Some(session_id);
println!();
if let Some(eod) = eod {
state.serial = Some(eod.serial);
state.refresh = eod.refresh;
@@ -326,31 +372,44 @@ async fn handle_incoming_pdu(
state.expire = eod.expire;
state.last_error_code = None;
println!(
"updated client state: session_id={}, serial={}",
session_id, eod.serial
);
if let Some(refresh) = eod.refresh {
println!("refresh : {}", refresh);
}
if let Some(retry) = eod.retry {
println!("retry : {}", retry);
}
if let Some(expire) = eod.expire {
println!("expire : {}", expire);
}
state.schedule_next_poll();
println!(
"next auto poll scheduled after {}s",
state.effective_poll_secs()
);
if state.output_mode == OutputMode::Verbose {
println!();
println!(
"updated client state: session_id={}, serial={}",
session_id, eod.serial
);
if let Some(refresh) = eod.refresh {
println!("refresh : {}", refresh);
}
if let Some(retry) = eod.retry {
println!("retry : {}", retry);
}
if let Some(expire) = eod.expire {
println!("expire : {}", expire);
}
println!(
"next auto poll scheduled after {}s",
state.effective_poll_secs()
);
} else {
println!(
"EndOfData: session_id={}, serial={}, next_poll={}s",
session_id,
eod.serial,
state.effective_poll_secs()
);
}
} else {
println!(
"updated client state: session_id={}, serial=<unknown>",
session_id
);
if state.output_mode == OutputMode::Verbose {
println!();
println!(
"updated client state: session_id={}, serial=<unknown>",
session_id
);
} else {
println!("EndOfData: session_id={}, serial=<unknown>", session_id);
}
}
if state.output_mode == OutputMode::SummaryOnly
@@ -363,8 +422,10 @@ async fn handle_incoming_pdu(
state.skipped_payload_pdu_count_in_round = 0;
}
println!("received EndOfData, keep connection open.");
println!();
if state.output_mode == OutputMode::Verbose {
println!("received EndOfData, keep connection open.");
println!();
}
}
PduType::SerialNotify => {
@@ -442,27 +503,35 @@ async fn handle_incoming_pdu(
}
async fn handle_poll_tick(writer: &mut ClientWriter, state: &mut ClientState) -> io::Result<()> {
println!();
println!(
"[auto-poll] timer fired (interval={}s)",
state.effective_poll_secs()
);
if state.output_mode == OutputMode::Verbose {
println!();
println!(
"[auto-poll] timer fired (interval={}s)",
state.effective_poll_secs()
);
}
match (state.session_id, state.serial) {
(Some(session_id), Some(serial)) => {
println!(
"[auto-poll] send Serial Query with session_id={}, serial={}",
session_id, serial
);
if state.output_mode == OutputMode::Verbose {
println!(
"[auto-poll] send Serial Query with session_id={}, serial={}",
session_id, serial
);
}
send_serial_query(writer, state.version, session_id, serial).await?;
}
_ => {
println!("[auto-poll] local state incomplete, send Reset Query");
if state.output_mode == OutputMode::Verbose {
println!("[auto-poll] local state incomplete, send Reset Query");
}
send_reset_query(writer, state.version).await?;
}
}
println!();
if state.output_mode == OutputMode::Verbose {
println!();
}
Ok(())
}
@@ -869,11 +938,30 @@ impl Config {
while let Some(arg) = args.next() {
match arg.as_str() {
"--tls" => {
if matches!(transport, TransportConfig::Tcp) {
"--tls" => match transport {
TransportConfig::Tcp => {
transport = TransportConfig::Tls(TlsConfig::default());
}
}
TransportConfig::Tls(_) => {}
TransportConfig::Ssh(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--tls cannot be used together with --ssh",
));
}
},
"--ssh" => match transport {
TransportConfig::Tcp => {
transport = TransportConfig::Ssh(SshConfig::default());
}
TransportConfig::Ssh(_) => {}
TransportConfig::Tls(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh cannot be used together with --tls",
));
}
},
"--ca-cert" => {
let path = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--ca-cert requires a path")
@@ -901,6 +989,45 @@ impl Config {
})?;
ensure_tls_config(&mut transport)?.server_name = Some(name);
}
"--ssh-user" => {
let user = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--ssh-user requires a value")
})?;
ensure_ssh_config(&mut transport)?.user = Some(user);
}
"--ssh-key" => {
let path = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--ssh-key requires a path")
})?;
ensure_ssh_config(&mut transport)?.private_key = Some(PathBuf::from(path));
}
"--ssh-subsystem" => {
let subsystem = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh-subsystem requires a value",
)
})?;
ensure_ssh_config(&mut transport)?.subsystem = Some(subsystem);
}
"--ssh-known-hosts" => {
let path = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh-known-hosts requires a path",
)
})?;
ensure_ssh_config(&mut transport)?.known_hosts = Some(PathBuf::from(path));
}
"--ssh-server-key" => {
let path = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh-server-key requires a path",
)
})?;
ensure_ssh_config(&mut transport)?.server_key = Some(PathBuf::from(path));
}
"--timeout" => {
let secs = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--timeout requires seconds")
@@ -1034,6 +1161,7 @@ fn should_print_pdu(output_mode: OutputMode, header: &PduHeader) -> bool {
enum TransportConfig {
Tcp,
Tls(TlsConfig),
Ssh(SshConfig),
}
impl TransportConfig {
@@ -1052,6 +1180,17 @@ impl TransportConfig {
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string())
),
Self::Ssh(cfg) => format!(
"ssh (user={}, subsystem={}, host_key_check={})",
cfg.user.as_deref().unwrap_or("<unset>"),
cfg.subsystem
.as_deref()
.unwrap_or(DEFAULT_SSH_SUBSYSTEM_NAME),
cfg.host_key_verification
.as_ref()
.map(HostKeyVerification::describe)
.unwrap_or("<unset>")
),
}
}
}
@@ -1064,14 +1203,62 @@ struct TlsConfig {
client_key: Option<PathBuf>,
}
fn ensure_tls_config(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
if matches!(transport, TransportConfig::Tcp) {
*transport = TransportConfig::Tls(TlsConfig::default());
}
#[derive(Debug, Clone)]
enum HostKeyVerification {
KnownHosts(PathBuf),
PinnedServerKey(PathBuf),
}
impl HostKeyVerification {
fn describe(&self) -> &'static str {
match self {
Self::KnownHosts(_) => "known_hosts",
Self::PinnedServerKey(_) => "pinned_server_key",
}
}
}
#[derive(Debug, Clone, Default)]
struct SshConfig {
user: Option<String>,
private_key: Option<PathBuf>,
subsystem: Option<String>,
known_hosts: Option<PathBuf>,
server_key: Option<PathBuf>,
host_key_verification: Option<HostKeyVerification>,
}
fn ensure_tls_config(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
match transport {
TransportConfig::Tcp => {
*transport = TransportConfig::Tls(TlsConfig::default());
match transport {
TransportConfig::Tls(cfg) => Ok(cfg),
_ => unreachable!(),
}
}
TransportConfig::Tls(cfg) => Ok(cfg),
TransportConfig::Tcp => unreachable!(),
TransportConfig::Ssh(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TLS options cannot be used together with --ssh",
)),
}
}
fn ensure_ssh_config(transport: &mut TransportConfig) -> io::Result<&mut SshConfig> {
match transport {
TransportConfig::Tcp => {
*transport = TransportConfig::Ssh(SshConfig::default());
match transport {
TransportConfig::Ssh(cfg) => Ok(cfg),
_ => unreachable!(),
}
}
TransportConfig::Ssh(cfg) => Ok(cfg),
TransportConfig::Tls(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"SSH options cannot be used together with --tls",
)),
}
}
@@ -1114,6 +1301,68 @@ fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<Tran
client_key: cfg.client_key,
}))
}
TransportConfig::Ssh(mut cfg) => {
let user = cfg.user.take().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"SSH mode requires --ssh-user <name>",
)
})?;
if user.trim().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh-user must not be empty",
));
}
let private_key = cfg.private_key.take().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"SSH mode requires --ssh-key <path>",
)
})?;
if cfg.known_hosts.is_some() && cfg.server_key.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"SSH host key verification must choose one: --ssh-known-hosts or --ssh-server-key",
));
}
let host_key_verification = if let Some(path) = cfg.known_hosts.take() {
HostKeyVerification::KnownHosts(path)
} else if let Some(path) = cfg.server_key.take() {
HostKeyVerification::PinnedServerKey(path)
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"SSH mode requires host key verification: --ssh-known-hosts <path> or --ssh-server-key <path>",
));
};
let subsystem = cfg
.subsystem
.take()
.unwrap_or_else(|| DEFAULT_SSH_SUBSYSTEM_NAME.to_string());
if subsystem.trim().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--ssh-subsystem must not be empty",
));
}
let _ = parse_host_port(addr)?;
Ok(TransportConfig::Ssh(SshConfig {
user: Some(user),
private_key: Some(private_key),
subsystem: Some(subsystem),
known_hosts: None,
server_key: None,
host_key_verification: Some(host_key_verification),
}))
}
}
}
@@ -1121,9 +1370,165 @@ async fn connect_stream(config: &Config) -> io::Result<DynStream> {
match &config.transport {
TransportConfig::Tcp => Ok(Box::new(TcpStream::connect(&config.addr).await?)),
TransportConfig::Tls(tls) => connect_tls_stream(&config.addr, tls).await,
TransportConfig::Ssh(ssh) => connect_ssh_stream(&config.addr, ssh).await,
}
}
#[derive(Debug, Clone)]
struct SshClientHandler {
host: String,
port: u16,
host_key_verification: HostKeyVerification,
}
impl client::Handler for SshClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
match &self.host_key_verification {
HostKeyVerification::KnownHosts(path) => {
check_known_hosts_path(&self.host, self.port, server_public_key, path)
.map_err(Into::into)
}
HostKeyVerification::PinnedServerKey(path) => {
let expected_key = load_public_key(path)?;
Ok(expected_key == *server_public_key)
}
}
}
}
struct SshSessionStream {
channel_stream: ChannelStream<SshClientMsg>,
_session: client::Handle<SshClientHandler>,
}
impl AsyncRead for SshSessionStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.channel_stream).poll_read(cx, buf)
}
}
impl AsyncWrite for SshSessionStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.channel_stream).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.channel_stream).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.channel_stream).poll_shutdown(cx)
}
}
async fn connect_ssh_stream(addr: &str, ssh: &SshConfig) -> io::Result<DynStream> {
let user = ssh
.user
.as_deref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing SSH user"))?;
let private_key_path = ssh
.private_key
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing SSH private key"))?;
let subsystem = ssh
.subsystem
.as_deref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing SSH subsystem"))?;
let host_key_verification = ssh.host_key_verification.clone().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"missing SSH host key verification",
)
})?;
let (host, port) = parse_host_port(addr)?;
let handler = SshClientHandler {
host,
port,
host_key_verification,
};
let session_config = Arc::new(client::Config::default());
let mut session = client::connect(session_config, addr, handler)
.await
.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("SSH handshake failed: {}", err),
)
})?;
let private_key = load_secret_key(private_key_path, None).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"failed to load SSH private key {}: {}",
private_key_path.display(),
err
),
)
})?;
let rsa_hash = session.best_supported_rsa_hash().await.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("failed to negotiate SSH RSA hash: {}", err),
)
})?;
let auth_result = session
.authenticate_publickey(
user.to_string(),
PrivateKeyWithHashAlg::new(Arc::new(private_key), rsa_hash.flatten()),
)
.await
.map_err(|err| {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!("SSH publickey authentication failed: {}", err),
)
})?;
if !auth_result.success() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"SSH publickey authentication rejected by server",
));
}
let channel = session.channel_open_session().await.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("failed to open SSH session channel: {}", err),
)
})?;
channel
.request_subsystem(true, subsystem)
.await
.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("failed to request SSH subsystem '{}': {}", subsystem, err),
)
})?;
let channel_stream = channel.into_stream();
Ok(Box::new(SshSessionStream {
channel_stream,
_session: session,
}))
}
async fn connect_tls_stream(addr: &str, tls: &TlsConfig) -> io::Result<DynStream> {
let stream = TcpStream::connect(addr).await?;
let connector = build_tls_connector(tls)?;
@@ -1219,6 +1624,44 @@ fn default_server_name_for_addr(addr: &str) -> Option<String> {
addr.rsplit_once(':').map(|(host, _port)| host.to_string())
}
fn parse_host_port(addr: &str) -> io::Result<(String, u16)> {
if let Some(rest) = addr.strip_prefix('[') {
let (host, port_part) = rest.split_once("]:").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid address '{}', expected [host]:port", addr),
)
})?;
let port = port_part.parse::<u16>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid port in address '{}': {}", addr, err),
)
})?;
return Ok((host.to_string(), port));
}
let (host, port_part) = addr.rsplit_once(':').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid address '{}', expected host:port", addr),
)
})?;
if host.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid address '{}', host must not be empty", addr),
));
}
let port = port_part.parse::<u16>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid port in address '{}': {}", addr, err),
)
})?;
Ok((host.to_string(), port))
}
fn parse_u64_arg(value: &str, name: &str) -> io::Result<u64> {
let parsed = value.parse::<u64>().map_err(|err| {
io::Error::new(