Enforce authorized_keys from restrictions
This commit is contained in:
+14
-3
@@ -406,13 +406,23 @@ async fn handle_native_user_auth(
|
|||||||
if pending.client.requested_mode == "doctor" {
|
if pending.client.requested_mode == "doctor" {
|
||||||
let check = {
|
let check = {
|
||||||
let locked = state.lock().expect("server state poisoned");
|
let locked = state.lock().expect("server state poisoned");
|
||||||
verify_native_user_auth_from_config(
|
if let Err(err) = verify_native_user_auth_from_config(
|
||||||
&locked.config,
|
&locked.config,
|
||||||
&pending.client,
|
&pending.client,
|
||||||
&pending.server,
|
&pending.server,
|
||||||
&req.auth,
|
&req.auth,
|
||||||
|
Some(peer.ip()),
|
||||||
)
|
)
|
||||||
.context("verify native user auth")?;
|
.context("verify native user auth")
|
||||||
|
{
|
||||||
|
return send_reject_to_client(
|
||||||
|
socket,
|
||||||
|
peer,
|
||||||
|
packet.header.conn_id,
|
||||||
|
&format!("{err:#}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
NativeAuthCheckOkBody {
|
NativeAuthCheckOkBody {
|
||||||
requested_user: pending.client.requested_user.clone(),
|
requested_user: pending.client.requested_user.clone(),
|
||||||
native_auth_enabled: locked.config.native_auth,
|
native_auth_enabled: locked.config.native_auth,
|
||||||
@@ -453,6 +463,7 @@ async fn handle_native_user_auth(
|
|||||||
&pending.client,
|
&pending.client,
|
||||||
&pending.server,
|
&pending.server,
|
||||||
&req.auth,
|
&req.auth,
|
||||||
|
Some(peer.ip()),
|
||||||
)
|
)
|
||||||
.context("verify native user auth")
|
.context("verify native user auth")
|
||||||
.and_then(|_| {
|
.and_then(|_| {
|
||||||
@@ -539,7 +550,7 @@ async fn handle_native_user_auth(
|
|||||||
) = match attached {
|
) = match attached {
|
||||||
Ok(attached) => attached,
|
Ok(attached) => attached,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return send_reject_to_client(socket, peer, packet.header.conn_id, &err.to_string())
|
return send_reject_to_client(socket, peer, packet.header.conn_id, &format!("{err:#}"))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+176
-12
@@ -8,8 +8,10 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::net::IpAddr;
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||||
|
|
||||||
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
||||||
@@ -290,6 +292,7 @@ pub fn verify_native_user_auth(
|
|||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
auth: &NativeUserAuth,
|
auth: &NativeUserAuth,
|
||||||
authorized_keys: &[AuthorizedKey],
|
authorized_keys: &[AuthorizedKey],
|
||||||
|
source_ip: Option<IpAddr>,
|
||||||
) -> Result<AuthorizedKey> {
|
) -> Result<AuthorizedKey> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
auth.public_key_algorithm == "ssh-ed25519",
|
auth.public_key_algorithm == "ssh-ed25519",
|
||||||
@@ -308,7 +311,7 @@ pub fn verify_native_user_auth(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
|
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
|
||||||
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
||||||
authorized.ensure_native_allowed(auth)?;
|
authorized.ensure_native_allowed(auth, source_ip)?;
|
||||||
|
|
||||||
let mut public_key = [0u8; 32];
|
let mut public_key = [0u8; 32];
|
||||||
public_key.copy_from_slice(&auth.public_key);
|
public_key.copy_from_slice(&auth.public_key);
|
||||||
@@ -328,9 +331,10 @@ pub fn verify_native_user_auth_from_config(
|
|||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
auth: &NativeUserAuth,
|
auth: &NativeUserAuth,
|
||||||
|
source_ip: Option<IpAddr>,
|
||||||
) -> Result<AuthorizedKey> {
|
) -> Result<AuthorizedKey> {
|
||||||
let authorized_keys = load_authorized_keys(&config.authorized_keys)?;
|
let authorized_keys = load_authorized_keys(&config.authorized_keys)?;
|
||||||
verify_native_user_auth(client, server, auth, &authorized_keys)
|
verify_native_user_auth(client, server, auth, &authorized_keys, source_ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) {
|
pub fn generate_native_ephemeral() -> (StaticSecret, [u8; 32]) {
|
||||||
@@ -385,13 +389,25 @@ pub fn load_ed25519_identity_with_passphrase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AuthorizedKey {
|
impl AuthorizedKey {
|
||||||
fn ensure_native_allowed(&self, auth: &NativeUserAuth) -> Result<()> {
|
fn ensure_native_allowed(
|
||||||
|
&self,
|
||||||
|
auth: &NativeUserAuth,
|
||||||
|
source_ip: Option<IpAddr>,
|
||||||
|
) -> Result<()> {
|
||||||
if !self.options.unsupported.is_empty() {
|
if !self.options.unsupported.is_empty() {
|
||||||
bail!(
|
bail!(
|
||||||
"authorized key has unsupported options: {}",
|
"authorized key has unsupported options: {}",
|
||||||
self.options.unsupported.join(",")
|
self.options.unsupported.join(",")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(from) = &self.options.from {
|
||||||
|
let Some(source_ip) = source_ip else {
|
||||||
|
bail!("authorized key from= requires source address context");
|
||||||
|
};
|
||||||
|
if !source_matches_from(source_ip, from)? {
|
||||||
|
bail!("authorized key from= does not permit source {source_ip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.options.force_command.is_some() {
|
if self.options.force_command.is_some() {
|
||||||
bail!("authorized key command= is not supported for native Dosh terminal login");
|
bail!("authorized key command= is not supported for native Dosh terminal login");
|
||||||
}
|
}
|
||||||
@@ -560,6 +576,97 @@ fn strip_quotes(raw: &str) -> &str {
|
|||||||
.unwrap_or(raw)
|
.unwrap_or(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn source_matches_from(source: IpAddr, raw: &str) -> Result<bool> {
|
||||||
|
let mut matched_positive = false;
|
||||||
|
for pattern in raw
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
let (negated, pattern) = pattern
|
||||||
|
.strip_prefix('!')
|
||||||
|
.map_or((false, pattern), |stripped| (true, stripped));
|
||||||
|
let matched = source_pattern_matches(source, pattern)?;
|
||||||
|
if negated && matched {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
matched_positive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(matched_positive)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_pattern_matches(source: IpAddr, pattern: &str) -> Result<bool> {
|
||||||
|
if let Some((network, prefix)) = pattern.split_once('/') {
|
||||||
|
let network = IpAddr::from_str(network)
|
||||||
|
.with_context(|| format!("parse authorized_keys from= network {pattern:?}"))?;
|
||||||
|
let prefix = prefix
|
||||||
|
.parse::<u8>()
|
||||||
|
.with_context(|| format!("parse authorized_keys from= prefix {pattern:?}"))?;
|
||||||
|
return cidr_contains(network, prefix, source);
|
||||||
|
}
|
||||||
|
if pattern.contains('*') || pattern.contains('?') {
|
||||||
|
return Ok(glob_matches(pattern, &source.to_string()));
|
||||||
|
}
|
||||||
|
match IpAddr::from_str(pattern) {
|
||||||
|
Ok(ip) => Ok(ip == source),
|
||||||
|
Err(_) => Ok(pattern == source.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cidr_contains(network: IpAddr, prefix: u8, source: IpAddr) -> Result<bool> {
|
||||||
|
match (network, source) {
|
||||||
|
(IpAddr::V4(network), IpAddr::V4(source)) => {
|
||||||
|
anyhow::ensure!(prefix <= 32, "IPv4 from= prefix must be <= 32");
|
||||||
|
let mask = if prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u32::MAX << (32 - prefix)
|
||||||
|
};
|
||||||
|
Ok((u32::from(network) & mask) == (u32::from(source) & mask))
|
||||||
|
}
|
||||||
|
(IpAddr::V6(network), IpAddr::V6(source)) => {
|
||||||
|
anyhow::ensure!(prefix <= 128, "IPv6 from= prefix must be <= 128");
|
||||||
|
let mask = if prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u128::MAX << (128 - prefix)
|
||||||
|
};
|
||||||
|
Ok((u128::from(network) & mask) == (u128::from(source) & mask))
|
||||||
|
}
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_matches(pattern: &str, value: &str) -> bool {
|
||||||
|
let pattern = pattern.as_bytes();
|
||||||
|
let value = value.as_bytes();
|
||||||
|
let (mut p, mut v) = (0usize, 0usize);
|
||||||
|
let mut star = None;
|
||||||
|
let mut star_value = 0usize;
|
||||||
|
while v < value.len() {
|
||||||
|
if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) {
|
||||||
|
p += 1;
|
||||||
|
v += 1;
|
||||||
|
} else if p < pattern.len() && pattern[p] == b'*' {
|
||||||
|
star = Some(p);
|
||||||
|
p += 1;
|
||||||
|
star_value = v;
|
||||||
|
} else if let Some(star_pos) = star {
|
||||||
|
p = star_pos + 1;
|
||||||
|
star_value += 1;
|
||||||
|
v = star_value;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while p < pattern.len() && pattern[p] == b'*' {
|
||||||
|
p += 1;
|
||||||
|
}
|
||||||
|
p == pattern.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
|
pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
|
||||||
let mut cursor = blob;
|
let mut cursor = blob;
|
||||||
let key_type = read_ssh_string(&mut cursor)?;
|
let key_type = read_ssh_string(&mut cursor)?;
|
||||||
@@ -884,11 +991,11 @@ mod tests {
|
|||||||
let authorized =
|
let authorized =
|
||||||
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap();
|
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&user_signing)).unwrap();
|
||||||
|
|
||||||
verify_native_user_auth(&client, &server, &auth, &authorized).unwrap();
|
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
|
||||||
|
|
||||||
let removed =
|
let removed =
|
||||||
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&other_signing)).unwrap();
|
parse_authorized_keys(&ssh_ed25519_authorized_key_line(&other_signing)).unwrap();
|
||||||
assert!(verify_native_user_auth(&client, &server, &auth, &removed).is_err());
|
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -904,7 +1011,7 @@ mod tests {
|
|||||||
let mut tampered = client.clone();
|
let mut tampered = client.clone();
|
||||||
tampered.requested_session = "other".to_string();
|
tampered.requested_session = "other".to_string();
|
||||||
|
|
||||||
assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized).is_err());
|
assert!(verify_native_user_auth(&tampered, &server, &auth, &authorized, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -921,7 +1028,60 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let authorized = parse_authorized_keys(&raw).unwrap();
|
let authorized = parse_authorized_keys(&raw).unwrap();
|
||||||
|
|
||||||
assert!(verify_native_user_auth(&client, &server, &auth, &authorized).is_err());
|
assert!(verify_native_user_auth(&client, &server, &auth, &authorized, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_user_auth_enforces_from_source_patterns() {
|
||||||
|
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
||||||
|
let user_signing = SigningKey::from_bytes(&[9u8; 32]);
|
||||||
|
let client = test_client_hello();
|
||||||
|
let mut server = test_server_hello(&host_signing);
|
||||||
|
sign_server_hello(&host_signing, &client, &mut server).unwrap();
|
||||||
|
let auth = sign_user_auth(&user_signing, &client, &server, Vec::new()).unwrap();
|
||||||
|
let authorized = parse_authorized_keys(&format!(
|
||||||
|
"from=\"127.0.0.1,192.168.1.0/24,!192.168.1.66\" {}",
|
||||||
|
ssh_ed25519_authorized_key_line(&user_signing)
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
verify_native_user_auth(
|
||||||
|
&client,
|
||||||
|
&server,
|
||||||
|
&auth,
|
||||||
|
&authorized,
|
||||||
|
Some("127.0.0.1".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
verify_native_user_auth(
|
||||||
|
&client,
|
||||||
|
&server,
|
||||||
|
&auth,
|
||||||
|
&authorized,
|
||||||
|
Some("192.168.1.42".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
verify_native_user_auth(
|
||||||
|
&client,
|
||||||
|
&server,
|
||||||
|
&auth,
|
||||||
|
&authorized,
|
||||||
|
Some("192.168.1.66".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_native_user_auth(
|
||||||
|
&client,
|
||||||
|
&server,
|
||||||
|
&auth,
|
||||||
|
&authorized,
|
||||||
|
Some("10.0.0.1".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(verify_native_user_auth(&client, &server, &auth, &authorized, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1028,21 +1188,21 @@ mod tests {
|
|||||||
ssh_ed25519_authorized_key_line(&user_signing)
|
ssh_ed25519_authorized_key_line(&user_signing)
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding).is_err());
|
assert!(verify_native_user_auth(&client, &server, &auth, &no_forwarding, None).is_err());
|
||||||
|
|
||||||
let permitted = parse_authorized_keys(&format!(
|
let permitted = parse_authorized_keys(&format!(
|
||||||
"permitopen=\"127.0.0.1:80\" {}",
|
"permitopen=\"127.0.0.1:80\" {}",
|
||||||
ssh_ed25519_authorized_key_line(&user_signing)
|
ssh_ed25519_authorized_key_line(&user_signing)
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
verify_native_user_auth(&client, &server, &auth, &permitted).unwrap();
|
verify_native_user_auth(&client, &server, &auth, &permitted, None).unwrap();
|
||||||
|
|
||||||
let denied = parse_authorized_keys(&format!(
|
let denied = parse_authorized_keys(&format!(
|
||||||
"permitopen=\"127.0.0.1:81\" {}",
|
"permitopen=\"127.0.0.1:81\" {}",
|
||||||
ssh_ed25519_authorized_key_line(&user_signing)
|
ssh_ed25519_authorized_key_line(&user_signing)
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(verify_native_user_auth(&client, &server, &auth, &denied).is_err());
|
assert!(verify_native_user_auth(&client, &server, &auth, &denied, None).is_err());
|
||||||
|
|
||||||
let dynamic = ForwardingRequest {
|
let dynamic = ForwardingRequest {
|
||||||
kind: ForwardingKind::Dynamic,
|
kind: ForwardingKind::Dynamic,
|
||||||
@@ -1052,8 +1212,12 @@ mod tests {
|
|||||||
target_port: None,
|
target_port: None,
|
||||||
};
|
};
|
||||||
let dynamic_auth = sign_user_auth(&user_signing, &client, &server, vec![dynamic]).unwrap();
|
let dynamic_auth = sign_user_auth(&user_signing, &client, &server, vec![dynamic]).unwrap();
|
||||||
assert!(verify_native_user_auth(&client, &server, &dynamic_auth, &no_forwarding).is_err());
|
assert!(
|
||||||
assert!(verify_native_user_auth(&client, &server, &dynamic_auth, &permitted).is_err());
|
verify_native_user_auth(&client, &server, &dynamic_auth, &no_forwarding, None).is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_native_user_auth(&client, &server, &dynamic_auth, &permitted, None).is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_client_hello() -> NativeClientHello {
|
fn test_client_hello() -> NativeClientHello {
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let authorized = parse_authorized_keys(&authorized_line).unwrap();
|
let authorized = parse_authorized_keys(&authorized_line).unwrap();
|
||||||
|
|
||||||
verify_native_user_auth(&client, &server, &auth, &authorized).unwrap();
|
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fake_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
|
fn fake_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
|
||||||
|
|||||||
@@ -223,6 +223,14 @@ fn start_echo_server() -> u16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
|
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
|
||||||
|
write_native_client_auth_with_options(dir, config_path, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_native_client_auth_with_options(
|
||||||
|
dir: &tempfile::TempDir,
|
||||||
|
config_path: &std::path::Path,
|
||||||
|
options: &str,
|
||||||
|
) {
|
||||||
let ssh_dir = dir.path().join(".ssh");
|
let ssh_dir = dir.path().join(".ssh");
|
||||||
fs::create_dir_all(&ssh_dir).unwrap();
|
fs::create_dir_all(&ssh_dir).unwrap();
|
||||||
let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
|
let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
|
||||||
@@ -232,11 +240,13 @@ fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Pa
|
|||||||
private
|
private
|
||||||
.write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF)
|
.write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
let public = private.public_key().to_openssh().unwrap();
|
||||||
dir.path().join("authorized_keys"),
|
let line = if options.is_empty() {
|
||||||
format!("{}\n", private.public_key().to_openssh().unwrap()),
|
public
|
||||||
)
|
} else {
|
||||||
.unwrap();
|
format!("{options} {public}")
|
||||||
|
};
|
||||||
|
fs::write(dir.path().join("authorized_keys"), format!("{line}\n")).unwrap();
|
||||||
|
|
||||||
let config = load_server_config(Some(config_path.to_path_buf())).unwrap();
|
let config = load_server_config(Some(config_path.to_path_buf())).unwrap();
|
||||||
let host_signing = load_or_create_host_key(&config).unwrap();
|
let host_signing = load_or_create_host_key(&config).unwrap();
|
||||||
@@ -556,6 +566,21 @@ fn native_attach_only_smoke() {
|
|||||||
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
|
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_attach_enforces_authorized_keys_from_option() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
write_native_client_auth_with_options(&dir, &config, "from=\"10.99.0.0/16\"");
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let output = native_attach_once(&dir, port);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(!output.status.success(), "stderr={stderr}");
|
||||||
|
assert!(stderr.contains("from="), "stderr={stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_attach_only_smoke_uses_ssh_agent() {
|
fn native_attach_only_smoke_uses_ssh_agent() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user