feat: support workspace-managed SSH repository access
This commit is contained in:
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub struct RepositorySshCredentialCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySshMaterializationAccess {
|
||||
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||
pub host_trust_id: String,
|
||||
pub host_trust_revision: u64,
|
||||
pub access: workspace_api::RepositoryAccessMode,
|
||||
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
|
||||
pub repository_uri: String,
|
||||
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||
#[serde(skip, default)]
|
||||
pub private_key: SensitiveString,
|
||||
#[serde(skip, default)]
|
||||
pub known_hosts_entry: SensitiveString,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ use crate::retention::{
|
||||
};
|
||||
#[cfg(feature = "ws-server")]
|
||||
use crate::runtime::RuntimeSubscriptionRecvError;
|
||||
use crate::ssh_host_key_probe::{
|
||||
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SSH_KEYSCAN_TIMEOUT,
|
||||
SshHostKeyProbeError, SshHostKeyProbeRequest, SshHostKeyProbeResponse,
|
||||
probe_ssh_host_keys_with_program,
|
||||
};
|
||||
use crate::workspace_issuer::{
|
||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION,
|
||||
@@ -58,7 +63,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
#[cfg(feature = "fs-store")]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -181,12 +185,27 @@ fn runtime_http_router_with_optional_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
|
||||
) -> Router {
|
||||
runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
runtime,
|
||||
local_token,
|
||||
workspace_auth,
|
||||
PathBuf::from("ssh-keyscan"),
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
|
||||
ssh_keyscan_program: PathBuf,
|
||||
) -> Router {
|
||||
let state = RuntimeHttpState {
|
||||
runtime,
|
||||
local_token: local_token.map(Arc::<str>::from),
|
||||
workspace_auth: workspace_auth.map(Arc::new),
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
ssh_keyscan_program: Arc::new(ssh_keyscan_program),
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
@@ -220,6 +239,10 @@ fn runtime_http_router_with_optional_auth(
|
||||
"/v1/working-directories/repository-access",
|
||||
post(authorize_working_directory_repository_access),
|
||||
)
|
||||
.route(
|
||||
SSH_HOST_KEY_PROBE_PATH,
|
||||
post(probe_repository_ssh_host_keys),
|
||||
)
|
||||
.route("/v1/repository-refs/observe", post(observe_repository_ref))
|
||||
.route(
|
||||
"/v1/working-directories/{working_directory_id}/sessions",
|
||||
@@ -293,6 +316,7 @@ struct RuntimeHttpState {
|
||||
local_token: Option<Arc<str>>,
|
||||
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
|
||||
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
|
||||
ssh_keyscan_program: Arc<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -811,6 +835,45 @@ async fn authorize_working_directory_repository_access(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn probe_repository_ssh_host_keys(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(_auth): Extension<RuntimeAuthContext>,
|
||||
body: Result<Json<SshHostKeyProbeRequest>, JsonRejection>,
|
||||
) -> RestResult<SshHostKeyProbeResponse> {
|
||||
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
|
||||
let response = probe_ssh_host_keys_with_program(
|
||||
&request,
|
||||
state.ssh_keyscan_program.as_path(),
|
||||
SSH_KEYSCAN_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
SshHostKeyProbeError::InvalidHostname | SshHostKeyProbeError::InvalidPort => {
|
||||
RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"ssh_host_key_probe_invalid_request",
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
SshHostKeyProbeError::Unavailable => RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"ssh_host_key_probe_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
SshHostKeyProbeError::Timeout => RuntimeHttpRestError::new(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"ssh_host_key_probe_timeout",
|
||||
error.to_string(),
|
||||
),
|
||||
SshHostKeyProbeError::Failed { .. } => RuntimeHttpRestError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"ssh_host_key_probe_failed",
|
||||
error.to_string(),
|
||||
),
|
||||
})?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn observe_repository_ref(
|
||||
State(state): State<RuntimeHttpState>,
|
||||
Extension(auth): Extension<RuntimeAuthContext>,
|
||||
@@ -2174,10 +2237,11 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
||||
return Some("workers:create");
|
||||
}
|
||||
if (path == "/v1/working-directories/repository-access"
|
||||
|| path == "/v1/repository-refs/observe")
|
||||
|| path == "/v1/repository-refs/observe"
|
||||
|| path == SSH_HOST_KEY_PROBE_PATH)
|
||||
&& *method == Method::POST
|
||||
{
|
||||
return Some("workdirs:operate");
|
||||
return Some(SSH_HOST_KEY_PROBE_OPERATION);
|
||||
}
|
||||
if path.starts_with("/v1/workdir-sessions")
|
||||
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
|
||||
@@ -2848,6 +2912,10 @@ mod tests {
|
||||
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
|
||||
Some("workdirs:operate")
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
Some(SSH_HOST_KEY_PROBE_OPERATION)
|
||||
);
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
|
||||
Some("workdirs:operate")
|
||||
@@ -2890,6 +2958,7 @@ mod tests {
|
||||
session: session.clone(),
|
||||
},
|
||||
)]))),
|
||||
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
|
||||
};
|
||||
let auth = RuntimeAuthContext {
|
||||
server_id: "server-a".to_string(),
|
||||
@@ -3245,6 +3314,112 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_probe_uses_workdir_operation_capability() {
|
||||
assert_eq!(
|
||||
required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
Some(SSH_HOST_KEY_PROBE_OPERATION)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_runtime_operation(&Method::POST, SSH_HOST_KEY_PROBE_PATH),
|
||||
SSH_HOST_KEY_PROBE_OPERATION
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssh_host_key_probe_rejects_invalid_hostname_before_execution() {
|
||||
let token = "local-token";
|
||||
let app = runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
Runtime::new_memory(),
|
||||
Some(token.to_string()),
|
||||
None,
|
||||
PathBuf::from("/definitely/missing/ssh-keyscan"),
|
||||
);
|
||||
let response = authed_json_request(
|
||||
app,
|
||||
Method::POST,
|
||||
SSH_HOST_KEY_PROBE_PATH,
|
||||
token,
|
||||
&SshHostKeyProbeRequest {
|
||||
hostname: "-oProxyCommand=malicious".to_string(),
|
||||
port: 22,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let error: RuntimeHttpErrorResponse = read_json(response).await;
|
||||
assert_eq!(error.error.code, "ssh_host_key_probe_invalid_request");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn authenticated_ssh_host_key_probe_returns_deduplicated_candidates() {
|
||||
use base64::Engine as _;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let mut blob = Vec::new();
|
||||
blob.extend_from_slice(&11_u32.to_be_bytes());
|
||||
blob.extend_from_slice(b"ssh-ed25519");
|
||||
blob.extend_from_slice(&32_u32.to_be_bytes());
|
||||
blob.extend_from_slice(&[9_u8; 32]);
|
||||
let key = base64::engine::general_purpose::STANDARD.encode(blob);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let program = temp.path().join("ssh-keyscan");
|
||||
let recorded_arguments = temp.path().join("arguments");
|
||||
std::fs::write(
|
||||
&program,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'private diagnostic' >&2\nprintf '%s\\n' 'example.test ssh-ed25519 {key}' '[example.test]:2222 ssh-ed25519 {key}'\n",
|
||||
recorded_arguments.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
|
||||
let token = "local-token";
|
||||
let app = runtime_http_router_with_auth_and_ssh_keyscan_program(
|
||||
Runtime::new_memory(),
|
||||
Some(token.to_string()),
|
||||
None,
|
||||
program,
|
||||
);
|
||||
let body = SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 2222,
|
||||
};
|
||||
|
||||
let unauthenticated = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/repositories/ssh/probe")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let response =
|
||||
authed_json_request(app, Method::POST, SSH_HOST_KEY_PROBE_PATH, token, &body).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(recorded_arguments).unwrap(),
|
||||
"-T\n5\n-p\n2222\n-t\ned25519\nexample.test\n"
|
||||
);
|
||||
let response: SshHostKeyProbeResponse = read_json(response).await;
|
||||
assert_eq!(response.candidates.len(), 1);
|
||||
assert_eq!(response.candidates[0].algorithm, "ssh-ed25519");
|
||||
assert_eq!(
|
||||
response.candidates[0].public_key,
|
||||
format!("ssh-ed25519 {key}")
|
||||
);
|
||||
assert!(response.candidates[0].fingerprint.starts_with("SHA256:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_errors_use_typed_rest_error_shape() {
|
||||
let token = "local-token";
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod resource;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub mod retention;
|
||||
mod runtime;
|
||||
pub mod ssh_host_key_probe;
|
||||
pub mod worker_backend;
|
||||
pub mod worker_source;
|
||||
pub mod working_directory;
|
||||
|
||||
@@ -30,8 +30,8 @@ use worker_runtime::workspace_issuer::{
|
||||
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
||||
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
|
||||
add_workspace_issuer_trust, replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
|
||||
validate_workspace_issuer_trust_records,
|
||||
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
|
||||
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
|
||||
};
|
||||
use worker_runtime::{Runtime, RuntimeOptions};
|
||||
|
||||
@@ -236,6 +236,33 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
factory = factory.with_resource_client(client.clone());
|
||||
backend_resource_client = Some(client);
|
||||
}
|
||||
let mut workspace_backend_resource_clients: Vec<(
|
||||
String,
|
||||
Arc<dyn worker_runtime::resource::BackendResourceClient>,
|
||||
)> = Vec::new();
|
||||
if config.backend_resource_endpoint.is_none()
|
||||
&& let Some(identity) = runtime_auth.identity.as_ref()
|
||||
{
|
||||
for workspace_issuer in runtime_auth
|
||||
.workspace_issuers
|
||||
.iter()
|
||||
.filter(|issuer| issuer.state == WorkspaceIssuerTrustState::Active)
|
||||
{
|
||||
let endpoint = workspace_backend_resource_endpoint(
|
||||
&workspace_issuer.backend_url,
|
||||
&workspace_issuer.workspace_id,
|
||||
);
|
||||
let client = Arc::new(
|
||||
worker_runtime::resource::HttpBackendResourceClient::new(
|
||||
endpoint,
|
||||
config.backend_resource_token.clone(),
|
||||
)
|
||||
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
|
||||
);
|
||||
workspace_backend_resource_clients
|
||||
.push((workspace_issuer.workspace_id.clone(), client));
|
||||
}
|
||||
}
|
||||
let backend = Arc::new(
|
||||
WorkerRuntimeExecutionBackend::new(factory)
|
||||
.map_err(ProcessError::WorkerAdapter)?
|
||||
@@ -267,14 +294,31 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some(identity) = runtime_auth.identity.as_ref() {
|
||||
runtime
|
||||
.bind_runtime_identity(&identity.identity_id)
|
||||
.map_err(ProcessError::Runtime)?;
|
||||
}
|
||||
if let Some(client) = backend_resource_client {
|
||||
runtime
|
||||
.install_backend_resource_client(client)
|
||||
.map_err(ProcessError::Runtime)?;
|
||||
}
|
||||
for (workspace_id, client) in workspace_backend_resource_clients {
|
||||
runtime
|
||||
.install_workspace_backend_resource_client(workspace_id, client)
|
||||
.map_err(ProcessError::Runtime)?;
|
||||
}
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
fn workspace_backend_resource_endpoint(backend_url: &str, workspace_id: &str) -> String {
|
||||
format!(
|
||||
"{}/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
|
||||
backend_url.trim_end_matches('/'),
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||
RuntimeOptions {
|
||||
display_name: config.display_name.clone(),
|
||||
@@ -1521,6 +1565,42 @@ mod tests {
|
||||
assert_eq!(error, "unknown auth command `trust-server`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_startup_binds_auth_identity_before_resource_use() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut config = ProcessConfig {
|
||||
fs_root: Some(temp.path().to_path_buf()),
|
||||
..ProcessConfig::default().unwrap()
|
||||
};
|
||||
config.http.store = RuntimeHttpStoreSelection::Memory;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-startup").unwrap();
|
||||
write_runtime_auth_file(
|
||||
&runtime_auth_path(&config),
|
||||
&RuntimeAuthFile {
|
||||
identity: Some(identity),
|
||||
workspace_issuers: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime = build_runtime(&config).unwrap();
|
||||
|
||||
runtime.bind_runtime_identity("runtime-startup").unwrap();
|
||||
assert!(runtime.bind_runtime_identity("other-runtime").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_resource_endpoints_are_derived_per_issuer() {
|
||||
assert_eq!(
|
||||
workspace_backend_resource_endpoint("https://backend.example/", "workspace-a"),
|
||||
"https://backend.example/api/runtime/v1/workspaces/workspace-a/resources/fetch"
|
||||
);
|
||||
assert_ne!(
|
||||
workspace_backend_resource_endpoint("https://backend.example", "workspace-a"),
|
||||
workspace_backend_resource_endpoint("https://backend.example", "workspace-b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_store_disables_runtime_catalog_persistence() {
|
||||
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
||||
|
||||
@@ -13,16 +13,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
|
||||
"application/vnd.yoi.repository-ssh-access+json";
|
||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
||||
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecretCandidate {
|
||||
pub credential_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
impl Drop for RepositorySshAccessSecretCandidate {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecretCandidate")
|
||||
.field("credential_id", &self.credential_id)
|
||||
.field("credential_revision", &self.credential_revision)
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct RepositorySshAccessSecret {
|
||||
pub private_key: String,
|
||||
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
|
||||
pub known_hosts_entry: String,
|
||||
}
|
||||
|
||||
impl Drop for RepositorySshAccessSecret {
|
||||
fn drop(&mut self) {
|
||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +56,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RepositorySshAccessSecret")
|
||||
.field("private_key", &"[REDACTED]")
|
||||
.field("credential_candidates", &self.credential_candidates)
|
||||
.field("known_hosts_entry", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
@@ -142,6 +167,8 @@ pub enum BackendResourceError {
|
||||
Oversized { max_bytes: u64, actual_bytes: u64 },
|
||||
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
||||
ContentTypeMismatch { expected: String, actual: String },
|
||||
#[error("backend resource fetch timed out")]
|
||||
Timeout,
|
||||
#[error("backend resource transport failed: {message}")]
|
||||
Transport { message: String },
|
||||
#[error("backend resource response is invalid: {message}")]
|
||||
@@ -163,6 +190,7 @@ pub struct HttpBackendResourceClient {
|
||||
bearer_token: Option<String>,
|
||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<String>,
|
||||
request_timeout: std::time::Duration,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -174,10 +202,16 @@ impl HttpBackendResourceClient {
|
||||
bearer_token,
|
||||
request_source_signer: None,
|
||||
request_source_audience: None,
|
||||
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_runtime_request_source(
|
||||
mut self,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
@@ -209,6 +243,7 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
let mut builder = self
|
||||
.client
|
||||
.post(endpoint.clone())
|
||||
.timeout(self.request_timeout)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body.clone());
|
||||
if let Some(signer) = self.request_source_signer.as_ref() {
|
||||
@@ -239,12 +274,15 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| BackendResourceError::Transport {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
let response = builder.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
BackendResourceError::Timeout
|
||||
} else {
|
||||
BackendResourceError::Transport {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<BackendResourceFetchResponse>()
|
||||
@@ -382,6 +420,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-server")]
|
||||
#[tokio::test]
|
||||
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
futures::future::pending::<()>().await;
|
||||
drop(stream);
|
||||
});
|
||||
let base_url = format!("http://{address}");
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let handle = handle_for(b"archive-bytes");
|
||||
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
|
||||
.with_request_timeout(std::time::Duration::from_millis(25))
|
||||
.with_runtime_request_source(&identity, base_url);
|
||||
|
||||
let error = client
|
||||
.fetch_resource(BackendResourceFetchRequest {
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
handle,
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
server.abort();
|
||||
assert_eq!(error, BackendResourceError::Timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
|
||||
let secret = RepositorySshAccessSecret {
|
||||
credential_candidates: vec![RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 2,
|
||||
private_key: "PRIVATE KEY secret bytes".to_string(),
|
||||
}],
|
||||
known_hosts_entry: "host key secret bytes".to_string(),
|
||||
};
|
||||
|
||||
let debug = format!("{secret:?}");
|
||||
assert!(debug.contains("credential-1"));
|
||||
assert!(!debug.contains("secret bytes"));
|
||||
assert_eq!(debug.matches("[REDACTED]").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_verification_detects_digest_mismatch() {
|
||||
let bytes = b"archive-bytes";
|
||||
|
||||
@@ -189,6 +189,23 @@ impl Runtime {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_workspace_backend_resource_client(
|
||||
&self,
|
||||
workspace_id: impl Into<String>,
|
||||
client: Arc<dyn BackendResourceClient>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Backend resource client Workspace id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
self.lock()?
|
||||
.workspace_backend_resource_clients
|
||||
.insert(workspace_id, BackendResourceClientRef(client));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create or restore a filesystem-backed Runtime.
|
||||
///
|
||||
/// The store is scoped by `options.root`; if the directory already exists,
|
||||
@@ -439,26 +456,51 @@ impl Runtime {
|
||||
&self,
|
||||
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
|
||||
if ssh.credential_candidates.is_empty() {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Repository SSH access requires at least one credential candidate".to_string(),
|
||||
));
|
||||
}
|
||||
if ssh
|
||||
.credential_candidates
|
||||
.iter()
|
||||
.all(|candidate| !candidate.private_key.expose().is_empty())
|
||||
&& !ssh.known_hosts_entry.expose().is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let (client, runtime_id) = {
|
||||
let state = self.lock()?;
|
||||
let client = state.backend_resource_client.clone().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Backend Repository access resource client is unavailable".to_string(),
|
||||
)
|
||||
})?;
|
||||
let client = state
|
||||
.workspace_backend_resource_clients
|
||||
.get(&ssh.secret_resource.workspace_id)
|
||||
.cloned()
|
||||
.or_else(|| state.backend_resource_client.clone())
|
||||
.ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(format!(
|
||||
"Backend Repository access resource client is unavailable for Workspace `{}`",
|
||||
ssh.secret_resource.workspace_id
|
||||
))
|
||||
})?;
|
||||
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
|
||||
})?;
|
||||
(client, runtime_id)
|
||||
};
|
||||
tracing::info!(
|
||||
target: "yoi::repository_access",
|
||||
event = "repository_access_resource_fetch_started",
|
||||
workspace_id = %ssh.secret_resource.workspace_id,
|
||||
resource_id = %ssh.secret_resource.resource_id,
|
||||
runtime_id = %runtime_id,
|
||||
credential_candidate_count = ssh.credential_candidates.len(),
|
||||
"fetching Repository SSH access resource from Workspace Backend"
|
||||
);
|
||||
let mut response = client
|
||||
.0
|
||||
.fetch_resource(BackendResourceFetchRequest {
|
||||
handle: ssh.secret_resource.clone(),
|
||||
runtime_id,
|
||||
runtime_id: runtime_id.clone(),
|
||||
worker_id: None,
|
||||
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
|
||||
})
|
||||
@@ -481,10 +523,40 @@ impl Runtime {
|
||||
"Backend Repository SSH access resource payload was invalid".to_string(),
|
||||
)
|
||||
})?;
|
||||
ssh.private_key =
|
||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
|
||||
if secret.credential_candidates.len() != ssh.credential_candidates.len()
|
||||
|| secret
|
||||
.credential_candidates
|
||||
.iter()
|
||||
.zip(&ssh.credential_candidates)
|
||||
.any(|(secret, metadata)| {
|
||||
secret.credential_id != metadata.credential_id
|
||||
|| secret.credential_revision != metadata.credential_revision
|
||||
})
|
||||
{
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Backend Repository SSH access resource credential metadata was invalid"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
for (candidate, secret) in ssh
|
||||
.credential_candidates
|
||||
.iter_mut()
|
||||
.zip(&mut secret.credential_candidates)
|
||||
{
|
||||
candidate.private_key =
|
||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
|
||||
}
|
||||
ssh.known_hosts_entry =
|
||||
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
|
||||
tracing::info!(
|
||||
target: "yoi::repository_access",
|
||||
event = "repository_access_resource_fetch_succeeded",
|
||||
workspace_id = %ssh.secret_resource.workspace_id,
|
||||
resource_id = %ssh.secret_resource.resource_id,
|
||||
runtime_id = %runtime_id,
|
||||
credential_candidate_count = ssh.credential_candidates.len(),
|
||||
"fetched Repository SSH access resource from Workspace Backend"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -492,10 +564,22 @@ impl Runtime {
|
||||
&self,
|
||||
mut request: WorkingDirectoryRepositoryAccessRequest,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let materialization_runtime_id = request.materialization.runtime_id.clone();
|
||||
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
|
||||
})?;
|
||||
self.resolve_repository_access_resource(ssh).await?;
|
||||
if let Err(error) = self.resolve_repository_access_resource(ssh).await {
|
||||
tracing::warn!(
|
||||
target: "yoi::repository_access",
|
||||
event = "repository_access_resource_fetch_failed",
|
||||
workspace_id = %ssh.secret_resource.workspace_id,
|
||||
resource_id = %ssh.secret_resource.resource_id,
|
||||
runtime_id = %materialization_runtime_id,
|
||||
error = %error,
|
||||
"failed to fetch Repository SSH access resource from Workspace Backend"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
self.authorize_working_directory_repository_access(request)
|
||||
}
|
||||
|
||||
@@ -2428,6 +2512,7 @@ struct RuntimeState {
|
||||
status: RuntimeStatus,
|
||||
execution_backend: Option<WorkerExecutionBackendRef>,
|
||||
backend_resource_client: Option<BackendResourceClientRef>,
|
||||
workspace_backend_resource_clients: BTreeMap<String, BackendResourceClientRef>,
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: u64,
|
||||
workers: BTreeMap<WorkerId, WorkerRecord>,
|
||||
@@ -2458,6 +2543,7 @@ impl RuntimeState {
|
||||
status: RuntimeStatus::Running,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
workspace_backend_resource_clients: BTreeMap::new(),
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
@@ -2489,6 +2575,7 @@ impl RuntimeState {
|
||||
status: RuntimeStatus::Running,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
workspace_backend_resource_clients: BTreeMap::new(),
|
||||
#[cfg(feature = "fs-store")]
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
@@ -2552,6 +2639,7 @@ impl RuntimeState {
|
||||
status: persisted.status,
|
||||
execution_backend: None,
|
||||
backend_resource_client: None,
|
||||
workspace_backend_resource_clients: BTreeMap::new(),
|
||||
next_diagnostic_id,
|
||||
workers,
|
||||
config_bundles: BTreeMap::new(),
|
||||
@@ -3344,6 +3432,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
|
||||
"repository_access_credential_unavailable",
|
||||
"Repository access credential lease is unavailable or already consumed",
|
||||
),
|
||||
BackendResourceError::Timeout => (
|
||||
"repository_access_resource_fetch_timeout",
|
||||
"Timed out while fetching Repository SSH access from Workspace Backend",
|
||||
),
|
||||
BackendResourceError::Transport { .. } => (
|
||||
"repository_access_provider_unavailable",
|
||||
"Repository access credential provider is unavailable",
|
||||
@@ -3615,8 +3707,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
|
||||
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
|
||||
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
|
||||
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
|
||||
WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||
WorkspaceApiRef,
|
||||
};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
|
||||
@@ -3670,6 +3763,10 @@ mod tests {
|
||||
BackendResourceError::MissingResource,
|
||||
"repository_access_credential_unavailable",
|
||||
),
|
||||
(
|
||||
BackendResourceError::Timeout,
|
||||
"repository_access_resource_fetch_timeout",
|
||||
),
|
||||
(
|
||||
BackendResourceError::Unauthorized {
|
||||
message: "denied".to_string(),
|
||||
@@ -3933,8 +4030,11 @@ mod tests {
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
credential_candidates: vec![RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: SensitiveString::new("private-key-bytes"),
|
||||
}],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
@@ -3943,7 +4043,6 @@ mod tests {
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: repository_resource_handle(),
|
||||
private_key: SensitiveString::new("private-key-bytes"),
|
||||
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
|
||||
}),
|
||||
}),
|
||||
@@ -4003,20 +4102,35 @@ mod tests {
|
||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||
let handle = repository_resource_handle();
|
||||
runtime
|
||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
private_key: "private-key-bytes".to_string(),
|
||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}))
|
||||
.install_workspace_backend_resource_client(
|
||||
"workspace-1",
|
||||
Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||
.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
credential_candidates: vec![
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: "private-key-bytes-1".to_string(),
|
||||
},
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
private_key: "private-key-bytes-2".to_string(),
|
||||
},
|
||||
],
|
||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let request = WorkingDirectoryRepositoryAccessRequest {
|
||||
working_directory_id: "working-directory-1".to_string(),
|
||||
@@ -4028,8 +4142,18 @@ mod tests {
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
credential_candidates: vec![
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
@@ -4038,7 +4162,6 @@ mod tests {
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: handle,
|
||||
private_key: SensitiveString::default(),
|
||||
known_hosts_entry: SensitiveString::default(),
|
||||
}),
|
||||
},
|
||||
@@ -4059,7 +4182,23 @@ mod tests {
|
||||
let accesses = backend.repository_accesses.lock().unwrap();
|
||||
assert_eq!(accesses.len(), 1);
|
||||
let access = accesses[0].materialization.ssh.as_ref().unwrap();
|
||||
assert_eq!(access.private_key.expose(), "private-key-bytes");
|
||||
assert_eq!(access.credential_candidates.len(), 2);
|
||||
assert_eq!(
|
||||
access.credential_candidates[0].credential_id,
|
||||
"credential-1"
|
||||
);
|
||||
assert_eq!(
|
||||
access.credential_candidates[1].credential_id,
|
||||
"credential-2"
|
||||
);
|
||||
assert_eq!(
|
||||
access.credential_candidates[0].private_key.expose(),
|
||||
"private-key-bytes-1"
|
||||
);
|
||||
assert_eq!(
|
||||
access.credential_candidates[1].private_key.expose(),
|
||||
"private-key-bytes-2"
|
||||
);
|
||||
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
|
||||
}
|
||||
|
||||
@@ -4072,20 +4211,35 @@ mod tests {
|
||||
runtime.bind_runtime_identity("runtime-1").unwrap();
|
||||
let handle = repository_resource_handle();
|
||||
runtime
|
||||
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
private_key: "create-private-key-bytes".to_string(),
|
||||
known_hosts_entry: "create-known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}))
|
||||
.install_workspace_backend_resource_client(
|
||||
"workspace-1",
|
||||
Arc::new(TestRepositoryResourceClient {
|
||||
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
|
||||
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
|
||||
resource_id: handle.resource_id.clone(),
|
||||
digest: handle.digest.clone(),
|
||||
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|
||||
.to_string(),
|
||||
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
|
||||
credential_candidates: vec![
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: "create-private-key-bytes-1".to_string(),
|
||||
},
|
||||
crate::resource::RepositorySshAccessSecretCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
private_key: "create-private-key-bytes-2".to_string(),
|
||||
},
|
||||
],
|
||||
known_hosts_entry: "create-known-hosts-entry".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let request = WorkingDirectoryRequest {
|
||||
repository: WorkingDirectoryRepository {
|
||||
@@ -4109,8 +4263,18 @@ mod tests {
|
||||
config_projection_digest: "sha256:projection".to_string(),
|
||||
cache_generation: 0,
|
||||
ssh: Some(RepositorySshMaterializationAccess {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
credential_candidates: vec![
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-1".to_string(),
|
||||
credential_revision: 1,
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
RepositorySshCredentialCandidate {
|
||||
credential_id: "credential-2".to_string(),
|
||||
credential_revision: 3,
|
||||
private_key: SensitiveString::default(),
|
||||
},
|
||||
],
|
||||
host_trust_id: "host-trust-1".to_string(),
|
||||
host_trust_revision: 1,
|
||||
access: workspace_api::RepositoryAccessMode::ReadOnly,
|
||||
@@ -4119,7 +4283,6 @@ mod tests {
|
||||
repository_source_fingerprint: "sha256:source".to_string(),
|
||||
repository_uri: "ssh://git@example.test/repo.git".to_string(),
|
||||
secret_resource: handle,
|
||||
private_key: SensitiveString::default(),
|
||||
known_hosts_entry: SensitiveString::default(),
|
||||
}),
|
||||
}),
|
||||
@@ -4138,7 +4301,14 @@ mod tests {
|
||||
.as_ref()
|
||||
.and_then(|materialization| materialization.ssh.as_ref())
|
||||
.unwrap();
|
||||
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
|
||||
assert_eq!(
|
||||
access.credential_candidates[0].private_key.expose(),
|
||||
"create-private-key-bytes-1"
|
||||
);
|
||||
assert_eq!(
|
||||
access.credential_candidates[1].private_key.expose(),
|
||||
"create-private-key-bytes-2"
|
||||
);
|
||||
assert_eq!(
|
||||
access.known_hosts_entry.expose(),
|
||||
"create-known-hosts-entry"
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Side-effect-free SSH host key discovery for Repository trust enrollment.
|
||||
//!
|
||||
//! Probing only observes public host keys. It does not persist trust, use clone
|
||||
//! credentials, or authenticate to the target host.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub const SSH_HOST_KEY_PROBE_PATH: &str = "/v1/repositories/ssh/probe";
|
||||
pub const SSH_HOST_KEY_PROBE_OPERATION: &str = "workdirs:operate";
|
||||
pub(crate) const SSH_KEYSCAN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS: &str = "5";
|
||||
const MAX_SSH_KEYSCAN_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
const MAX_PROBE_CANDIDATES: usize = 32;
|
||||
const MAX_DIAGNOSTIC_BYTES: usize = 256;
|
||||
|
||||
/// `POST /v1/repositories/ssh/probe` request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyProbeRequest {
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// One public host key observed by an SSH host key probe.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyCandidate {
|
||||
/// Canonical OpenSSH public key text (`algorithm base64-key`), without a host prefix.
|
||||
pub public_key: String,
|
||||
/// OpenSSH public key algorithm name.
|
||||
pub algorithm: String,
|
||||
/// OpenSSH SHA-256 fingerprint (`SHA256:base64-digest`).
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
/// `POST /v1/repositories/ssh/probe` response.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshHostKeyProbeResponse {
|
||||
pub candidates: Vec<SshHostKeyCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SshHostKeyProbeError {
|
||||
#[error("SSH host key probe hostname is invalid")]
|
||||
InvalidHostname,
|
||||
#[error("SSH host key probe port must be greater than zero")]
|
||||
InvalidPort,
|
||||
#[error("SSH host key probe executable is unavailable")]
|
||||
Unavailable,
|
||||
#[error("SSH host key probe timed out")]
|
||||
Timeout,
|
||||
#[error("SSH host key probe failed: {diagnostic}")]
|
||||
Failed { diagnostic: String },
|
||||
}
|
||||
|
||||
/// Observe the target's public Ed25519 host keys without persisting trust or using credentials.
|
||||
pub async fn probe_ssh_host_keys(
|
||||
request: &SshHostKeyProbeRequest,
|
||||
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||
probe_ssh_host_keys_with_program(request, Path::new("ssh-keyscan"), SSH_KEYSCAN_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub(crate) async fn probe_ssh_host_keys_with_program(
|
||||
request: &SshHostKeyProbeRequest,
|
||||
program: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||
validate_request(request)?;
|
||||
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.args(["-T", SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS])
|
||||
.arg("-p")
|
||||
.arg(request.port.to_string())
|
||||
.args(["-t", "ed25519"])
|
||||
.arg(&request.hostname)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// ssh-keyscan diagnostics are intentionally not returned or retained: they may contain
|
||||
// environment-specific details and are not needed for the public error contract.
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let output = tokio::time::timeout(timeout, command.output())
|
||||
.await
|
||||
.map_err(|_| SshHostKeyProbeError::Timeout)?
|
||||
.map_err(|_| SshHostKeyProbeError::Unavailable)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: bounded_diagnostic(format!(
|
||||
"ssh-keyscan exited unsuccessfully ({})",
|
||||
output.status
|
||||
)),
|
||||
});
|
||||
}
|
||||
if output.stdout.len() > MAX_SSH_KEYSCAN_OUTPUT_BYTES {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: "ssh-keyscan output exceeded the probe limit".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let candidates = parse_ssh_keyscan_output(&output.stdout);
|
||||
if candidates.is_empty() {
|
||||
return Err(SshHostKeyProbeError::Failed {
|
||||
diagnostic: "ssh-keyscan returned no valid ssh-ed25519 host keys".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(SshHostKeyProbeResponse { candidates })
|
||||
}
|
||||
|
||||
fn validate_request(request: &SshHostKeyProbeRequest) -> Result<(), SshHostKeyProbeError> {
|
||||
if request.port == 0 {
|
||||
return Err(SshHostKeyProbeError::InvalidPort);
|
||||
}
|
||||
validate_hostname(&request.hostname)
|
||||
}
|
||||
|
||||
fn validate_hostname(hostname: &str) -> Result<(), SshHostKeyProbeError> {
|
||||
if hostname.is_empty()
|
||||
|| hostname.len() > 253
|
||||
|| !hostname.is_ascii()
|
||||
|| hostname.bytes().any(|byte| byte.is_ascii_whitespace())
|
||||
|| hostname.starts_with('-')
|
||||
{
|
||||
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||
}
|
||||
if hostname.parse::<IpAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
|
||||
if hostname.is_empty()
|
||||
|| hostname.split('.').any(|label| {
|
||||
label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| label.starts_with('-')
|
||||
|| label.ends_with('-')
|
||||
|| !label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
})
|
||||
{
|
||||
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_ssh_keyscan_output(output: &[u8]) -> Vec<SshHostKeyCandidate> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for line in output.split(|byte| *byte == b'\n') {
|
||||
let Ok(line) = std::str::from_utf8(line) else {
|
||||
continue;
|
||||
};
|
||||
let mut fields = line.split_ascii_whitespace();
|
||||
let (Some(_host), Some(algorithm), Some(encoded_key)) =
|
||||
(fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if line.trim_start().starts_with('#') || algorithm != "ssh-ed25519" {
|
||||
continue;
|
||||
}
|
||||
let Ok(key_blob) = STANDARD.decode(encoded_key) else {
|
||||
continue;
|
||||
};
|
||||
if !is_ed25519_public_key_blob(&key_blob) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical_key = STANDARD.encode(&key_blob);
|
||||
if !seen.insert(canonical_key.clone()) {
|
||||
continue;
|
||||
}
|
||||
let public_key = format!("{algorithm} {canonical_key}");
|
||||
candidates.push(SshHostKeyCandidate {
|
||||
algorithm: algorithm.to_string(),
|
||||
fingerprint: format!(
|
||||
"SHA256:{}",
|
||||
STANDARD_NO_PAD.encode(Sha256::digest(&key_blob))
|
||||
),
|
||||
public_key,
|
||||
});
|
||||
if candidates.len() == MAX_PROBE_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn is_ed25519_public_key_blob(blob: &[u8]) -> bool {
|
||||
let Some((algorithm, rest)) = take_ssh_string(blob) else {
|
||||
return false;
|
||||
};
|
||||
let Some((public_key, rest)) = take_ssh_string(rest) else {
|
||||
return false;
|
||||
};
|
||||
algorithm == b"ssh-ed25519" && public_key.len() == 32 && rest.is_empty()
|
||||
}
|
||||
|
||||
fn take_ssh_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||
let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize;
|
||||
let value = input.get(4..4usize.checked_add(length)?)?;
|
||||
let rest = input.get(4usize.checked_add(length)?..)?;
|
||||
Some((value, rest))
|
||||
}
|
||||
|
||||
fn bounded_diagnostic(mut diagnostic: String) -> String {
|
||||
if diagnostic.len() <= MAX_DIAGNOSTIC_BYTES {
|
||||
return diagnostic;
|
||||
}
|
||||
let mut end = MAX_DIAGNOSTIC_BYTES;
|
||||
while !diagnostic.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
diagnostic.truncate(end);
|
||||
diagnostic
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encoded_ed25519_key(seed: u8) -> String {
|
||||
let mut blob = Vec::new();
|
||||
blob.extend_from_slice(&("ssh-ed25519".len() as u32).to_be_bytes());
|
||||
blob.extend_from_slice(b"ssh-ed25519");
|
||||
blob.extend_from_slice(&32_u32.to_be_bytes());
|
||||
blob.extend_from_slice(&[seed; 32]);
|
||||
STANDARD.encode(blob)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_validation_rejects_option_injection_and_ambiguous_text() {
|
||||
for hostname in [
|
||||
"",
|
||||
"-example.test",
|
||||
"--help",
|
||||
"example.test other.test",
|
||||
"example.test\nother.test",
|
||||
"example_test",
|
||||
".example.test",
|
||||
"example..test",
|
||||
"example.test:22",
|
||||
"[::1]",
|
||||
"éxample.test",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_hostname(hostname),
|
||||
Err(SshHostKeyProbeError::InvalidHostname),
|
||||
"{hostname:?} must be rejected"
|
||||
);
|
||||
}
|
||||
for hostname in [
|
||||
"localhost",
|
||||
"example.test",
|
||||
"example.test.",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
] {
|
||||
validate_hostname(hostname).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_validation_rejects_zero_port() {
|
||||
assert_eq!(
|
||||
validate_request(&SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 0,
|
||||
}),
|
||||
Err(SshHostKeyProbeError::InvalidPort)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_valid_ed25519_keys_and_deduplicates() {
|
||||
let key = encoded_ed25519_key(7);
|
||||
let other_key = encoded_ed25519_key(8);
|
||||
let output = format!(
|
||||
"# comment\nexample.test ssh-rsa AAAA\nexample.test ssh-ed25519 invalid!\nexample.test ssh-ed25519 {key}\n[example.test]:2222 ssh-ed25519 {key}\nexample.test ssh-ed25519 {other_key}\n"
|
||||
);
|
||||
|
||||
let candidates = parse_ssh_keyscan_output(output.as_bytes());
|
||||
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(candidates[0].algorithm, "ssh-ed25519");
|
||||
assert_eq!(candidates[0].public_key, format!("ssh-ed25519 {key}"));
|
||||
let decoded = STANDARD.decode(key).unwrap();
|
||||
assert_eq!(
|
||||
candidates[0].fingerprint,
|
||||
format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(decoded)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_base64_that_is_not_an_ed25519_wire_key() {
|
||||
let output = format!("example.test ssh-ed25519 {}\n", STANDARD.encode([1_u8; 32]));
|
||||
assert!(parse_ssh_keyscan_output(output.as_bytes()).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unsuccessful_command_does_not_return_stderr() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let program = temp.path().join("ssh-keyscan");
|
||||
std::fs::write(
|
||||
&program,
|
||||
"#!/bin/sh\nprintf 'secret from stderr' >&2\nexit 7\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = probe_ssh_host_keys_with_program(
|
||||
&SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
},
|
||||
&program,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let diagnostic = error.to_string();
|
||||
assert!(matches!(error, SshHostKeyProbeError::Failed { .. }));
|
||||
assert!(!diagnostic.contains("secret"));
|
||||
assert!(diagnostic.len() <= MAX_DIAGNOSTIC_BYTES + "SSH host key probe failed: ".len());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn command_execution_times_out_without_returning_process_diagnostics() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let program = temp.path().join("ssh-keyscan");
|
||||
std::fs::write(
|
||||
&program,
|
||||
"#!/bin/sh\nprintf 'secret from stderr' >&2\nsleep 2\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let request = SshHostKeyProbeRequest {
|
||||
hostname: "example.test".to_string(),
|
||||
port: 22,
|
||||
};
|
||||
|
||||
let error = probe_ssh_host_keys_with_program(&request, &program, Duration::from_millis(20))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, SshHostKeyProbeError::Timeout);
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user